praxis-agent 0.35.0 → 0.36.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 (38) hide show
  1. package/README.md +5 -1
  2. package/dist/application/background-agent-manager.d.ts +16 -6
  3. package/dist/application/background-agent-manager.js +139 -97
  4. package/dist/application/context-engine.d.ts +56 -0
  5. package/dist/application/context-engine.js +129 -0
  6. package/dist/application/session-service.d.ts +8 -4
  7. package/dist/application/session-service.js +528 -434
  8. package/dist/application/subagent-service.js +358 -177
  9. package/dist/application/top-level-agent-manager.d.ts +1 -0
  10. package/dist/application/top-level-agent-manager.js +1025 -403
  11. package/dist/application/transcript-projection.d.ts +33 -0
  12. package/dist/application/transcript-projection.js +110 -0
  13. package/dist/application/turn-lifecycle.d.ts +2 -0
  14. package/dist/application/turn-memory-coordinator.d.ts +43 -0
  15. package/dist/application/turn-memory-coordinator.js +98 -0
  16. package/dist/cli/agents-dashboard.js +10 -5
  17. package/dist/cli/interactive.js +264 -204
  18. package/dist/cli/tui/claude-style.js +154 -130
  19. package/dist/cli/tui/task-panel.js +10 -1
  20. package/dist/cli/tui/theme.d.ts +46 -3
  21. package/dist/cli/tui/theme.js +414 -56
  22. package/dist/cli/tui/tool-permission.js +9 -5
  23. package/dist/cli/tui/tui-interaction-router.d.ts +78 -0
  24. package/dist/cli/tui/tui-interaction-router.js +127 -0
  25. package/dist/cli-runtime.d.ts +2 -2
  26. package/dist/core/agent-orchestration.d.ts +31 -0
  27. package/dist/core/agent-orchestration.js +235 -0
  28. package/dist/core/runtime.d.ts +5 -0
  29. package/dist/core/runtime.js +15 -1
  30. package/dist/persistence/claude-job-store.d.ts +58 -0
  31. package/dist/persistence/claude-job-store.js +483 -1
  32. package/dist/persistence/native-transcript-reader.d.ts +53 -0
  33. package/dist/persistence/native-transcript-reader.js +283 -0
  34. package/dist/persistence/subagent-lifecycle-store.d.ts +41 -8
  35. package/dist/persistence/subagent-lifecycle-store.js +373 -146
  36. package/dist/platform/exclusive-file-lease.d.ts +3 -0
  37. package/dist/platform/exclusive-file-lease.js +3 -0
  38. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { mkdir, readFile, readdir, realpath, rm } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
7
7
  import { resolveClaudePaths } from '../compatibility/claude/paths.js';
8
+ import { isTerminalLifecycleState } from '../core/agent-orchestration.js';
8
9
  import { ClaudeJobStore, isProcessAlive, newClaudeJobIdentity, } from '../persistence/claude-job-store.js';
9
10
  import { resolveDataPlanePaths, } from '../persistence/data-plane.js';
10
11
  import { writeFileAtomically } from '../platform/atomic-write.js';
@@ -235,6 +236,7 @@ export class TopLevelAgentManager {
235
236
  const cwd = await canonicalDirectory(options.cwd ?? this.options.cwd);
236
237
  let identity;
237
238
  let state;
239
+ let execution;
238
240
  for (let attempt = 0; attempt < JOB_CREATE_ATTEMPTS; attempt += 1) {
239
241
  const generated = newClaudeJobIdentity();
240
242
  const sessionId = options.sourceSessionId
@@ -272,7 +274,7 @@ export class TopLevelAgentManager {
272
274
  socketPath: socketPath(this.options.configRoot, generated.id),
273
275
  controlToken: randomBytes(24).toString('hex'),
274
276
  };
275
- if (await this.store.create(candidate, {
277
+ const created = await this.store.createExecution(candidate, {
276
278
  version: 1,
277
279
  argv: [...options.argv],
278
280
  resume: options.resumeSessionId !== undefined,
@@ -283,15 +285,53 @@ export class TopLevelAgentManager {
283
285
  ...(options.sourceCheckpoint === undefined
284
286
  ? {}
285
287
  : { sourceCheckpoint: options.sourceCheckpoint }),
286
- })) {
288
+ });
289
+ if (created) {
287
290
  identity = { id: generated.id, sessionId };
288
291
  state = candidate;
292
+ execution = created;
289
293
  break;
290
294
  }
291
295
  }
292
- if (!identity || !state)
296
+ if (!identity || !state || !execution)
293
297
  throw new Error('Could not allocate agent ID');
298
+ const allocatedExecution = execution;
294
299
  let child;
300
+ const failLaunch = async (error) => {
301
+ const message = redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveEnvironmentValues(this.options.environment ?? process.env));
302
+ const secondary = [];
303
+ if (child?.pid !== undefined) {
304
+ try {
305
+ child.kill('SIGTERM');
306
+ }
307
+ catch (killError) {
308
+ secondary.push(killError);
309
+ }
310
+ }
311
+ try {
312
+ await allocatedExecution.finish('failed', (current) => {
313
+ const now = new Date().toISOString();
314
+ return {
315
+ ...clearWorkerFields(current),
316
+ state: 'failed',
317
+ detail: message,
318
+ tempo: 'idle',
319
+ updatedAt: now,
320
+ firstTerminalAt: current.firstTerminalAt ?? now,
321
+ };
322
+ });
323
+ }
324
+ catch (finishError) {
325
+ secondary.push(finishError);
326
+ }
327
+ try {
328
+ await allocatedExecution.release();
329
+ }
330
+ catch (releaseError) {
331
+ secondary.push(releaseError);
332
+ }
333
+ throw new AggregateError([error, ...secondary], 'Could not start background agent');
334
+ };
295
335
  try {
296
336
  child = spawn(this.options.executablePath ?? process.execPath, [this.options.cliPath, '__background-worker', identity.id], {
297
337
  cwd,
@@ -299,76 +339,73 @@ export class TopLevelAgentManager {
299
339
  detached: true,
300
340
  stdio: 'ignore',
301
341
  });
342
+ const spawnedChild = child;
302
343
  await new Promise((resolveSpawn, rejectSpawn) => {
303
344
  const spawned = () => {
304
- child.removeListener('error', failed);
345
+ spawnedChild.removeListener('error', failed);
305
346
  resolveSpawn();
306
347
  };
307
348
  const failed = (error) => {
308
- child.removeListener('spawn', spawned);
349
+ spawnedChild.removeListener('spawn', spawned);
309
350
  rejectSpawn(error);
310
351
  };
311
- child.once('spawn', spawned);
312
- child.once('error', failed);
352
+ spawnedChild.once('spawn', spawned);
353
+ spawnedChild.once('error', failed);
313
354
  });
314
355
  }
315
356
  catch (error) {
316
- const failedAt = new Date().toISOString();
317
- const message = redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveEnvironmentValues(this.options.environment ?? process.env));
318
- await this.store.update(identity.id, (current) => ({
319
- ...clearWorkerFields(current),
320
- state: 'failed',
321
- detail: message,
322
- tempo: 'idle',
323
- updatedAt: failedAt,
324
- firstTerminalAt: failedAt,
325
- }));
326
- throw new Error('Could not start background agent', { cause: error });
357
+ return failLaunch(error);
327
358
  }
328
- const childPid = child.pid;
359
+ const childPid = child?.pid;
329
360
  if (!childPid) {
330
- await this.store.update(identity.id, (current) => ({
331
- ...clearWorkerFields(current),
332
- state: 'failed',
333
- detail: 'worker failed to start',
334
- tempo: 'idle',
361
+ return failLaunch(new Error('worker failed to start'));
362
+ }
363
+ try {
364
+ await execution.update((current) => ({
365
+ ...current,
366
+ pid: childPid,
335
367
  updatedAt: new Date().toISOString(),
336
- firstTerminalAt: new Date().toISOString(),
337
368
  }));
338
- throw new Error('Could not start background agent');
369
+ }
370
+ catch (error) {
371
+ return failLaunch(error);
339
372
  }
340
373
  child.unref();
341
- await this.store.update(identity.id, (current) => ({
342
- ...current,
343
- pid: childPid,
344
- updatedAt: new Date().toISOString(),
345
- }));
374
+ try {
375
+ await execution.handoff();
376
+ }
377
+ catch (error) {
378
+ const secondary = [];
379
+ try {
380
+ child.kill('SIGTERM');
381
+ }
382
+ catch (killError) {
383
+ secondary.push(killError);
384
+ }
385
+ try {
386
+ await execution.release();
387
+ }
388
+ catch (releaseError) {
389
+ secondary.push(releaseError);
390
+ }
391
+ try {
392
+ const reconciliation = await this.store.reconcileOwnerLoss(identity.id);
393
+ if (reconciliation.owned) {
394
+ secondary.push(new Error(`Background agent handoff owner is still held for ${identity.id}`));
395
+ }
396
+ }
397
+ catch (reconcileError) {
398
+ secondary.push(reconcileError);
399
+ }
400
+ throw new AggregateError([error, ...secondary], 'Background agent handoff failed');
401
+ }
346
402
  return identity;
347
403
  }
348
404
  async list(options) {
349
405
  const cwd = options.cwd === undefined
350
406
  ? undefined
351
407
  : await canonicalDirectory(options.cwd);
352
- const states = await this.store.list();
353
- const reconciled = await Promise.all(states.map(async (state) => {
354
- if (state.state !== 'working' || (await this.workerAlive(state))) {
355
- return state;
356
- }
357
- return this.store.update(state.daemonShort, (current) => {
358
- if (current.state !== 'working' || current.pid !== state.pid) {
359
- return current;
360
- }
361
- const now = new Date().toISOString();
362
- return {
363
- ...clearWorkerFields(current),
364
- state: 'failed',
365
- detail: 'worker exited unexpectedly',
366
- tempo: 'idle',
367
- updatedAt: now,
368
- firstTerminalAt: current.firstTerminalAt ?? now,
369
- };
370
- });
371
- }));
408
+ const reconciled = await Promise.all((await this.store.listWithLifecycle()).map(async (view) => (await this.readLifecycleView(view.state.daemonShort, view)).state));
372
409
  const praxis = reconciled
373
410
  .filter((state) => cwd === undefined || state.cwd === cwd)
374
411
  .filter((state) => options.all || state.state === 'working')
@@ -397,6 +434,40 @@ export class TopLevelAgentManager {
397
434
  const native = await this.nativeSessions(cwd, new Set(reconciled.map((state) => state.sessionId)), options.all);
398
435
  return [...praxis, ...native].sort((left, right) => right.startedAt - left.startedAt);
399
436
  }
437
+ async readLifecycleView(id, known, reconcileLegacy = true) {
438
+ const view = known ?? (await this.store.readWithLifecycle(id));
439
+ if (view.legacy || !view.lifecycle) {
440
+ if (!reconcileLegacy)
441
+ return view;
442
+ if (view.state.state !== 'working' ||
443
+ (await this.workerAlive(view.state)))
444
+ return view;
445
+ const state = await this.store.update(id, (current) => {
446
+ if (current.state !== 'working' || current.pid !== view.state.pid)
447
+ return current;
448
+ const now = new Date().toISOString();
449
+ return {
450
+ ...clearWorkerFields(current),
451
+ state: 'failed',
452
+ detail: 'worker exited unexpectedly',
453
+ tempo: 'idle',
454
+ updatedAt: now,
455
+ firstTerminalAt: current.firstTerminalAt ?? now,
456
+ };
457
+ });
458
+ return { state, lifecycleState: 'failed', lifecycle: null, legacy: true };
459
+ }
460
+ if (isTerminalLifecycleState(view.lifecycle.state))
461
+ return view;
462
+ if (view.lifecycle.state === 'queued' &&
463
+ view.state.pid !== undefined &&
464
+ Number.isFinite(Date.parse(view.state.updatedAt)) &&
465
+ Date.now() - Date.parse(view.state.updatedAt) <
466
+ WORKER_REGISTRATION_GRACE_MS &&
467
+ (await this.workerAlive(view.state)))
468
+ return view;
469
+ return (await this.store.reconcileOwnerLoss(id)).view;
470
+ }
400
471
  async nativeSessions(cwd, knownSessionIds, all) {
401
472
  const registryRoot = topLevelAgentProcessRegistryRoot(this.options.configRoot, this.options.dataPlane);
402
473
  let files;
@@ -462,10 +533,13 @@ export class TopLevelAgentManager {
462
533
  }
463
534
  }
464
535
  async stop(id) {
465
- const state = await this.store.read(id);
466
- if (state.state !== 'working') {
467
- throw new Error(`Agent ${id} is not running (state: ${state.state})`);
536
+ let view = await this.readLifecycleView(id);
537
+ if (view.lifecycle && isTerminalLifecycleState(view.lifecycleState)) {
538
+ throw new Error(`Agent ${id} is not running (state: ${view.lifecycleState})`);
468
539
  }
540
+ if (!view.lifecycle && view.state.state !== 'working')
541
+ throw new Error(`Agent ${id} is not running (state: ${view.state.state})`);
542
+ const state = view.state;
469
543
  let stopped = false;
470
544
  if (state.socketPath && state.controlToken) {
471
545
  try {
@@ -481,20 +555,59 @@ export class TopLevelAgentManager {
481
555
  (await this.workerAlive(state))) {
482
556
  process.kill(state.pid, 'SIGTERM');
483
557
  }
484
- const now = new Date().toISOString();
485
- await this.store.update(id, (current) => ({
486
- ...clearWorkerFields(current),
487
- state: 'stopped',
488
- detail: 'stopped',
489
- tempo: 'idle',
490
- updatedAt: now,
491
- firstTerminalAt: current.firstTerminalAt ?? now,
492
- }));
558
+ const deadline = Date.now() + STARTUP_CONTROL_WAIT_MS;
559
+ for (;;) {
560
+ view = await this.readLifecycleView(id);
561
+ if (view.lifecycle) {
562
+ if (view.lifecycleState === 'cancelled')
563
+ return;
564
+ if (isTerminalLifecycleState(view.lifecycleState))
565
+ throw new Error(`Agent ${id} stop ended in state ${view.lifecycleState}`);
566
+ }
567
+ else if (stopped || view.state.state !== 'working') {
568
+ if (stopped || view.state.state === 'stopped') {
569
+ if (stopped && view.state.state === 'working') {
570
+ const now = new Date().toISOString();
571
+ await this.store.update(id, (current) => ({
572
+ ...clearWorkerFields(current),
573
+ state: 'stopped',
574
+ detail: 'stopped',
575
+ tempo: 'idle',
576
+ updatedAt: now,
577
+ firstTerminalAt: current.firstTerminalAt ?? now,
578
+ }));
579
+ }
580
+ return;
581
+ }
582
+ throw new Error(`Agent ${id} stop ended in state ${view.state.state}`);
583
+ }
584
+ if (Date.now() >= deadline) {
585
+ if (view.legacy) {
586
+ const now = new Date().toISOString();
587
+ await this.store.update(id, (current) => ({
588
+ ...clearWorkerFields(current),
589
+ state: 'stopped',
590
+ detail: 'stopped',
591
+ tempo: 'idle',
592
+ updatedAt: now,
593
+ firstTerminalAt: current.firstTerminalAt ?? now,
594
+ }));
595
+ return;
596
+ }
597
+ throw new Error(`Agent ${id} cancellation is still in progress`);
598
+ }
599
+ await waitForSocketRetry();
600
+ }
493
601
  }
494
602
  async attach(id, input, output, signal) {
495
- const state = await this.store.read(id);
496
- if (state.state !== 'working' || !state.socketPath || !state.controlToken) {
497
- throw new Error(`Agent ${id} is not attachable (state: ${state.state})`);
603
+ const view = await this.readLifecycleView(id, undefined, false);
604
+ const state = view.state;
605
+ if ((view.lifecycle &&
606
+ !['queued', 'running', 'waiting'].includes(view.lifecycleState)) ||
607
+ (!view.lifecycle && state.state !== 'working') ||
608
+ !state.socketPath ||
609
+ !state.controlToken) {
610
+ throw new Error(`Agent ${id} is not attachable (state: ${view.lifecycleState})`);
498
611
  }
499
612
  const socket = await connectToWorker(state.socketPath, ATTACH_READY_WAIT_MS, signal);
500
613
  let readyResolve;
@@ -597,17 +710,25 @@ export class TopLevelAgentManager {
597
710
  async request(path, message, timeoutMs) {
598
711
  return new Promise((resolveRequest, reject) => {
599
712
  const socket = createConnection(path);
713
+ let settled = false;
600
714
  const timer = setTimeout(() => {
715
+ if (settled)
716
+ return;
717
+ settled = true;
601
718
  socket.destroy();
602
719
  reject(new Error('Agent control request timed out'));
603
720
  }, timeoutMs);
604
721
  timer.unref?.();
605
722
  const finish = (operation) => {
723
+ if (settled)
724
+ return;
725
+ settled = true;
606
726
  clearTimeout(timer);
607
727
  socket.end();
608
728
  operation();
609
729
  };
610
730
  socket.once('error', (error) => finish(() => reject(error)));
731
+ socket.once('close', () => finish(() => reject(new Error('Agent control request closed'))));
611
732
  socket.once('connect', () => writeWire(socket, message));
612
733
  lines(socket, (response) => finish(() => resolveRequest(response)));
613
734
  });
@@ -625,387 +746,888 @@ export class TopLevelAgentManager {
625
746
  value.sessionId === state.sessionId);
626
747
  }
627
748
  catch {
628
- const createdAt = Date.parse(state.createdAt);
629
- return (Number.isFinite(createdAt) &&
630
- Date.now() - createdAt < WORKER_REGISTRATION_GRACE_MS);
749
+ const updatedAt = Date.parse(state.updatedAt);
750
+ return (Number.isFinite(updatedAt) &&
751
+ Date.now() - updatedAt < WORKER_REGISTRATION_GRACE_MS);
631
752
  }
632
753
  }
633
754
  }
634
755
  export async function runTopLevelAgentWorker(options) {
635
- const store = new ClaudeJobStore(options.configRoot, join(options.configRoot, options.dataPlane === 'native' ? 'state' : 'praxis'));
636
- const initial = await store.read(options.id);
637
- let dispatch = await store.readDispatch(options.id);
638
- const sensitiveValues = sensitiveEnvironmentValues(process.env);
639
- const safeText = (text) => redactSensitiveText(text, sensitiveValues);
640
- const safeErrorMessage = (error) => safeText(error instanceof Error ? error.message : String(error));
641
- if (initial.state !== 'working')
642
- return;
643
- if (!initial.socketPath || !initial.controlToken) {
644
- throw new Error(`Agent ${options.id} has no control endpoint`);
645
- }
646
- const socketFile = initial.socketPath;
647
- const processFile = join(topLevelAgentProcessRegistryRoot(options.configRoot, options.dataPlane), `${process.pid}.json`);
648
- const processStartedAt = Date.now();
649
- const writeProcessStatus = (status) => {
650
- const now = Date.now();
651
- return writeFileAtomically(processFile, `${JSON.stringify({
652
- pid: process.pid,
653
- sessionId: initial.sessionId,
654
- cwd: initial.cwd,
655
- startedAt: processStartedAt,
656
- version: initial.cliVersion,
657
- kind: 'bg',
658
- entrypoint: 'cli',
659
- name: initial.intent,
660
- jobId: options.id,
661
- status,
662
- updatedAt: now,
663
- statusUpdatedAt: now,
664
- })}\n`);
756
+ let stopRequested = false;
757
+ let requestCancellation = async () => undefined;
758
+ let cancellationFinalizer;
759
+ let activeController;
760
+ const captureStop = () => {
761
+ stopRequested = true;
762
+ void requestCancellation().catch(() => undefined);
763
+ void cancellationFinalizer?.().catch(() => undefined);
665
764
  };
666
- await mkdir(dirname(socketFile), { recursive: true });
667
- await rm(socketFile, { force: true });
668
- const clients = new Set();
669
- const broadcast = (message) => {
670
- for (const client of clients)
671
- writeWire(client, message);
765
+ process.once('SIGTERM', captureStop);
766
+ process.once('SIGINT', captureStop);
767
+ const removeCaptureListeners = () => {
768
+ process.removeListener('SIGTERM', captureStop);
769
+ process.removeListener('SIGINT', captureStop);
770
+ };
771
+ const store = new ClaudeJobStore(options.configRoot, join(options.configRoot, options.dataPlane === 'native' ? 'state' : 'praxis'));
772
+ let execution;
773
+ let cleanupClaimed;
774
+ let executionReleased = false;
775
+ const releaseExecution = async () => {
776
+ if (executionReleased || !execution)
777
+ return;
778
+ const failures = [];
779
+ for (let attempt = 0; attempt < 2; attempt += 1) {
780
+ try {
781
+ await execution.release();
782
+ executionReleased = true;
783
+ return;
784
+ }
785
+ catch (error) {
786
+ failures.push(error);
787
+ }
788
+ }
789
+ throw new AggregateError(failures, 'Could not release agent execution');
672
790
  };
673
- let liveTurnText = '';
674
- let sessionStarted = dispatch.resume || dispatch.handoffComplete === true;
675
- let outputWriteError;
676
- let outputWrites = Promise.resolve();
677
- let runtime;
791
+ let claimed = false;
792
+ let postClaimFailure;
793
+ let deferredFailure;
678
794
  try {
679
- runtime = await options.createRuntime((event) => {
680
- if (event.type === 'text-delta') {
681
- const delta = safeText(event.delta);
682
- liveTurnText += delta;
683
- outputWrites = outputWrites
684
- .then(() => store.appendOutput(options.id, delta))
685
- .catch((error) => {
686
- outputWriteError = error;
795
+ const initialView = await store.readWithLifecycle(options.id);
796
+ if (initialView.lifecycle &&
797
+ isTerminalLifecycleState(initialView.lifecycleState) &&
798
+ initialView.lifecycleState !== 'orphaned')
799
+ return;
800
+ if (!initialView.lifecycle && initialView.state.state !== 'working')
801
+ return;
802
+ const initial = initialView.state;
803
+ let dispatch = await store.readDispatch(options.id);
804
+ const sensitiveValues = sensitiveEnvironmentValues(process.env);
805
+ const safeText = (text) => redactSensitiveText(text, sensitiveValues);
806
+ const safeErrorMessage = (error) => safeText(error instanceof Error ? error.message : String(error));
807
+ const hasOwnedContention = (error) => error instanceof Error &&
808
+ error.message ===
809
+ `Agent ${options.id} lifecycle execution is already owned`;
810
+ const claimDeadline = Date.now() + WORKER_REGISTRATION_GRACE_MS;
811
+ for (;;) {
812
+ try {
813
+ execution = await store.claimExecution(options.id);
814
+ claimed = true;
815
+ break;
816
+ }
817
+ catch (error) {
818
+ if (!hasOwnedContention(error) || Date.now() >= claimDeadline)
819
+ throw error;
820
+ await waitForSocketRetry();
821
+ }
822
+ }
823
+ const claimedExecution = execution;
824
+ if (!claimedExecution)
825
+ throw new Error('Worker execution was not claimed');
826
+ let runtime;
827
+ let runtimeInitialization;
828
+ let runtimeInitializationError;
829
+ let runtimeClosed = false;
830
+ let server;
831
+ const clients = new Set();
832
+ let stopClient;
833
+ let outputWriteError;
834
+ let outputWrites = Promise.resolve();
835
+ let liveTurnText = '';
836
+ let sessionStarted = dispatch.resume || dispatch.handoffComplete === true;
837
+ let closing = false;
838
+ let setupStep = Promise.resolve();
839
+ const runSetupStep = (operation) => {
840
+ const next = setupStep.then(operation);
841
+ setupStep = next.then(() => undefined, () => undefined);
842
+ return next;
843
+ };
844
+ let listenPending = false;
845
+ let listenCancelled = false;
846
+ let rejectPendingListen;
847
+ let serverClosePromise;
848
+ let resolveServerClose;
849
+ let rejectServerClose;
850
+ let serverCloseRequested = false;
851
+ let serverCloseStarted = false;
852
+ let serverCloseError;
853
+ let invokeServerClose;
854
+ const settleServerClose = (error) => {
855
+ if (error !== undefined)
856
+ rejectServerClose?.(error);
857
+ else
858
+ resolveServerClose?.();
859
+ resolveServerClose = undefined;
860
+ rejectServerClose = undefined;
861
+ };
862
+ const closeServer = () => {
863
+ if (!server)
864
+ return Promise.resolve();
865
+ const currentServer = server;
866
+ if (!serverClosePromise) {
867
+ serverClosePromise = new Promise((resolveClose, rejectClose) => {
868
+ resolveServerClose = resolveClose;
869
+ rejectServerClose = rejectClose;
687
870
  });
688
- broadcast({ type: 'output', text: delta });
871
+ const closeNow = () => {
872
+ if (serverCloseStarted)
873
+ return;
874
+ serverCloseStarted = true;
875
+ try {
876
+ currentServer.close((error) => {
877
+ if (error) {
878
+ serverCloseError = error;
879
+ settleServerClose(error);
880
+ }
881
+ else
882
+ settleServerClose();
883
+ });
884
+ }
885
+ catch (error) {
886
+ if (error.code === 'ERR_SERVER_NOT_RUNNING')
887
+ settleServerClose();
888
+ else {
889
+ serverCloseError = error;
890
+ settleServerClose(error);
891
+ }
892
+ }
893
+ };
894
+ invokeServerClose = closeNow;
895
+ if (currentServer.listening)
896
+ closeNow();
897
+ else if (!listenPending)
898
+ settleServerClose();
899
+ else
900
+ serverCloseRequested = true;
689
901
  }
690
- }, dispatch);
691
- }
692
- catch (error) {
693
- const message = safeErrorMessage(error);
694
- const failedAt = new Date().toISOString();
695
- await store.appendOutput(options.id, `${message}\n`);
696
- await store.appendTimeline(options.id, {
697
- at: failedAt,
698
- state: 'failed',
699
- detail: message,
700
- text: message,
902
+ else if (currentServer.listening && serverCloseRequested) {
903
+ invokeServerClose?.();
904
+ }
905
+ return serverClosePromise;
906
+ };
907
+ const closePendingListen = () => {
908
+ if (!listenPending)
909
+ return;
910
+ listenCancelled = true;
911
+ const closePromise = closeServer();
912
+ rejectPendingListen?.(new Error('Agent listen cancelled'));
913
+ void closePromise.catch(() => undefined);
914
+ };
915
+ let turnQueue = Promise.resolve();
916
+ let finishWorker;
917
+ const finished = new Promise((resolveFinished) => {
918
+ finishWorker = resolveFinished;
701
919
  });
702
- await store.update(options.id, (state) => ({
703
- ...clearWorkerFields(state),
704
- state: 'failed',
705
- detail: message,
706
- tempo: 'idle',
707
- updatedAt: failedAt,
708
- firstTerminalAt: state.firstTerminalAt ?? failedAt,
709
- }));
710
- return;
711
- }
712
- let activeController;
713
- let closing = false;
714
- let turnQueue = Promise.resolve();
715
- let finishWorker;
716
- const finished = new Promise((resolveFinished) => {
717
- finishWorker = resolveFinished;
718
- });
719
- const server = createServer((client) => {
720
- let authenticated = false;
721
- lines(client, (message) => {
722
- if (!authenticated) {
723
- if (message.type === 'stop' && message.token === initial.controlToken) {
724
- authenticated = true;
725
- writeWire(client, { type: 'stopped' });
726
- void setStopped('stopped').catch(() => undefined);
727
- return;
920
+ const socketFile = initial.socketPath ?? socketPath(options.configRoot, options.id);
921
+ const processFile = join(topLevelAgentProcessRegistryRoot(options.configRoot, options.dataPlane), `${process.pid}.json`);
922
+ const processStartedAt = Date.now();
923
+ const writeProcessStatus = (status) => {
924
+ const now = Date.now();
925
+ return writeFileAtomically(processFile, `${JSON.stringify({
926
+ pid: process.pid,
927
+ sessionId: initial.sessionId,
928
+ cwd: initial.cwd,
929
+ startedAt: processStartedAt,
930
+ version: initial.cliVersion,
931
+ kind: 'bg',
932
+ entrypoint: 'cli',
933
+ name: initial.intent,
934
+ jobId: options.id,
935
+ status,
936
+ updatedAt: now,
937
+ statusUpdatedAt: now,
938
+ })}\n`);
939
+ };
940
+ const closeRuntime = async () => {
941
+ if (runtimeClosed)
942
+ return;
943
+ runtimeClosed = true;
944
+ if (runtimeInitialization) {
945
+ try {
946
+ runtime = await runtimeInitialization;
728
947
  }
729
- if (message.type !== 'attach' ||
730
- message.token !== initial.controlToken) {
731
- client.destroy();
732
- return;
948
+ catch (error) {
949
+ runtimeInitializationError = error;
733
950
  }
734
- authenticated = true;
735
- clients.add(client);
736
- void store.output(options.id).then((history) => {
737
- writeWire(client, {
738
- type: 'ready',
739
- text: history,
740
- });
741
- });
742
- return;
743
951
  }
744
- if (message.type === 'detach') {
745
- client.end();
746
- return;
952
+ await runtime?.close?.();
953
+ };
954
+ const closeControl = (destroy = false) => {
955
+ for (const client of clients) {
956
+ if (destroy)
957
+ client.destroy();
958
+ else
959
+ client.end();
747
960
  }
748
- if (message.type === 'prompt' && message.text && message.requestId) {
749
- const { text, requestId } = message;
750
- turnQueue = turnQueue.then(() => runTurn(text, sessionStarted, requestId, client));
961
+ if (server?.listening || listenPending)
962
+ void closeServer().catch(() => undefined);
963
+ };
964
+ cleanupClaimed = async () => {
965
+ const failures = [];
966
+ closePendingListen();
967
+ try {
968
+ await setupStep;
751
969
  }
752
- });
753
- client.once('close', () => clients.delete(client));
754
- });
755
- const closeControl = () => {
756
- for (const client of clients)
757
- client.end();
758
- if (server.listening)
759
- server.close();
760
- finishWorker?.();
761
- };
762
- const setStopped = async (detail) => {
763
- if (closing)
764
- return;
765
- liveTurnText = '';
766
- closing = true;
767
- activeController?.abort();
768
- const now = new Date().toISOString();
769
- try {
770
- await store.update(options.id, (state) => ({
771
- ...clearWorkerFields(state),
772
- state: 'stopped',
773
- detail,
774
- tempo: 'idle',
775
- updatedAt: now,
776
- firstTerminalAt: state.firstTerminalAt ?? now,
777
- }));
778
- await store.appendTimeline(options.id, {
779
- at: now,
780
- state: 'stopped',
781
- detail,
782
- text: detail,
783
- });
784
- }
785
- finally {
786
- broadcast({ type: 'stopped' });
787
- closeControl();
788
- }
789
- };
790
- const failWorker = async (error) => {
791
- if (closing)
792
- return;
793
- closing = true;
794
- activeController?.abort();
795
- const message = safeErrorMessage(error);
796
- const now = new Date().toISOString();
797
- try {
798
- await store.appendOutput(options.id, `${message}\n`);
799
- await store.trimOutput(options.id, MAX_JOB_OUTPUT_BYTES);
800
- await store.appendTimeline(options.id, {
801
- at: now,
802
- state: 'failed',
803
- detail: message,
804
- text: message,
805
- });
806
- await store.update(options.id, (state) => ({
807
- ...clearWorkerFields(state),
808
- state: 'failed',
809
- detail: message,
810
- tempo: 'idle',
811
- updatedAt: now,
812
- firstTerminalAt: state.firstTerminalAt ?? now,
813
- }));
814
- }
815
- finally {
816
- broadcast({ type: 'failed', message });
817
- closeControl();
818
- }
819
- };
820
- async function runTurn(prompt, resume, requestId, client) {
821
- if (closing)
822
- return;
823
- liveTurnText = '';
824
- outputWriteError = undefined;
825
- activeController = new AbortController();
826
- const now = new Date().toISOString();
827
- const activated = await store.update(options.id, (state) => {
828
- if (state.state !== 'working')
829
- return state;
830
- const next = {
831
- ...state,
832
- detail: safeText(prompt),
833
- tempo: 'active',
834
- inFlight: { tasks: 1, queued: 0, kinds: ['prompt'] },
835
- updatedAt: now,
970
+ catch (error) {
971
+ failures.push(error);
972
+ }
973
+ activeController?.abort();
974
+ try {
975
+ await outputWrites;
976
+ }
977
+ catch (error) {
978
+ failures.push(error);
979
+ }
980
+ try {
981
+ await closeRuntime();
982
+ }
983
+ catch (error) {
984
+ failures.push(error);
985
+ }
986
+ try {
987
+ closeControl(true);
988
+ await closeServer();
989
+ }
990
+ catch (error) {
991
+ failures.push(error);
992
+ }
993
+ try {
994
+ await rm(socketFile, { force: true });
995
+ }
996
+ catch (error) {
997
+ failures.push(error);
998
+ }
999
+ try {
1000
+ await rm(processFile, { force: true });
1001
+ }
1002
+ catch (error) {
1003
+ failures.push(error);
1004
+ }
1005
+ stopClient?.destroy();
1006
+ stopClient = undefined;
1007
+ if (failures.length > 0)
1008
+ throw new AggregateError(failures, 'Agent fallback cleanup failed');
1009
+ };
1010
+ const broadcast = (message) => {
1011
+ for (const client of clients)
1012
+ writeWire(client, message);
1013
+ };
1014
+ const lifecycleWrite = (() => {
1015
+ let chain = Promise.resolve();
1016
+ return (operation) => {
1017
+ const next = chain.then(operation);
1018
+ chain = next.then(() => undefined, () => undefined);
1019
+ return next;
836
1020
  };
837
- delete next.needs;
838
- return next;
839
- });
840
- if (closing || activated.state !== 'working')
841
- return;
842
- await writeProcessStatus('working');
843
- let resumeTurn = resume;
844
- try {
845
- if (!sessionStarted && dispatch.sourceSessionId) {
846
- if (!runtime.ensureFork && !runtime.fork) {
847
- throw new Error('Background handoff runtime cannot fork sessions');
1021
+ })();
1022
+ let cancellationTransition;
1023
+ requestCancellation = () => {
1024
+ if (!cancellationTransition) {
1025
+ cancellationTransition = lifecycleWrite(async () => {
1026
+ if (['queued', 'running', 'waiting'].includes(claimedExecution.snapshot.state))
1027
+ await claimedExecution.beginCancellation();
1028
+ }).then(() => undefined);
1029
+ }
1030
+ return cancellationTransition;
1031
+ };
1032
+ const finalization = (outcome, error, skipTurnDrain = false) => {
1033
+ if (finalizationPromise)
1034
+ return finalizationPromise;
1035
+ const operation = (async () => {
1036
+ const cancellationAtStart = outcome === 'cancelled' ||
1037
+ stopRequested ||
1038
+ claimedExecution.snapshot.state === 'cancelling';
1039
+ const detail = cancellationAtStart ? 'stopped' : safeErrorMessage(error);
1040
+ const failures = [];
1041
+ try {
1042
+ if (cancellationAtStart)
1043
+ await requestCancellation();
848
1044
  }
849
- if (runtime.ensureFork) {
850
- await runtime.ensureFork(dispatch.sourceSessionId, initial.sessionId, dispatch.sourceCheckpoint);
1045
+ catch (failure) {
1046
+ failures.push(failure);
851
1047
  }
852
- else {
853
- await runtime.fork?.(dispatch.sourceSessionId, initial.sessionId);
1048
+ closing = true;
1049
+ activeController?.abort();
1050
+ closePendingListen();
1051
+ try {
1052
+ await setupStep;
854
1053
  }
855
- dispatch = await store.updateDispatch(options.id, (current) => {
856
- const next = {
857
- ...current,
858
- resume: true,
859
- handoffComplete: true,
860
- };
861
- delete next.sourceSessionId;
862
- delete next.sourceCheckpoint;
863
- return next;
864
- });
865
- sessionStarted = true;
866
- resumeTurn = true;
867
- }
868
- const result = resumeTurn
869
- ? await runtime.resume(initial.sessionId, prompt, activeController.signal)
870
- : await runtime.run(prompt, activeController.signal, initial.sessionId);
871
- sessionStarted = true;
872
- if (closing)
873
- return;
874
- const completedAt = new Date().toISOString();
875
- await outputWrites;
876
- if (outputWriteError)
877
- throw outputWriteError;
878
- const resultText = safeText(result.text);
879
- if (liveTurnText.length === 0 && resultText.length > 0) {
880
- broadcast({ type: 'output', text: resultText });
881
- await store.appendOutput(options.id, resultText);
882
- }
883
- await store.appendOutput(options.id, '\n');
884
- await store.trimOutput(options.id, MAX_JOB_OUTPUT_BYTES);
885
- await store.appendTimeline(options.id, {
886
- at: completedAt,
887
- state: 'working',
888
- detail: resultText,
889
- text: resultText,
1054
+ catch (failure) {
1055
+ if (!(cancellationAtStart && listenCancelled))
1056
+ failures.push(failure);
1057
+ }
1058
+ if (!skipTurnDrain) {
1059
+ try {
1060
+ await turnQueue;
1061
+ }
1062
+ catch (failure) {
1063
+ failures.push(failure);
1064
+ }
1065
+ }
1066
+ try {
1067
+ await outputWrites;
1068
+ // A streaming write failure is already the primary fatal error when
1069
+ // runTurn routes that same object through failed finalization. It is
1070
+ // still a cancellation cleanup failure, and distinct queued errors
1071
+ // remain cleanup failures.
1072
+ if (outputWriteError &&
1073
+ (cancellationAtStart || outputWriteError !== error))
1074
+ failures.push(outputWriteError);
1075
+ }
1076
+ catch (failure) {
1077
+ failures.push(failure);
1078
+ }
1079
+ if (!cancellationAtStart) {
1080
+ try {
1081
+ await store.appendOutput(options.id, `${detail}\n`);
1082
+ await store.trimOutput(options.id, MAX_JOB_OUTPUT_BYTES);
1083
+ }
1084
+ catch (failure) {
1085
+ failures.push(failure);
1086
+ }
1087
+ }
1088
+ try {
1089
+ await closeRuntime();
1090
+ }
1091
+ catch (failure) {
1092
+ failures.push(failure);
1093
+ }
1094
+ if (cancellationAtStart && runtimeInitializationError)
1095
+ failures.push(runtimeInitializationError);
1096
+ try {
1097
+ const deferServerDrain = cancellationAtStart && stopClient !== undefined;
1098
+ closeControl(true);
1099
+ // Starting the close synchronously stops new accepts. For an
1100
+ // authenticated stop, retain the drain promise until after ack.
1101
+ await Promise.resolve();
1102
+ if (serverCloseError)
1103
+ throw serverCloseError;
1104
+ if (!deferServerDrain)
1105
+ await closeServer();
1106
+ }
1107
+ catch (failure) {
1108
+ failures.push(failure);
1109
+ }
1110
+ try {
1111
+ await rm(socketFile, { force: true });
1112
+ }
1113
+ catch (failure) {
1114
+ failures.push(failure);
1115
+ }
1116
+ try {
1117
+ await rm(processFile, { force: true });
1118
+ }
1119
+ catch (failure) {
1120
+ failures.push(failure);
1121
+ }
1122
+ const cancellation = cancellationAtStart ||
1123
+ stopRequested ||
1124
+ String(claimedExecution.snapshot.state) === 'cancelling';
1125
+ if (cancellation && !cancellationAtStart) {
1126
+ try {
1127
+ await requestCancellation();
1128
+ }
1129
+ catch (failure) {
1130
+ failures.push(failure);
1131
+ }
1132
+ }
1133
+ if (failures.length > 0) {
1134
+ const diagnostic = safeText(failures.map((failure) => safeErrorMessage(failure)).join('; '));
1135
+ try {
1136
+ if (!isTerminalLifecycleState(claimedExecution.snapshot.state))
1137
+ await lifecycleWrite(() => claimedExecution.update((state) => ({
1138
+ ...clearWorkerFields(state),
1139
+ detail: diagnostic,
1140
+ tempo: 'idle',
1141
+ updatedAt: new Date().toISOString(),
1142
+ })));
1143
+ }
1144
+ catch (diagnosticError) {
1145
+ failures.push(diagnosticError);
1146
+ }
1147
+ throw new AggregateError(failures, 'Agent cleanup failed');
1148
+ }
1149
+ if (isTerminalLifecycleState(claimedExecution.snapshot.state)) {
1150
+ return;
1151
+ }
1152
+ const terminal = await lifecycleWrite(() => claimedExecution.finish(cancellation ? 'cancelled' : 'failed', (state) => ({
1153
+ ...clearWorkerFields(state),
1154
+ ...(cancellation
1155
+ ? { detail, tempo: 'idle' }
1156
+ : {
1157
+ state: 'failed',
1158
+ detail,
1159
+ tempo: 'idle',
1160
+ firstTerminalAt: state.firstTerminalAt ?? new Date().toISOString(),
1161
+ }),
1162
+ updatedAt: new Date().toISOString(),
1163
+ })));
1164
+ let timelineError;
1165
+ try {
1166
+ await store.appendTimeline(options.id, {
1167
+ at: new Date().toISOString(),
1168
+ state: cancellation ? 'stopped' : 'failed',
1169
+ detail,
1170
+ text: detail,
1171
+ });
1172
+ }
1173
+ catch (failure) {
1174
+ timelineError = failure;
1175
+ }
1176
+ if (cancellation && terminal.state === 'cancelled') {
1177
+ broadcast({ type: 'stopped' });
1178
+ if (stopClient) {
1179
+ writeWire(stopClient, { type: 'stopped' });
1180
+ stopClient.end();
1181
+ stopClient = undefined;
1182
+ }
1183
+ try {
1184
+ await closeServer();
1185
+ }
1186
+ catch (failure) {
1187
+ throw new AggregateError([failure], 'Agent server close failed after cancellation');
1188
+ }
1189
+ }
1190
+ else if (!cancellation) {
1191
+ broadcast({ type: 'failed', message: detail });
1192
+ }
1193
+ if (timelineError)
1194
+ throw new AggregateError([timelineError], 'Terminal lifecycle timeline projection failed');
1195
+ })();
1196
+ const finalPromise = operation
1197
+ .catch((failure) => {
1198
+ finalizationError = failure;
1199
+ throw failure;
1200
+ })
1201
+ .finally(() => {
1202
+ closeControl(true);
1203
+ stopClient?.destroy();
1204
+ stopClient = undefined;
1205
+ finishWorker?.();
890
1206
  });
891
- const idled = await store.update(options.id, (state) => {
892
- if (state.state !== 'working')
893
- return state;
1207
+ finalizationPromise = finalPromise;
1208
+ return finalPromise;
1209
+ };
1210
+ let finalizationError;
1211
+ let finalizationPromise;
1212
+ cancellationFinalizer = () => finalization('cancelled');
1213
+ const runTurn = async (prompt, resume, requestId, client) => {
1214
+ if (closing || stopRequested)
1215
+ return;
1216
+ liveTurnText = '';
1217
+ outputWriteError = undefined;
1218
+ activeController = new AbortController();
1219
+ const now = new Date().toISOString();
1220
+ if (!['running', 'waiting'].includes(claimedExecution.snapshot.state) ||
1221
+ closing ||
1222
+ stopRequested)
1223
+ return;
1224
+ const activate = (state) => {
894
1225
  const next = {
895
1226
  ...state,
896
- detail: resultText,
897
- tempo: 'idle',
898
- inFlight: { tasks: 0, queued: 0, kinds: [] },
899
- tokens: state.tokens + result.usage.inputTokens + result.usage.outputTokens,
900
- updatedAt: completedAt,
1227
+ detail: safeText(prompt),
1228
+ tempo: 'active',
1229
+ inFlight: { tasks: 1, queued: 0, kinds: ['prompt'] },
1230
+ updatedAt: now,
901
1231
  };
902
1232
  delete next.needs;
903
1233
  return next;
904
- });
905
- if (!closing && idled.state === 'working') {
906
- await writeProcessStatus('idle');
1234
+ };
1235
+ let resumeTurn = resume;
1236
+ try {
1237
+ if (claimedExecution.snapshot.state === 'waiting')
1238
+ await lifecycleWrite(() => claimedExecution.running(activate));
1239
+ else
1240
+ await lifecycleWrite(() => claimedExecution.update(activate));
1241
+ if (closing || stopRequested)
1242
+ return;
1243
+ await writeProcessStatus('working');
1244
+ if (!sessionStarted && dispatch.sourceSessionId) {
1245
+ if (!runtime?.ensureFork && !runtime?.fork)
1246
+ throw new Error('Background handoff runtime cannot fork sessions');
1247
+ if (runtime.ensureFork)
1248
+ await runtime.ensureFork(dispatch.sourceSessionId, initial.sessionId, dispatch.sourceCheckpoint);
1249
+ else
1250
+ await runtime.fork?.(dispatch.sourceSessionId, initial.sessionId);
1251
+ dispatch = await store.updateDispatch(options.id, (current) => {
1252
+ const next = {
1253
+ ...current,
1254
+ resume: true,
1255
+ handoffComplete: true,
1256
+ };
1257
+ delete next.sourceSessionId;
1258
+ delete next.sourceCheckpoint;
1259
+ return next;
1260
+ });
1261
+ sessionStarted = true;
1262
+ resumeTurn = true;
1263
+ }
1264
+ const currentRuntime = runtime;
1265
+ const currentController = activeController;
1266
+ if (!currentRuntime || !currentController)
1267
+ throw new Error('Background agent runtime is not ready');
1268
+ const result = resumeTurn
1269
+ ? await currentRuntime.resume(initial.sessionId, prompt, currentController.signal)
1270
+ : await currentRuntime.run(prompt, currentController.signal, initial.sessionId);
1271
+ sessionStarted = true;
1272
+ if (closing || stopRequested)
1273
+ return;
1274
+ const completedAt = new Date().toISOString();
1275
+ await outputWrites;
1276
+ if (outputWriteError)
1277
+ throw outputWriteError;
1278
+ const resultText = safeText(result.text);
1279
+ if (liveTurnText.length === 0 && resultText.length > 0) {
1280
+ broadcast({ type: 'output', text: resultText });
1281
+ await store.appendOutput(options.id, resultText);
1282
+ }
1283
+ await store.appendOutput(options.id, '\n');
1284
+ await store.trimOutput(options.id, MAX_JOB_OUTPUT_BYTES);
1285
+ await store.appendTimeline(options.id, {
1286
+ at: completedAt,
1287
+ state: 'working',
1288
+ detail: resultText,
1289
+ text: resultText,
1290
+ });
1291
+ const idled = await lifecycleWrite(() => claimedExecution.waiting((state) => {
1292
+ const next = {
1293
+ ...state,
1294
+ detail: resultText,
1295
+ tempo: 'idle',
1296
+ inFlight: { tasks: 0, queued: 0, kinds: [] },
1297
+ tokens: state.tokens +
1298
+ result.usage.inputTokens +
1299
+ result.usage.outputTokens,
1300
+ updatedAt: completedAt,
1301
+ };
1302
+ delete next.needs;
1303
+ return next;
1304
+ }));
1305
+ if (!closing && idled.state === 'waiting')
1306
+ await writeProcessStatus('idle');
1307
+ if (requestId && client)
1308
+ writeWire(client, { type: 'turn-complete', requestId });
907
1309
  }
908
- if (requestId && client) {
909
- writeWire(client, { type: 'turn-complete', requestId });
1310
+ catch (turnError) {
1311
+ if (closing || activeController?.signal.aborted || stopRequested)
1312
+ return;
1313
+ const message = safeErrorMessage(turnError);
1314
+ const failedAt = new Date().toISOString();
1315
+ if (resumeTurn) {
1316
+ try {
1317
+ await store.appendOutput(options.id, `${message}\n`);
1318
+ await store.trimOutput(options.id, MAX_JOB_OUTPUT_BYTES);
1319
+ await store.appendTimeline(options.id, {
1320
+ at: failedAt,
1321
+ state: 'working',
1322
+ detail: message,
1323
+ text: message,
1324
+ });
1325
+ const idled = await lifecycleWrite(() => claimedExecution.waiting((state) => {
1326
+ const next = {
1327
+ ...state,
1328
+ detail: message,
1329
+ tempo: 'idle',
1330
+ inFlight: { tasks: 0, queued: 0, kinds: [] },
1331
+ updatedAt: failedAt,
1332
+ };
1333
+ delete next.needs;
1334
+ return next;
1335
+ }));
1336
+ if (!closing && idled.state === 'waiting')
1337
+ await writeProcessStatus('idle');
1338
+ if (requestId && client)
1339
+ writeWire(client, { type: 'turn-error', requestId, message });
1340
+ }
1341
+ catch (persistenceError) {
1342
+ await finalization('failed', persistenceError, true).catch(() => undefined);
1343
+ }
1344
+ }
1345
+ else {
1346
+ await finalization('failed', turnError, true).catch(() => undefined);
1347
+ }
910
1348
  }
911
- }
912
- catch (error) {
913
- if (closing || activeController.signal.aborted)
1349
+ finally {
1350
+ activeController = undefined;
1351
+ }
1352
+ };
1353
+ try {
1354
+ if (!initial.socketPath || !initial.controlToken) {
1355
+ const token = initial.controlToken ?? randomBytes(24).toString('hex');
1356
+ await runSetupStep(() => lifecycleWrite(() => claimedExecution.update((state) => ({
1357
+ ...state,
1358
+ socketPath: socketFile,
1359
+ controlToken: token,
1360
+ updatedAt: new Date().toISOString(),
1361
+ }))));
1362
+ }
1363
+ const current = await runSetupStep(() => store.read(options.id));
1364
+ const controlToken = current.controlToken;
1365
+ if (!controlToken)
1366
+ throw new Error(`Agent ${options.id} has no control endpoint`);
1367
+ if (stopRequested || closing) {
1368
+ await finalization('cancelled');
914
1369
  return;
915
- const message = safeErrorMessage(error);
916
- const failedAt = new Date().toISOString();
917
- if (resumeTurn) {
918
- try {
919
- await store.appendOutput(options.id, `${message}\n`);
920
- await store.trimOutput(options.id, MAX_JOB_OUTPUT_BYTES);
921
- await store.appendTimeline(options.id, {
922
- at: failedAt,
923
- state: 'working',
924
- detail: message,
925
- text: message,
926
- });
927
- const idled = await store.update(options.id, (state) => {
928
- if (state.state !== 'working')
929
- return state;
1370
+ }
1371
+ await runSetupStep(() => lifecycleWrite(() => claimedExecution.update((state) => ({
1372
+ ...state,
1373
+ pid: process.pid,
1374
+ updatedAt: new Date().toISOString(),
1375
+ }))));
1376
+ if (stopRequested || closing) {
1377
+ await finalization('cancelled');
1378
+ return;
1379
+ }
1380
+ await runSetupStep(async () => {
1381
+ await mkdir(dirname(socketFile), { recursive: true });
1382
+ await rm(socketFile, { force: true });
1383
+ });
1384
+ if (stopRequested || closing) {
1385
+ await finalization('cancelled');
1386
+ return;
1387
+ }
1388
+ if (claimedExecution.snapshot.state === 'queued') {
1389
+ await runSetupStep(() => lifecycleWrite(() => claimedExecution.running((state) => ({
1390
+ ...state,
1391
+ tempo: 'active',
1392
+ updatedAt: new Date().toISOString(),
1393
+ }))));
1394
+ if (dispatch.deferInitialTurn)
1395
+ await runSetupStep(() => lifecycleWrite(() => claimedExecution.waiting((state) => {
930
1396
  const next = {
931
1397
  ...state,
932
- detail: message,
933
- tempo: 'idle',
934
- inFlight: { tasks: 0, queued: 0, kinds: [] },
935
- updatedAt: failedAt,
1398
+ tempo: dispatch.handoffComplete === true
1399
+ ? 'idle'
1400
+ : 'blocked',
1401
+ updatedAt: new Date().toISOString(),
936
1402
  };
937
- delete next.needs;
1403
+ if (dispatch.handoffComplete === true)
1404
+ delete next.needs;
1405
+ else
1406
+ next.needs = 'send a prompt to start';
938
1407
  return next;
1408
+ })));
1409
+ }
1410
+ if (stopRequested || closing) {
1411
+ await finalization('cancelled');
1412
+ return;
1413
+ }
1414
+ runtimeInitialization = runSetupStep(() => Promise.resolve().then(() => options.createRuntime((event) => {
1415
+ if (event.type === 'text-delta') {
1416
+ const delta = safeText(event.delta);
1417
+ liveTurnText += delta;
1418
+ outputWrites = outputWrites
1419
+ .then(() => store.appendOutput(options.id, delta))
1420
+ .catch((error) => {
1421
+ outputWriteError = error;
939
1422
  });
940
- if (!closing && idled.state === 'working') {
941
- await writeProcessStatus('idle');
1423
+ broadcast({ type: 'output', text: delta });
1424
+ }
1425
+ }, dispatch)));
1426
+ try {
1427
+ runtime = await runtimeInitialization;
1428
+ }
1429
+ catch (runtimeError) {
1430
+ if (stopRequested || closing)
1431
+ await finalization('cancelled', runtimeError);
1432
+ else
1433
+ await finalization('failed', runtimeError);
1434
+ return;
1435
+ }
1436
+ if (stopRequested || closing) {
1437
+ await finalization('cancelled');
1438
+ return;
1439
+ }
1440
+ server = createServer((client) => {
1441
+ let authenticated = false;
1442
+ lines(client, (message) => {
1443
+ if (!authenticated) {
1444
+ if (message.type === 'stop' && message.token === controlToken) {
1445
+ authenticated = true;
1446
+ stopClient = client;
1447
+ void finalization('cancelled')
1448
+ .catch(() => undefined)
1449
+ .finally(() => {
1450
+ if (stopClient === client)
1451
+ client.destroy();
1452
+ });
1453
+ return;
1454
+ }
1455
+ if (message.type !== 'attach' || message.token !== controlToken) {
1456
+ client.destroy();
1457
+ return;
1458
+ }
1459
+ authenticated = true;
1460
+ clients.add(client);
1461
+ void store.output(options.id).then((history) => {
1462
+ writeWire(client, { type: 'ready', text: history });
1463
+ });
1464
+ return;
1465
+ }
1466
+ if (message.type === 'detach') {
1467
+ client.end();
1468
+ return;
942
1469
  }
943
- if (requestId && client) {
944
- writeWire(client, { type: 'turn-error', requestId, message });
1470
+ if (message.type === 'prompt' && message.text && message.requestId) {
1471
+ const { text, requestId } = message;
1472
+ turnQueue = turnQueue.then(() => runTurn(text, sessionStarted, requestId, client));
945
1473
  }
1474
+ });
1475
+ client.once('close', () => clients.delete(client));
1476
+ });
1477
+ listenPending = true;
1478
+ listenCancelled = false;
1479
+ await runSetupStep(() => new Promise((resolveListen, rejectListen) => {
1480
+ let settled = false;
1481
+ rejectPendingListen = (listenError) => {
1482
+ if (settled)
1483
+ return;
1484
+ settled = true;
1485
+ listenPending = false;
1486
+ rejectListen(listenError);
1487
+ };
1488
+ const listening = () => {
1489
+ if (listenCancelled) {
1490
+ void closeServer().catch(() => undefined);
1491
+ void rm(socketFile, { force: true }).catch(() => undefined);
1492
+ return;
1493
+ }
1494
+ if (settled)
1495
+ return;
1496
+ settled = true;
1497
+ listenPending = false;
1498
+ rejectPendingListen = undefined;
1499
+ server?.removeListener('error', failed);
1500
+ server?.removeListener('close', closed);
1501
+ resolveListen();
1502
+ };
1503
+ const closed = () => {
1504
+ if (serverClosePromise)
1505
+ settleServerClose();
1506
+ if (settled)
1507
+ return;
1508
+ settled = true;
1509
+ listenPending = false;
1510
+ rejectPendingListen = undefined;
1511
+ server?.removeListener('listening', listening);
1512
+ server?.removeListener('error', failed);
1513
+ rejectListen(new Error('Agent server closed before listening'));
1514
+ };
1515
+ const failed = (listenError) => {
1516
+ if (serverClosePromise)
1517
+ settleServerClose();
1518
+ if (settled)
1519
+ return;
1520
+ settled = true;
1521
+ listenPending = false;
1522
+ rejectPendingListen = undefined;
1523
+ server?.removeListener('listening', listening);
1524
+ server?.removeListener('close', closed);
1525
+ rejectListen(listenError);
1526
+ };
1527
+ server?.once('listening', listening);
1528
+ server?.once('close', closed);
1529
+ server?.once('error', failed);
1530
+ try {
1531
+ server?.listen(socketFile);
946
1532
  }
947
- catch (persistenceError) {
948
- await failWorker(persistenceError);
1533
+ catch (listenError) {
1534
+ failed(listenError);
949
1535
  }
1536
+ }));
1537
+ if (stopRequested || closing) {
1538
+ closeControl(true);
1539
+ await rm(socketFile, { force: true }).catch(() => undefined);
1540
+ await finalization('cancelled');
1541
+ return;
1542
+ }
1543
+ server.on('error', (serverError) => {
1544
+ void finalization('failed', serverError).catch(() => undefined);
1545
+ });
1546
+ const waitingForHandoff = dispatch.deferInitialTurn === true && dispatch.handoffComplete !== true;
1547
+ const registered = await runSetupStep(() => lifecycleWrite(() => claimedExecution.update((state) => {
1548
+ const next = {
1549
+ ...state,
1550
+ pid: process.pid,
1551
+ detail: dispatch.deferInitialTurn ? state.detail : 'starting',
1552
+ tempo: dispatch.deferInitialTurn
1553
+ ? waitingForHandoff
1554
+ ? 'blocked'
1555
+ : 'idle'
1556
+ : 'active',
1557
+ ...(waitingForHandoff ? { needs: 'send a prompt to start' } : {}),
1558
+ socketPath: socketFile,
1559
+ controlToken,
1560
+ updatedAt: new Date().toISOString(),
1561
+ };
1562
+ if (!waitingForHandoff)
1563
+ delete next.needs;
1564
+ return next;
1565
+ })));
1566
+ if (registered.state !== 'working' || stopRequested || closing) {
1567
+ await finalization('cancelled');
1568
+ return;
950
1569
  }
951
- else {
952
- await failWorker(error);
1570
+ await runSetupStep(() => writeProcessStatus(dispatch.deferInitialTurn ? 'idle' : 'working'));
1571
+ if (stopRequested || closing) {
1572
+ closeControl(true);
1573
+ await rm(socketFile, { force: true }).catch(() => undefined);
1574
+ await rm(processFile, { force: true }).catch(() => undefined);
1575
+ await finalization('cancelled');
1576
+ return;
953
1577
  }
1578
+ if (!dispatch.deferInitialTurn)
1579
+ turnQueue = turnQueue.then(() => runTurn(initial.intent, dispatch.resume));
1580
+ await finished;
1581
+ if (finalizationError)
1582
+ throw finalizationError;
954
1583
  }
955
- finally {
956
- activeController = undefined;
1584
+ catch (error) {
1585
+ if (finalizationPromise) {
1586
+ try {
1587
+ await finalizationPromise;
1588
+ }
1589
+ catch (finalizationFailure) {
1590
+ postClaimFailure = finalizationFailure;
1591
+ throw finalizationFailure;
1592
+ }
1593
+ }
1594
+ else if (!closing) {
1595
+ try {
1596
+ await finalization(stopRequested ? 'cancelled' : 'failed', error);
1597
+ }
1598
+ catch (finalizationFailure) {
1599
+ postClaimFailure = finalizationFailure;
1600
+ throw finalizationFailure;
1601
+ }
1602
+ }
957
1603
  }
958
1604
  }
959
- server.on('error', (error) => void failWorker(error).catch(() => closeControl()));
960
- await new Promise((resolveListen, reject) => {
961
- server.once('error', reject);
962
- server.listen(socketFile, resolveListen);
963
- });
964
- const waitingForHandoff = dispatch.deferInitialTurn === true && dispatch.handoffComplete !== true;
965
- const registered = await store.update(options.id, (state) => {
966
- if (state.state !== 'working')
967
- return state;
968
- const next = {
969
- ...state,
970
- pid: process.pid,
971
- detail: dispatch.deferInitialTurn ? state.detail : 'starting',
972
- tempo: dispatch.deferInitialTurn
973
- ? waitingForHandoff
974
- ? 'blocked'
975
- : 'idle'
976
- : 'active',
977
- ...(waitingForHandoff ? { needs: 'send a prompt to start' } : {}),
978
- socketPath: socketFile,
979
- updatedAt: new Date().toISOString(),
980
- };
981
- if (!waitingForHandoff)
982
- delete next.needs;
983
- return next;
984
- });
985
- if (registered.state !== 'working') {
986
- server.close();
987
- await runtime.close?.();
988
- await rm(socketFile, { force: true });
989
- return;
990
- }
991
- await writeProcessStatus(dispatch.deferInitialTurn ? 'idle' : 'working');
992
- const stop = () => void setStopped('stopped').catch(() => undefined);
993
- process.once('SIGTERM', stop);
994
- process.once('SIGINT', stop);
995
- if (!dispatch.deferInitialTurn) {
996
- turnQueue = turnQueue.then(() => runTurn(initial.intent, dispatch.resume));
997
- }
998
- try {
999
- await finished;
1605
+ catch (error) {
1606
+ deferredFailure = error;
1000
1607
  }
1001
1608
  finally {
1002
- process.removeListener('SIGTERM', stop);
1003
- process.removeListener('SIGINT', stop);
1004
- await turnQueue.catch(() => undefined);
1005
- await outputWrites;
1006
- await runtime.close?.();
1007
- await rm(socketFile, { force: true });
1008
- await rm(processFile, { force: true });
1609
+ removeCaptureListeners();
1610
+ if (claimed) {
1611
+ const failures = [];
1612
+ try {
1613
+ await cleanupClaimed?.();
1614
+ }
1615
+ catch (error) {
1616
+ failures.push(error);
1617
+ }
1618
+ try {
1619
+ await releaseExecution();
1620
+ }
1621
+ catch (error) {
1622
+ failures.push(error);
1623
+ }
1624
+ if (postClaimFailure !== undefined)
1625
+ failures.unshift(postClaimFailure);
1626
+ if (failures.length > 0)
1627
+ deferredFailure = new AggregateError(failures, 'Agent worker cleanup failed');
1628
+ }
1009
1629
  }
1630
+ if (deferredFailure !== undefined)
1631
+ throw deferredFailure;
1010
1632
  }
1011
1633
  //# sourceMappingURL=top-level-agent-manager.js.map