@ours.network/fleet 1.2.0-nightly.53 → 1.2.0-nightly.55

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 (108) hide show
  1. package/README.md +22 -12
  2. package/dist/agent-ours/bridge.js +10 -3
  3. package/dist/agent-ours/file-rpc.d.ts +8 -0
  4. package/dist/agent-ours/file-rpc.js +11 -1
  5. package/dist/agent-ours/harness.js +47 -3
  6. package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
  7. package/dist/agent-ours/mcp-endpoint.js +4 -0
  8. package/dist/agent-ours/service.d.ts +6 -1
  9. package/dist/agent-ours/service.js +73 -8
  10. package/dist/application/conversation-history.d.ts +2 -0
  11. package/dist/application/conversation-history.js +25 -2
  12. package/dist/application/log-service.js +8 -8
  13. package/dist/application/role-creation-service.js +2 -1
  14. package/dist/application/role-removal-service.js +7 -1
  15. package/dist/application/role-repository.js +17 -0
  16. package/dist/application/supervisor-ours-tools.js +3 -1
  17. package/dist/application/task-layouts.js +12 -2
  18. package/dist/application/task-room-service.js +19 -17
  19. package/dist/application/types.d.ts +2 -0
  20. package/dist/build-info.json +4 -4
  21. package/dist/cli.js +31 -3
  22. package/dist/config.d.ts +11 -0
  23. package/dist/config.js +13 -2
  24. package/dist/file-delivery/reader.d.ts +17 -0
  25. package/dist/file-delivery/reader.js +94 -0
  26. package/dist/file-delivery/store.d.ts +15 -0
  27. package/dist/file-delivery/store.js +124 -0
  28. package/dist/file-delivery/types.d.ts +19 -0
  29. package/dist/file-delivery/types.js +1 -0
  30. package/dist/harness/agent-session.d.ts +2 -0
  31. package/dist/harness/claude-code-session.js +1 -1
  32. package/dist/harness/codex-app-server-proxy.js +6 -2
  33. package/dist/harness/codex-file-inventory.d.ts +15 -0
  34. package/dist/harness/codex-file-inventory.js +113 -0
  35. package/dist/harness/codex-session.js +2 -2
  36. package/dist/harness/codex.js +2 -1
  37. package/dist/isolation/policy.js +4 -1
  38. package/dist/managed-recovery.d.ts +13 -0
  39. package/dist/managed-recovery.js +31 -0
  40. package/dist/ops.d.ts +2 -0
  41. package/dist/ops.js +15 -2
  42. package/dist/rooms-tasks/cli.js +21 -0
  43. package/dist/rooms-tasks/close.d.ts +2 -0
  44. package/dist/rooms-tasks/close.js +66 -1
  45. package/dist/rooms-tasks/deletion.js +10 -0
  46. package/dist/rooms-tasks/erasure.js +4 -2
  47. package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
  48. package/dist/rooms-tasks/layout-member-retirement.js +90 -0
  49. package/dist/rooms-tasks/layout-service.d.ts +1 -0
  50. package/dist/rooms-tasks/layout-service.js +31 -13
  51. package/dist/rooms-tasks/layout.d.ts +7 -0
  52. package/dist/rooms-tasks/layout.js +35 -0
  53. package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
  54. package/dist/rooms-tasks/legacy-task-member.js +116 -0
  55. package/dist/rooms-tasks/member-ownership.d.ts +5 -0
  56. package/dist/rooms-tasks/member-ownership.js +40 -0
  57. package/dist/rooms-tasks/provision.js +26 -1
  58. package/dist/rooms-tasks/recovery.d.ts +19 -0
  59. package/dist/rooms-tasks/recovery.js +110 -0
  60. package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
  61. package/dist/rooms-tasks/task-service-retirement.js +21 -0
  62. package/dist/rooms-tasks/task-state.d.ts +1 -0
  63. package/dist/rooms-tasks/task-state.js +16 -1
  64. package/dist/rooms-tasks/types.d.ts +3 -0
  65. package/dist/rooms-tasks/workspace.d.ts +2 -0
  66. package/dist/rooms-tasks/workspace.js +5 -0
  67. package/dist/runner.d.ts +6 -0
  68. package/dist/runner.js +161 -22
  69. package/dist/session/acp.d.ts +5 -0
  70. package/dist/session/acp.js +68 -8
  71. package/dist/session/arbiter.d.ts +5 -0
  72. package/dist/session/arbiter.js +16 -2
  73. package/dist/session/codex-app-server.d.ts +1 -0
  74. package/dist/session/codex-app-server.js +5 -0
  75. package/dist/session/conversation-types.d.ts +4 -2
  76. package/dist/session/types.d.ts +3 -0
  77. package/dist/spawn.d.ts +5 -1
  78. package/dist/spawn.js +32 -3
  79. package/dist/startup-readiness.d.ts +1 -1
  80. package/dist/startup-readiness.js +3 -3
  81. package/dist/supervisor/adoption.d.ts +8 -0
  82. package/dist/supervisor/adoption.js +78 -0
  83. package/dist/supervisor/catalog.d.ts +62 -0
  84. package/dist/supervisor/catalog.js +254 -0
  85. package/dist/supervisor/fleet.d.ts +8 -0
  86. package/dist/supervisor/fleet.js +89 -0
  87. package/dist/supervisor/index.js +3 -6
  88. package/dist/supervisor/launchd.d.ts +1 -1
  89. package/dist/supervisor/launchd.js +24 -12
  90. package/dist/supervisor/legacy.d.ts +35 -0
  91. package/dist/supervisor/legacy.js +524 -0
  92. package/dist/supervisor/manager.d.ts +15 -0
  93. package/dist/supervisor/manager.js +258 -0
  94. package/dist/supervisor/scope.d.ts +4 -0
  95. package/dist/supervisor/scope.js +10 -0
  96. package/dist/supervisor/systemd.d.ts +2 -2
  97. package/dist/supervisor/systemd.js +14 -14
  98. package/dist/task-supervision.d.ts +9 -0
  99. package/dist/task-supervision.js +145 -0
  100. package/dist/temp-lifecycle.d.ts +17 -7
  101. package/dist/temp-lifecycle.js +85 -53
  102. package/dist/temp-supervisor-recovery.d.ts +1 -1
  103. package/dist/temp-supervisor-recovery.js +2 -2
  104. package/dist/web/server.js +24 -0
  105. package/docs/acp-files.md +102 -0
  106. package/docs/fleet-migration.md +54 -0
  107. package/docs/task-workspaces.md +26 -0
  108. package/package.json +7 -3
@@ -664,6 +664,8 @@ function findCursorForSeat(id, stored, seat) {
664
664
  if (cursor && seat.identity_cid
665
665
  && cursor.identity_cid.toLowerCase() !== seat.identity_cid.toLowerCase())
666
666
  throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' identity CID mismatch between cursor and room seat`);
667
+ if (cursor?.action_id && seat.launch?.action_id && cursor.action_id !== seat.launch.action_id)
668
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' creation ownership proof changed`);
667
669
  return cursor;
668
670
  }
669
671
  /**
@@ -684,11 +686,20 @@ export function upsertTaskDeletionMembersFromSeats(id, seats) {
684
686
  for (const seat of seats) {
685
687
  if (!seat.identity_cid)
686
688
  continue;
687
- if (!findCursorForSeat(id, stored, seat)) {
689
+ const cursor = findCursorForSeat(id, stored, seat);
690
+ if (!cursor) {
688
691
  stored.deletion.members.push({
689
692
  name: seat.role_name, identity_cid: seat.identity_cid, phase: 'pending', updated_at: now,
693
+ ...(seat.launch?.action_id ? { action_id: seat.launch.action_id } : {}),
694
+ ...(seat.launch?.task_supervised ? { task_supervised: true } : {}),
690
695
  });
691
696
  }
697
+ else {
698
+ if (seat.launch?.action_id)
699
+ cursor.action_id ??= seat.launch.action_id;
700
+ if (seat.launch?.task_supervised)
701
+ cursor.task_supervised = true;
702
+ }
692
703
  }
693
704
  writeTask(stored);
694
705
  return presentTaskLenient(stored);
@@ -747,6 +758,10 @@ export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
747
758
  cursor = { name: seat.role_name, identity_cid: seat.identity_cid, phase: 'pending', updated_at: now };
748
759
  stored.deletion.members.push(cursor);
749
760
  }
761
+ if (seat.launch?.action_id)
762
+ cursor.action_id ??= seat.launch.action_id;
763
+ if (seat.launch?.task_supervised)
764
+ cursor.task_supervised = true;
750
765
  if (DELETION_MEMBER_PHASE_ORDER[evidence.phase] < DELETION_MEMBER_PHASE_ORDER[cursor.phase])
751
766
  continue;
752
767
  if (cursor.launch_id !== undefined && cursor.launch_id !== evidence.launch_id)
@@ -62,6 +62,8 @@ export type TaskDeletionMemberPhase = 'pending' | 'stop_requested' | 'liveness_a
62
62
  export interface TaskDeletionMemberCursor {
63
63
  name: string;
64
64
  identity_cid: string;
65
+ action_id?: string;
66
+ task_supervised?: boolean;
65
67
  phase: TaskDeletionMemberPhase;
66
68
  launch_id?: string;
67
69
  archive_path?: string;
@@ -170,6 +172,7 @@ export interface RoomRoleBriefingDefinition {
170
172
  last_error?: string;
171
173
  }
172
174
  export interface RoomMemberLaunchState {
175
+ task_supervised?: boolean;
173
176
  state: 'pending' | 'intent' | 'launched' | 'stopped' | 'failed';
174
177
  attempt: number;
175
178
  action_id?: string;
@@ -11,6 +11,8 @@ export declare class WorkspaceError extends Error {
11
11
  export declare function assertSafeAncestors(path: string): void;
12
12
  export declare function planWorkspace(owner: OwnedWorkspace['owner'], id: string): OwnedWorkspace;
13
13
  export declare function validateWorkspace(w: OwnedWorkspace, owner?: "task" | "room", id?: string): void;
14
+ /** Read-only recovery proof; never allocate a replacement for a missing workspace. */
15
+ export declare function assertWorkspacePresent(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string): void;
14
16
  /** Caller must first prove the exact workspace marker or retired archive
15
17
  * provenance, and hold its lifecycle lock with all managed writers retired.
16
18
  * Directories need owner rwx for recursive removal; ordinary files do not.
@@ -54,6 +54,11 @@ function marked(path, w) {
54
54
  if (JSON.stringify(actual) !== JSON.stringify(w))
55
55
  fail('ownership marker mismatch');
56
56
  }
57
+ /** Read-only recovery proof; never allocate a replacement for a missing workspace. */
58
+ export function assertWorkspacePresent(w, owner, id) {
59
+ validateWorkspace(w, owner, id);
60
+ marked(w.path, w);
61
+ }
57
62
  function staging(w, phase) {
58
63
  return join(dirname(w.path), `.${w.id}.${w.token}.${phase}`);
59
64
  }
package/dist/runner.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface RunnerDeps {
39
39
  reportOwnerStartupFailure(stateDir: string): Promise<'delivered' | 'duplicate'>;
40
40
  /** Lets a test (or a shutdown path) end the supervised restart loop. */
41
41
  shouldStop?(): boolean;
42
+ /** Parent shutdown suspends a managed transient member; explicit stop still retires. */
43
+ suspendOnStop?: boolean;
42
44
  }
43
45
  export declare const SUPERVISOR_RECYCLE_REQUIRED = "OWNER_CHANNEL_SUPERVISOR_RECYCLE_REQUIRED";
44
46
  export declare class SupervisorRecycleRequiredError extends Error {
@@ -201,9 +203,13 @@ export declare function runOnce(name: string, opts?: {
201
203
  */
202
204
  export declare function runSupervised(name: string, opts?: {
203
205
  configPath?: string;
206
+ temp?: boolean;
204
207
  }, partialDeps?: Partial<RunnerDeps>, attempt?: (n: string, o: {
205
208
  configPath?: string;
206
209
  allowResumeRotation?: boolean;
210
+ temp?: boolean;
207
211
  }, d: Partial<RunnerDeps>) => Promise<AttemptResult>): Promise<RestartLedger>;
208
212
  /** Temp-agent entrypoint: run once, journal why it ended, then archive its evidence. */
213
+ /** The parent uses the same adapter-relative sustained-uptime reset boundary. */
214
+ export declare function stableSupervisorWindow(name: string, temp: boolean, configPath?: string): number;
209
215
  export declare function runTemp(name: string, deps?: Partial<RunnerDeps>, attempt?: typeof runOnce): Promise<void>;
package/dist/runner.js CHANGED
@@ -1,7 +1,10 @@
1
+ import { readProvenance } from './creation.js';
2
+ import { firstManagedSessionMayStart, managedRecoveryPath, readManagedRecovery, writeManagedRecovery } from './managed-recovery.js';
1
3
  import { StartupTelemetry } from './startup-telemetry.js';
2
4
  import { managedStartupPrompt, managedTaskPrompt } from './startup-prompt.js';
3
5
  import { createLayoutControl } from './rooms-tasks/layout-control.js';
4
6
  import { TemporaryChatIdle } from './temp-idle.js';
7
+ import { taskSupervisorMayRun, taskMemberRetirementProof } from './task-supervision.js';
5
8
  import { prepareManagedAgent, releaseManagedAgent } from './agent-ours/service.js';
6
9
  import { prepareManagedHarness } from './agent-ours/harness.js';
7
10
  import { prepareManagedCliLaunch } from './managed-cli.js';
@@ -33,7 +36,7 @@ import { ScheduledLoopManager, } from './loops/manager.js';
33
36
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
34
37
  import { effectivePermissionMode } from './permissions.js';
35
38
  import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
36
- import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
39
+ import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, readTempSupervisor, } from './temp-lifecycle.js';
37
40
  import { FleetCommandAuditStore } from './fleet-command-audit.js';
38
41
  export const SUPERVISOR_RECYCLE_REQUIRED = 'OWNER_CHANNEL_SUPERVISOR_RECYCLE_REQUIRED';
39
42
  export class SupervisorRecycleRequiredError extends Error {
@@ -511,14 +514,43 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
511
514
  deps.log(`[${name}] worklog rotation deferred: concurrent modification detected`);
512
515
  else if (rotation.rotated)
513
516
  deps.log(`[${name}] worklog rotated: ${rotation.beforeBytes} -> ${rotation.afterBytes} bytes`);
517
+ const durableTask = Boolean(readTempSupervisor(dir)?.taskOwner);
518
+ const retainedManaged = durableTask || Boolean(deps.suspendOnStop && adapter.supportsResume);
514
519
  const sidFile = join(dir, '.session-id');
520
+ const backendSidFile = join(dir, role.session === 'codex-app-server' ? '.session-id' : '.acp-session-id');
521
+ let recovery = retainedManaged ? readManagedRecovery(dir) : undefined;
522
+ // Native Codex shares .session-id with the runner's pre-launch UUID, so file
523
+ // existence alone is not evidence that its first backend thread was created.
524
+ const firstSessionRetry = retainedManaged && existsSync(join(dir, '.booted'))
525
+ && (!existsSync(backendSidFile) || (role.session === 'codex-app-server' && recovery?.session === 'pending'))
526
+ && firstManagedSessionMayStart(dir);
527
+ if (retainedManaged && existsSync(join(dir, '.booted'))
528
+ && (!existsSync(sidFile) || !existsSync(backendSidFile)) && !firstSessionRetry)
529
+ throw Error('TASK_CONTEXT_RESUME_UNAVAILABLE');
515
530
  if (!existsSync(sidFile))
516
531
  writeFileSync(sidFile, randomUUID() + '\n');
517
532
  const sessionId = readFileSync(sidFile, 'utf8').trim();
518
533
  const bootedFile = join(dir, '.booted');
519
534
  const exitFile = join(dir, '.exit-status');
520
535
  const booted = existsSync(bootedFile);
521
- const mode = booted && adapter.supportsResume ? 'resume' : 'fresh';
536
+ if (durableTask && !adapter.supportsResume)
537
+ throw Error('TASK_HARNESS_RESUME_UNSUPPORTED');
538
+ const mode = booted && adapter.supportsResume && !firstSessionRetry ? 'resume' : 'fresh';
539
+ const requireResume = retainedManaged && mode === 'resume';
540
+ if (retainedManaged && !recovery) {
541
+ // Older admitted sessions have no cursor. Continue once, without replaying
542
+ // their assignment; fresh sessions record intent before the first attempt.
543
+ recovery = { version: 1, session: booted ? 'established' : 'pending', readiness: booted,
544
+ initial: booted ? 'dispatching' : 'pending', active: booted };
545
+ writeManagedRecovery(dir, recovery);
546
+ }
547
+ const recoveryBefore = recovery ? { ...recovery } : undefined;
548
+ const saveRecovery = (update) => {
549
+ if (!recovery)
550
+ return;
551
+ recovery = { ...recovery, ...update };
552
+ writeManagedRecovery(dir, recovery);
553
+ };
522
554
  // Stamp EVERY attempt, not just the first. `.booted` used to be written only
523
555
  // on the fresh path, so after a restart — including one the supervisor never
524
556
  // saw, like an OOM-kill — its mtime still read the original boot and any
@@ -672,10 +704,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
672
704
  managedOurs: managedHarness.ours,
673
705
  role, prep,
674
706
  launch: { ...launch, argv: wrappedArgv, env: managedHarness.env },
675
- cwd: runCwd, stateDir: dir, mode, permissions: perms,
707
+ cwd: runCwd, stateDir: dir, mode, requireResume, permissions: perms,
676
708
  permissionMode: effectivePermissionMode(role), log: deps.log,
677
709
  }));
710
+ managedService.setFileDelivery?.(agentSession.sendFileToChat?.bind(agentSession));
678
711
  startupTelemetry.mark('harness_ready');
712
+ saveRecovery({ session: 'established' });
679
713
  pid = agentSession.pid;
680
714
  arbiter = new RoleTurnArbiter(agentSession);
681
715
  sessionHandle = arbiter;
@@ -696,7 +730,25 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
696
730
  startMonitorAfterStartup();
697
731
  }
698
732
  });
699
- unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); unsubscribeTelemetry?.(); };
733
+ const activePrompts = new Set();
734
+ const unsubscribeContinuity = agentSession.subscribeConversation?.(event => {
735
+ if (!recovery?.readiness)
736
+ return;
737
+ if (event.kind === 'prompt.started') {
738
+ if (event.promptId)
739
+ activePrompts.add(event.promptId);
740
+ saveRecovery({ active: true });
741
+ }
742
+ else if (event.kind === 'turn.completed' && 'outcome' in event.payload) {
743
+ if (event.promptId)
744
+ activePrompts.delete(event.promptId);
745
+ // A shutdown/failed turn needs continuation; a successful terminal turn
746
+ // proves it may stay idle. Never store prompt bodies in this cursor.
747
+ if (event.payload.outcome === 'completed')
748
+ saveRecovery({ active: activePrompts.size > 0 });
749
+ }
750
+ });
751
+ unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); unsubscribeTelemetry?.(); unsubscribeContinuity?.(); };
700
752
  if (role.owner_channel) {
701
753
  try {
702
754
  ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
@@ -817,8 +869,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
817
869
  // Wait for the first turn's TERMINAL result. An agent that accepts the
818
870
  // startup prompt and then refuses it has not started; logging the role as
819
871
  // up would hide a role that never completed its readiness turn.
820
- startupTelemetry.mark('startup_submitted');
821
- const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
872
+ const retainedResume = retainedManaged && mode === 'resume' && recoveryBefore?.readiness === true;
873
+ if (!retainedResume)
874
+ startupTelemetry.mark('startup_submitted');
875
+ // The backend already verified the saved conversation. Resuming a managed
876
+ // member admits new wakes/console input without replaying startup or work.
877
+ const starting = retainedResume
878
+ ? Promise.resolve({ accepted: false, succeeded: true, outcome: 'completed', detail: 'retained backend resumed; no readiness prompt submitted' })
879
+ : arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
822
880
  // Keep the early cursor baseline; do not deliver wakes into the protected
823
881
  // first turn. Steering can pre-empt startup just like cancellation.
824
882
  // Owner traffic and agent replies must be observed during a long first
@@ -883,20 +941,36 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
883
941
  + 'keeping temporary supervisor alive');
884
942
  startupRecoveryAllowed = interruptedForWake;
885
943
  sessionStartupComplete = started.succeeded || (interruptedForWake && successfulTurnObserved);
886
- if (started.succeeded)
944
+ if (started.succeeded && !retainedResume)
887
945
  startupTelemetry.mark('readiness_turn_completed');
946
+ if (started.succeeded)
947
+ saveRecovery({ readiness: true });
888
948
  // Readiness is now complete. Configured work (or retained continuity) is
889
949
  // admitted as a distinct turn; its terminal result must not block console
890
950
  // readiness, mail delivery, or supervisor liveness. Empty fresh agents wait
891
951
  // for authenticated console input or the monitor's unread/arrival wake.
892
- if (started.succeeded && !deps.shouldStop?.() && (role.mission?.trim() || role.roomMemberStartup?.task?.trim() || role.briefing_file?.trim() || booted)) {
893
- const queued = await arbiter.queuePrompt(managedTaskPrompt(dir, mode, booted), {
952
+ const configuredWork = Boolean(role.mission?.trim() || role.roomMemberStartup?.task?.trim() || role.briefing_file?.trim());
953
+ const initialPending = retainedManaged && recoveryBefore?.initial === 'pending';
954
+ const interruptedWork = retainedManaged && (recoveryBefore?.active || recoveryBefore?.initial === 'dispatching');
955
+ const workPrompt = retainedManaged
956
+ ? initialPending && configuredWork ? managedTaskPrompt(dir, 'fresh', false)
957
+ : interruptedWork ? 'Fleet supervisor restarted. Continue pending work in the restored conversation; do not redo completed steps. '
958
+ + (recoveryBefore?.initial === 'dispatching' ? `If you have not yet received your assignment, read ${join(dir, 'briefing.md')} once before acting. ` : '')
959
+ + 'Read the continuation log only as needed to recover your next action. If no work remains, end this turn and wait for a concrete task or supervisor wake.' : undefined
960
+ : configuredWork || booted ? managedTaskPrompt(dir, mode, booted) : undefined;
961
+ if (started.succeeded && !deps.shouldStop?.() && workPrompt) {
962
+ saveRecovery({ initial: 'dispatching', active: true });
963
+ const queued = await arbiter.queuePrompt(workPrompt, {
894
964
  origin: { kind: 'startup' },
895
965
  });
896
966
  void queued.completion.then(result => {
967
+ if (result.succeeded)
968
+ saveRecovery({ initial: 'completed', active: activePrompts.size > 0 });
897
969
  deps.log(`[${name}] configured task turn ${result.outcome}`);
898
970
  }, () => deps.log(`[${name}] configured task turn failed`));
899
971
  }
972
+ else if (started.succeeded && initialPending && !configuredWork)
973
+ saveRecovery({ initial: 'completed' });
900
974
  reloadLoopConfig = async () => {
901
975
  const nextRole = findRole(loadConfig(configPath), name);
902
976
  const definitions = nextRole.loops ?? [];
@@ -985,7 +1059,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
985
1059
  });
986
1060
  let chatIdle;
987
1061
  try {
988
- if (temp && !role.roomMemberStartup)
1062
+ if (temp && !role.roomMemberStartup && !readTempSupervisor(dir)?.taskOwner)
989
1063
  chatIdle = new TemporaryChatIdle(dir, deps.now());
990
1064
  }
991
1065
  catch {
@@ -1121,11 +1195,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1121
1195
  const rotate = (why) => {
1122
1196
  writeFileSync(sidFile, randomUUID() + '\n');
1123
1197
  rmSync(bootedFile, { force: true });
1198
+ rmSync(managedRecoveryPath(dir), { force: true });
1124
1199
  rotated = true;
1125
1200
  deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
1126
1201
  };
1127
- if (!temp && deps.shouldStop?.())
1128
- deps.log(`[${name}] supervisor stop requested -> next start RESUMES context`);
1202
+ if (readTempSupervisor(dir)?.taskOwner || ((!temp || deps.suspendOnStop) && deps.shouldStop?.()))
1203
+ deps.log(`[${name}] supervisor/context retained -> next start RESUMES context`);
1129
1204
  else if (exitRecord.detail.includes(ACP_CANCEL_DEADLINE_EXCEEDED)
1130
1205
  || exitRecord.detail.includes(CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED))
1131
1206
  // This is a deliberate adapter reclamation, not evidence that resume state
@@ -1175,11 +1250,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1175
1250
  */
1176
1251
  export async function runSupervised(name, opts = {}, partialDeps = {}, attempt = runOnce) {
1177
1252
  const deps = { ...defaultDeps(), ...partialDeps };
1178
- const dir = agentDir(name);
1253
+ const taskMember = opts.temp === true;
1254
+ const dir = agentDir(name, taskMember);
1179
1255
  mkdirSync(dir, { recursive: true });
1180
1256
  let stopping = false;
1181
1257
  const requestStop = () => { stopping = true; };
1182
- const shouldStop = () => stopping || (partialDeps.shouldStop?.() ?? false);
1258
+ const shouldStop = () => stopping || (partialDeps.shouldStop?.() ?? false)
1259
+ || (taskMember && requestedTempStopReason(dir) === 'operator-stop');
1183
1260
  deps.shouldStop = shouldStop;
1184
1261
  const stamp = () => new Date(deps.now()).toISOString();
1185
1262
  // Record how the PREVIOUS supervisor process ended before doing anything
@@ -1207,10 +1284,12 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1207
1284
  process.on('SIGINT', requestStop);
1208
1285
  try {
1209
1286
  while (!shouldStop()) {
1287
+ if (taskMember && !taskSupervisorMayRun(name))
1288
+ break;
1210
1289
  let ledger = readRestartLedger(dir);
1211
1290
  try {
1212
1291
  const configPath = resolveConfigPath(dir, opts.configPath);
1213
- const role = findRole(loadConfig(configPath), name);
1292
+ const role = taskMember ? loadTempRole(name) : findRole(loadConfig(configPath), name);
1214
1293
  reconcileModelRecovery(dir, role, stamp());
1215
1294
  if (modelRecoveryHeld(dir)) {
1216
1295
  await deps.sleep(HELD_DOWN_POLL_MS);
@@ -1226,18 +1305,26 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1226
1305
  await deps.sleep(HELD_DOWN_POLL_MS);
1227
1306
  continue;
1228
1307
  }
1308
+ if (attempt === runOnce && taskMember) {
1309
+ // Boot-time daemon unavailability must not consume the harness crash budget.
1310
+ const { waitForRoleDaemon } = await import('./startup-readiness.js');
1311
+ await waitForRoleDaemon(name, undefined, {}, true);
1312
+ }
1313
+ if (taskMember && (shouldStop() || !taskSupervisorMayRun(name)))
1314
+ break;
1229
1315
  let result;
1230
1316
  try {
1231
1317
  // Service-manager boot and automatic retry bypass the operator-facing
1232
1318
  // up/restart commands. Reconcile again immediately before every real
1233
1319
  // permanent harness attempt; the operation is idempotent and releases its
1234
1320
  // provisioning lease before the agent or owner channel binds.
1235
- if (attempt === runOnce) {
1321
+ if (attempt === runOnce && !taskMember) {
1236
1322
  const configPath = resolveConfigPath(dir, opts.configPath);
1237
1323
  const role = findRole(loadConfig(configPath), name);
1238
1324
  await reconcilePermanentRoleIdentities(role, undefined, deps.log);
1239
1325
  }
1240
- result = await attempt(name, { configPath: opts.configPath, allowResumeRotation: !ledger.resumeDiscarded }, deps);
1326
+ result = await attempt(name, { configPath: opts.configPath, ...(taskMember ? { temp: true } : {}),
1327
+ allowResumeRotation: taskMember ? false : !ledger.resumeDiscarded }, deps);
1241
1328
  }
1242
1329
  catch (e) {
1243
1330
  if (e instanceof SupervisorRecycleRequiredError) {
@@ -1259,7 +1346,7 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1259
1346
  mode: 'fresh',
1260
1347
  };
1261
1348
  }
1262
- if (stopping)
1349
+ if (stopping || (taskMember && (shouldStop() || result.retirementReason)))
1263
1350
  break;
1264
1351
  // Re-read: the attempt itself may have taken minutes, and an operator may
1265
1352
  // have reset the ledger meanwhile.
@@ -1277,7 +1364,8 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1277
1364
  await deps.sleep(HELD_DOWN_POLL_MS);
1278
1365
  continue;
1279
1366
  }
1280
- const fastFailSecs = fastFailSecsFor(name, opts.configPath);
1367
+ const fastFailSecs = taskMember ? getAdapter(loadTempRole(name).harness).exitPolicy.fastFailSecs
1368
+ : fastFailSecsFor(name, opts.configPath);
1281
1369
  // The fast-fail boundary starts a recovery episode; it must not also be
1282
1370
  // the boundary that declares recovery successful. Otherwise alternating
1283
1371
  // 19s and 20s deaths erase one another forever. Require the configured
@@ -1314,7 +1402,7 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1314
1402
  next.nextDelayMs = 0;
1315
1403
  writeRestartLedger(dir, next);
1316
1404
  deps.log(`[${name}] HELD DOWN after ${failures} immediate failures at ${next.openedAt} — ` +
1317
- `${reason}; the agent will not be restarted until: ours-fleet restart ${name}`);
1405
+ `${reason}; recover with: ${taskMember ? `ours-fleet task recover-members ${readTempSupervisor(dir).taskOwner.taskId}` : `ours-fleet restart ${name}`}`);
1318
1406
  continue;
1319
1407
  }
1320
1408
  writeRestartLedger(dir, next);
@@ -1329,7 +1417,7 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1329
1417
  process.off('SIGINT', requestStop);
1330
1418
  // An orderly shutdown clears the marker; an unhandled signal or OOM-kill
1331
1419
  // leaves it so the successor can identify an abrupt termination.
1332
- if (shouldStop())
1420
+ if (!taskMember && shouldStop())
1333
1421
  await deps.releaseAgentOurs(findRole(loadConfig(opts.configPath), name));
1334
1422
  releaseSupervisorRun(dir);
1335
1423
  }
@@ -1349,8 +1437,58 @@ function fastFailSecsFor(name, configPath) {
1349
1437
  }
1350
1438
  }
1351
1439
  /** Temp-agent entrypoint: run once, journal why it ended, then archive its evidence. */
1440
+ /** The parent uses the same adapter-relative sustained-uptime reset boundary. */
1441
+ export function stableSupervisorWindow(name, temp, configPath) {
1442
+ try {
1443
+ const role = temp ? loadTempRole(name) : findRole(loadConfig(configPath), name);
1444
+ return getAdapter(role.harness).exitPolicy.fastFailSecs * RESTART_FAIL_THRESHOLD;
1445
+ }
1446
+ catch {
1447
+ return 20 * RESTART_FAIL_THRESHOLD;
1448
+ }
1449
+ }
1352
1450
  export async function runTemp(name, deps = {}, attempt = runOnce) {
1353
1451
  const dir = agentDir(name, true);
1452
+ const metadata = readTempSupervisor(dir);
1453
+ // Non-resumable standalone temporaries retain their prior finite lifecycle:
1454
+ // a parent stop retires them rather than promising retained conversation.
1455
+ if (!metadata?.taskOwner && deps.suspendOnStop && !getAdapter(loadTempRole(name).harness).supportsResume)
1456
+ deps = { ...deps, suspendOnStop: false };
1457
+ if (!metadata?.taskOwner) {
1458
+ const role = loadTempRole(name);
1459
+ const provenance = readProvenance(dir);
1460
+ if (role.roomMemberStartup?.task_id
1461
+ || (name.startsWith('layout-') && provenance?.creationActionId?.startsWith('task-')))
1462
+ throw Error('TASK_SUPERVISOR_OWNER_MISSING');
1463
+ }
1464
+ if (metadata?.taskOwner) {
1465
+ // Serialize the entire process lifetime before metadata/session writes.
1466
+ const lease = await acquireOwnerBinderLease(join(dir, '.task-supervisor'), 'task-supervisor', name);
1467
+ let ownershipVerified = false;
1468
+ try {
1469
+ const mayRun = taskSupervisorMayRun(name);
1470
+ ownershipVerified = true;
1471
+ if (!mayRun)
1472
+ return;
1473
+ await markTempSupervisorActive(dir);
1474
+ await runSupervised(name, { temp: true }, deps, attempt);
1475
+ }
1476
+ finally {
1477
+ try {
1478
+ if (ownershipVerified && requestedTempStopReason(dir)) {
1479
+ // Parent shutdown suspends; an explicit stop retires the exact saved
1480
+ // owner even when no harness attempt ran (for example held circuits).
1481
+ await (deps.releaseAgentOurs ?? releaseManagedAgent)(loadTempRole(name), taskMemberRetirementProof(name));
1482
+ const archived = archiveTempState(name, 'operator-stop', 'retired', 'explicit task member retirement');
1483
+ deps.log?.(`[${name}] task member retired: operator-stop${archived ? `; evidence archived at ${archived}` : ''}`);
1484
+ }
1485
+ }
1486
+ finally {
1487
+ lease.release();
1488
+ }
1489
+ }
1490
+ return;
1491
+ }
1354
1492
  await markTempSupervisorActive(dir);
1355
1493
  let signal;
1356
1494
  const onTerm = () => { signal = 'SIGTERM'; };
@@ -1377,7 +1515,8 @@ export async function runTemp(name, deps = {}, attempt = runOnce) {
1377
1515
  process.off('SIGTERM', onTerm);
1378
1516
  process.off('SIGINT', onInt);
1379
1517
  // A recycle replaces the supervisor process, not the logical temporary agent.
1380
- if (!(failure instanceof SupervisorRecycleRequiredError) || signal || deps.shouldStop?.()) {
1518
+ if (!((signal || deps.shouldStop?.()) && deps.suspendOnStop && !requestedTempStopReason(dir))
1519
+ && (!(failure instanceof SupervisorRecycleRequiredError) || signal || deps.shouldStop?.())) {
1381
1520
  const requested = requestedTempStopReason(dir);
1382
1521
  const reason = requested
1383
1522
  ?? result?.retirementReason
@@ -1,3 +1,4 @@
1
+ import type { FileDeliveryInput, CopyChatFile, DeliveredFile } from '../file-delivery/types.js';
1
2
  import { type TaskNoticeBinding, type TaskCreatedNotice } from './task-notice.js';
2
3
  import * as acp from '@agentclientprotocol/sdk';
3
4
  import type { CommonPermissions } from '../config.js';
@@ -50,6 +51,7 @@ export interface AcpSessionOptions {
50
51
  inheritEnvironment?: boolean;
51
52
  stateDir: string;
52
53
  mode: 'fresh' | 'resume';
54
+ requireResume?: boolean;
53
55
  permissions: CommonPermissions;
54
56
  /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
55
57
  modeId?: string;
@@ -176,6 +178,7 @@ export declare class AcpSession implements AgentSession {
176
178
  private stallTimer?;
177
179
  private stallAttempt?;
178
180
  private readonly toolBoundaryWaiters;
181
+ private fileExportBusy;
179
182
  private activeTurn?;
180
183
  private constructor();
181
184
  static start(options: AcpSessionOptions): Promise<AcpSession>;
@@ -303,6 +306,8 @@ export declare class AcpSession implements AgentSession {
303
306
  */
304
307
  private settlePendingAutomatically;
305
308
  exitResult(): ExitRecord | null;
309
+ /** One attempt, tied to the managed live turn captured before any approval/read. */
310
+ sendFileToChat(input: FileDeliveryInput, signal: AbortSignal, copy: CopyChatFile, policy: CommonPermissions): Promise<DeliveredFile>;
306
311
  close(): Promise<void>;
307
312
  /** ACP requires the field. Bundled agents treat [] as no client-added servers. */
308
313
  private declaredMcpServers;
@@ -278,6 +278,7 @@ export class AcpSession {
278
278
  stallTimer;
279
279
  stallAttempt;
280
280
  toolBoundaryWaiters = new Set();
281
+ fileExportBusy = false;
281
282
  activeTurn;
282
283
  constructor(options, child, connection) {
283
284
  this.options = options;
@@ -466,6 +467,7 @@ export class AcpSession {
466
467
  readiness: this.readiness === 'idle' && this.steeringOccupied
467
468
  ? 'running' : this.readiness,
468
469
  sessionId: this.sessionId,
470
+ ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.origin.kind } : {}),
469
471
  lastError: this.lastError,
470
472
  pendingPermissionId: this.pendingPermissions.keys().next().value,
471
473
  runtimeModel: this.runtimeModel,
@@ -580,6 +582,10 @@ export class AcpSession {
580
582
  * steering response from becoming a tight replay loop.
581
583
  */
582
584
  async steerOrQueueWake(text, options) {
585
+ // A tool-boundary wait may outlive its original turn. Recheck the current
586
+ // typed source at delivery, since an Owner turn can begin during the wait.
587
+ if (options.origin?.kind === 'fleet-monitor' && this.activeTurn?.origin?.kind === 'owner-admin-console')
588
+ return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
583
589
  if (this.stallRecoveryClaimed)
584
590
  return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
585
591
  const steered = await this.steerPrompt(text);
@@ -823,6 +829,8 @@ export class AcpSession {
823
829
  if (!this.sessionId)
824
830
  return;
825
831
  const active = this.activeTurn;
832
+ if (active)
833
+ active.fileDeliveryRevoked = true;
826
834
  const previousSource = active?.cancellationSource;
827
835
  if (active && (source === 'owner' || source === 'local-console' || !previousSource))
828
836
  active.cancellationSource = source;
@@ -1026,6 +1034,54 @@ export class AcpSession {
1026
1034
  exitResult() {
1027
1035
  return this.exit;
1028
1036
  }
1037
+ /** One attempt, tied to the managed live turn captured before any approval/read. */
1038
+ async sendFileToChat(input, signal, copy, policy) {
1039
+ const active = this.activeTurn, sessionId = this.sessionId;
1040
+ const check = () => {
1041
+ signal.throwIfAborted();
1042
+ if (!active || !sessionId || this.activeTurn !== active || this.sessionId !== sessionId || active.fileDeliveryRevoked
1043
+ || active.cancellationSource || this.closing || !this.isAlive() || this.replaying || this.steeringOccupied
1044
+ || active.origin?.kind === 'scheduled-loop' || policy.approval === 'deny')
1045
+ throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE');
1046
+ };
1047
+ check();
1048
+ if (this.fileExportBusy)
1049
+ throw Error('FILE_DELIVERY_BUSY');
1050
+ this.fileExportBusy = true;
1051
+ const toolCallId = 'fleet-file-export-' + randomUUID();
1052
+ try {
1053
+ const onAbort = () => {
1054
+ for (const [id, pending] of this.pendingPermissions)
1055
+ if (pending.toolCallId === toolCallId)
1056
+ this.settlePendingAutomatically(id, pending, 'cancelled', undefined, 'File send cancelled');
1057
+ };
1058
+ signal.addEventListener('abort', onAbort, { once: true });
1059
+ let answer;
1060
+ try {
1061
+ answer = await this.requestPermission({ sessionId: sessionId, toolCall: {
1062
+ toolCallId, title: 'Send file to this ACP chat: ' + (input.filename ?? input.path), kind: 'read', status: 'pending',
1063
+ locations: [{ path: resolve(this.options.cwd, input.path) }],
1064
+ }, options: [{ optionId: 'send', name: 'Send file', kind: 'allow_once' }, { optionId: 'deny', name: 'Do not send', kind: 'reject_once' }] }, policy);
1065
+ }
1066
+ finally {
1067
+ signal.removeEventListener('abort', onAbort);
1068
+ }
1069
+ check();
1070
+ if (answer.outcome.outcome !== 'selected' || answer.outcome.optionId !== 'send')
1071
+ throw Error('FILE_SEND_DENIED');
1072
+ const attachment = await copy({ sessionGeneration: this.sessionGeneration, acpSessionId: sessionId, turnId: active.id });
1073
+ // Synchronous check + durable append establishes order with cancellation/stop.
1074
+ // No await, pending-send journal, replay or recovery exists after this point.
1075
+ check();
1076
+ this.conversation.append({ kind: 'file.attached', source: 'agent', sessionGeneration: this.sessionGeneration,
1077
+ acpSessionId: sessionId, promptId: active.id, turnId: active.id, payload: { attachment } });
1078
+ return attachment;
1079
+ }
1080
+ finally {
1081
+ this.releaseTool(toolCallId);
1082
+ this.fileExportBusy = false;
1083
+ }
1084
+ }
1029
1085
  async close() {
1030
1086
  this.closing = true;
1031
1087
  if (this.stallTimer)
@@ -1079,6 +1135,8 @@ export class AcpSession {
1079
1135
  const persisted = this.options.mode === 'resume' && existsSync(this.sessionFile)
1080
1136
  ? readFileSync(this.sessionFile, 'utf8').trim()
1081
1137
  : '';
1138
+ if (this.options.requireResume && (!persisted || !(this.agentCapabilities?.sessionCapabilities?.resume != null || this.agentCapabilities?.loadSession)))
1139
+ throw new Error('TASK_CONTEXT_RESUME_UNAVAILABLE');
1082
1140
  let advertisedConfigOptions;
1083
1141
  let advertisedModes;
1084
1142
  let advertisedModelId;
@@ -1414,6 +1472,8 @@ export class AcpSession {
1414
1472
  }
1415
1473
  async steerPrompt(text) {
1416
1474
  this.steeringWasUsed = true;
1475
+ if (this.activeTurn)
1476
+ this.activeTurn.fileDeliveryRevoked = true;
1417
1477
  if (!this.sessionId || !this.isAlive())
1418
1478
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
1419
1479
  this.steeringRequests++;
@@ -1445,7 +1505,7 @@ export class AcpSession {
1445
1505
  this.steeringRequests--;
1446
1506
  }
1447
1507
  }
1448
- requestPermission(params) {
1508
+ requestPermission(params, neutralPolicy) {
1449
1509
  // `kinds` is a PRIORITY order. Scanning the agent's option array instead
1450
1510
  // (`options.find(o => kinds.includes(o.kind))`) hands the choice to whatever
1451
1511
  // order the agent happened to list, which is exactly how an automatic denial
@@ -1463,7 +1523,7 @@ export class AcpSession {
1463
1523
  // Permission is part of the tool lifecycle. Reserve before any policy or
1464
1524
  // human decision so a monitor wake cannot slip between request and answer.
1465
1525
  this.reservePermission(toolCallId, permissionId);
1466
- if (this.isEffectiveCodexProtectedMcpApproval(params)) {
1526
+ if (!neutralPolicy && this.isEffectiveCodexProtectedMcpApproval(params)) {
1467
1527
  // Protected MCP approval is already the tool's narrow gate. Never turn
1468
1528
  // this one decision into an adapter-wide standing grant.
1469
1529
  const option = choose(['allow_once']);
@@ -1474,9 +1534,9 @@ export class AcpSession {
1474
1534
  this.releasePermission(toolCallId, permissionId);
1475
1535
  return Promise.resolve(response);
1476
1536
  }
1477
- if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
1537
+ if ((neutralPolicy ?? this.options.permissions).approval === 'allow' && this.withinAutomaticBoundary(params, neutralPolicy)) {
1478
1538
  const option = choose(['allow_always', 'allow_once']);
1479
- const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
1539
+ const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${(neutralPolicy ?? this.options.permissions).filesystem} boundary`);
1480
1540
  if (option)
1481
1541
  this.allowPermission(toolCallId, permissionId);
1482
1542
  else
@@ -1486,8 +1546,8 @@ export class AcpSession {
1486
1546
  // A live grace window still counts as attended: the controller may be
1487
1547
  // mid-reconnect, and denying instantly is exactly what the grace prevents.
1488
1548
  const unattended = this.controllerCount === 0 && !this.controllerGrace
1489
- && this.options.permissions.unattended === 'deny';
1490
- if (this.options.permissions.approval === 'deny' || unattended) {
1549
+ && (neutralPolicy ?? this.options.permissions).unattended === 'deny';
1550
+ if ((neutralPolicy ?? this.options.permissions).approval === 'deny' || unattended) {
1491
1551
  // reject_once FIRST: `reject_always` teaches the agent a standing rule from
1492
1552
  // a decision no human made, so one unattended denial would silently disable
1493
1553
  // the tool for the rest of the session.
@@ -1588,8 +1648,8 @@ export class AcpSession {
1588
1648
  ? { outcome: { outcome: 'selected', optionId: option.optionId } }
1589
1649
  : { outcome: { outcome: 'cancelled' } };
1590
1650
  }
1591
- withinAutomaticBoundary(params) {
1592
- const filesystem = this.options.permissions.filesystem;
1651
+ withinAutomaticBoundary(params, policy) {
1652
+ const filesystem = (policy ?? this.options.permissions).filesystem;
1593
1653
  if (filesystem === 'unrestricted')
1594
1654
  return true;
1595
1655
  if (filesystem === 'read-only' && params.toolCall.kind !== 'read')