@ours.network/fleet 1.2.0-nightly.54 → 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 (84) hide show
  1. package/README.md +20 -12
  2. package/dist/agent-ours/service.d.ts +4 -1
  3. package/dist/agent-ours/service.js +31 -7
  4. package/dist/application/log-service.js +8 -8
  5. package/dist/application/role-creation-service.js +2 -1
  6. package/dist/application/role-removal-service.js +7 -1
  7. package/dist/application/role-repository.js +17 -0
  8. package/dist/application/task-layouts.js +12 -2
  9. package/dist/application/task-room-service.js +19 -17
  10. package/dist/application/types.d.ts +2 -0
  11. package/dist/build-info.json +4 -4
  12. package/dist/cli.js +31 -3
  13. package/dist/config.d.ts +2 -0
  14. package/dist/harness/agent-session.d.ts +2 -0
  15. package/dist/harness/claude-code-session.js +1 -1
  16. package/dist/harness/codex-session.js +2 -2
  17. package/dist/managed-recovery.d.ts +13 -0
  18. package/dist/managed-recovery.js +31 -0
  19. package/dist/ops.d.ts +2 -0
  20. package/dist/ops.js +15 -2
  21. package/dist/rooms-tasks/cli.js +21 -0
  22. package/dist/rooms-tasks/close.d.ts +2 -0
  23. package/dist/rooms-tasks/close.js +66 -1
  24. package/dist/rooms-tasks/deletion.js +10 -0
  25. package/dist/rooms-tasks/erasure.js +4 -2
  26. package/dist/rooms-tasks/layout-member-retirement.d.ts +10 -0
  27. package/dist/rooms-tasks/layout-member-retirement.js +90 -0
  28. package/dist/rooms-tasks/layout-service.d.ts +1 -0
  29. package/dist/rooms-tasks/layout-service.js +31 -13
  30. package/dist/rooms-tasks/layout.d.ts +7 -0
  31. package/dist/rooms-tasks/layout.js +35 -0
  32. package/dist/rooms-tasks/legacy-task-member.d.ts +19 -0
  33. package/dist/rooms-tasks/legacy-task-member.js +116 -0
  34. package/dist/rooms-tasks/member-ownership.d.ts +5 -0
  35. package/dist/rooms-tasks/member-ownership.js +40 -0
  36. package/dist/rooms-tasks/provision.js +26 -1
  37. package/dist/rooms-tasks/recovery.d.ts +19 -0
  38. package/dist/rooms-tasks/recovery.js +110 -0
  39. package/dist/rooms-tasks/task-service-retirement.d.ts +11 -0
  40. package/dist/rooms-tasks/task-service-retirement.js +21 -0
  41. package/dist/rooms-tasks/task-state.d.ts +1 -0
  42. package/dist/rooms-tasks/task-state.js +16 -1
  43. package/dist/rooms-tasks/types.d.ts +3 -0
  44. package/dist/rooms-tasks/workspace.d.ts +2 -0
  45. package/dist/rooms-tasks/workspace.js +5 -0
  46. package/dist/runner.d.ts +6 -0
  47. package/dist/runner.js +160 -22
  48. package/dist/session/acp.d.ts +1 -0
  49. package/dist/session/acp.js +7 -0
  50. package/dist/session/arbiter.d.ts +5 -0
  51. package/dist/session/arbiter.js +16 -2
  52. package/dist/session/codex-app-server.d.ts +1 -0
  53. package/dist/session/codex-app-server.js +5 -0
  54. package/dist/session/types.d.ts +2 -0
  55. package/dist/spawn.d.ts +5 -1
  56. package/dist/spawn.js +32 -3
  57. package/dist/startup-readiness.d.ts +1 -1
  58. package/dist/startup-readiness.js +3 -3
  59. package/dist/supervisor/adoption.d.ts +8 -0
  60. package/dist/supervisor/adoption.js +78 -0
  61. package/dist/supervisor/catalog.d.ts +62 -0
  62. package/dist/supervisor/catalog.js +254 -0
  63. package/dist/supervisor/fleet.d.ts +8 -0
  64. package/dist/supervisor/fleet.js +89 -0
  65. package/dist/supervisor/index.js +3 -6
  66. package/dist/supervisor/launchd.d.ts +1 -1
  67. package/dist/supervisor/launchd.js +24 -12
  68. package/dist/supervisor/legacy.d.ts +35 -0
  69. package/dist/supervisor/legacy.js +524 -0
  70. package/dist/supervisor/manager.d.ts +15 -0
  71. package/dist/supervisor/manager.js +258 -0
  72. package/dist/supervisor/scope.d.ts +4 -0
  73. package/dist/supervisor/scope.js +10 -0
  74. package/dist/supervisor/systemd.d.ts +2 -2
  75. package/dist/supervisor/systemd.js +14 -14
  76. package/dist/task-supervision.d.ts +9 -0
  77. package/dist/task-supervision.js +145 -0
  78. package/dist/temp-lifecycle.d.ts +17 -7
  79. package/dist/temp-lifecycle.js +85 -53
  80. package/dist/temp-supervisor-recovery.d.ts +1 -1
  81. package/dist/temp-supervisor-recovery.js +2 -2
  82. package/docs/fleet-migration.md +54 -0
  83. package/docs/task-workspaces.md +26 -0
  84. package/package.json +4 -1
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,11 +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
  }));
678
710
  managedService.setFileDelivery?.(agentSession.sendFileToChat?.bind(agentSession));
679
711
  startupTelemetry.mark('harness_ready');
712
+ saveRecovery({ session: 'established' });
680
713
  pid = agentSession.pid;
681
714
  arbiter = new RoleTurnArbiter(agentSession);
682
715
  sessionHandle = arbiter;
@@ -697,7 +730,25 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
697
730
  startMonitorAfterStartup();
698
731
  }
699
732
  });
700
- 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?.(); };
701
752
  if (role.owner_channel) {
702
753
  try {
703
754
  ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
@@ -818,8 +869,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
818
869
  // Wait for the first turn's TERMINAL result. An agent that accepts the
819
870
  // startup prompt and then refuses it has not started; logging the role as
820
871
  // up would hide a role that never completed its readiness turn.
821
- startupTelemetry.mark('startup_submitted');
822
- 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' } });
823
880
  // Keep the early cursor baseline; do not deliver wakes into the protected
824
881
  // first turn. Steering can pre-empt startup just like cancellation.
825
882
  // Owner traffic and agent replies must be observed during a long first
@@ -884,20 +941,36 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
884
941
  + 'keeping temporary supervisor alive');
885
942
  startupRecoveryAllowed = interruptedForWake;
886
943
  sessionStartupComplete = started.succeeded || (interruptedForWake && successfulTurnObserved);
887
- if (started.succeeded)
944
+ if (started.succeeded && !retainedResume)
888
945
  startupTelemetry.mark('readiness_turn_completed');
946
+ if (started.succeeded)
947
+ saveRecovery({ readiness: true });
889
948
  // Readiness is now complete. Configured work (or retained continuity) is
890
949
  // admitted as a distinct turn; its terminal result must not block console
891
950
  // readiness, mail delivery, or supervisor liveness. Empty fresh agents wait
892
951
  // for authenticated console input or the monitor's unread/arrival wake.
893
- if (started.succeeded && !deps.shouldStop?.() && (role.mission?.trim() || role.roomMemberStartup?.task?.trim() || role.briefing_file?.trim() || booted)) {
894
- 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, {
895
964
  origin: { kind: 'startup' },
896
965
  });
897
966
  void queued.completion.then(result => {
967
+ if (result.succeeded)
968
+ saveRecovery({ initial: 'completed', active: activePrompts.size > 0 });
898
969
  deps.log(`[${name}] configured task turn ${result.outcome}`);
899
970
  }, () => deps.log(`[${name}] configured task turn failed`));
900
971
  }
972
+ else if (started.succeeded && initialPending && !configuredWork)
973
+ saveRecovery({ initial: 'completed' });
901
974
  reloadLoopConfig = async () => {
902
975
  const nextRole = findRole(loadConfig(configPath), name);
903
976
  const definitions = nextRole.loops ?? [];
@@ -986,7 +1059,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
986
1059
  });
987
1060
  let chatIdle;
988
1061
  try {
989
- if (temp && !role.roomMemberStartup)
1062
+ if (temp && !role.roomMemberStartup && !readTempSupervisor(dir)?.taskOwner)
990
1063
  chatIdle = new TemporaryChatIdle(dir, deps.now());
991
1064
  }
992
1065
  catch {
@@ -1122,11 +1195,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1122
1195
  const rotate = (why) => {
1123
1196
  writeFileSync(sidFile, randomUUID() + '\n');
1124
1197
  rmSync(bootedFile, { force: true });
1198
+ rmSync(managedRecoveryPath(dir), { force: true });
1125
1199
  rotated = true;
1126
1200
  deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
1127
1201
  };
1128
- if (!temp && deps.shouldStop?.())
1129
- 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`);
1130
1204
  else if (exitRecord.detail.includes(ACP_CANCEL_DEADLINE_EXCEEDED)
1131
1205
  || exitRecord.detail.includes(CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED))
1132
1206
  // This is a deliberate adapter reclamation, not evidence that resume state
@@ -1176,11 +1250,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1176
1250
  */
1177
1251
  export async function runSupervised(name, opts = {}, partialDeps = {}, attempt = runOnce) {
1178
1252
  const deps = { ...defaultDeps(), ...partialDeps };
1179
- const dir = agentDir(name);
1253
+ const taskMember = opts.temp === true;
1254
+ const dir = agentDir(name, taskMember);
1180
1255
  mkdirSync(dir, { recursive: true });
1181
1256
  let stopping = false;
1182
1257
  const requestStop = () => { stopping = true; };
1183
- const shouldStop = () => stopping || (partialDeps.shouldStop?.() ?? false);
1258
+ const shouldStop = () => stopping || (partialDeps.shouldStop?.() ?? false)
1259
+ || (taskMember && requestedTempStopReason(dir) === 'operator-stop');
1184
1260
  deps.shouldStop = shouldStop;
1185
1261
  const stamp = () => new Date(deps.now()).toISOString();
1186
1262
  // Record how the PREVIOUS supervisor process ended before doing anything
@@ -1208,10 +1284,12 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1208
1284
  process.on('SIGINT', requestStop);
1209
1285
  try {
1210
1286
  while (!shouldStop()) {
1287
+ if (taskMember && !taskSupervisorMayRun(name))
1288
+ break;
1211
1289
  let ledger = readRestartLedger(dir);
1212
1290
  try {
1213
1291
  const configPath = resolveConfigPath(dir, opts.configPath);
1214
- const role = findRole(loadConfig(configPath), name);
1292
+ const role = taskMember ? loadTempRole(name) : findRole(loadConfig(configPath), name);
1215
1293
  reconcileModelRecovery(dir, role, stamp());
1216
1294
  if (modelRecoveryHeld(dir)) {
1217
1295
  await deps.sleep(HELD_DOWN_POLL_MS);
@@ -1227,18 +1305,26 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1227
1305
  await deps.sleep(HELD_DOWN_POLL_MS);
1228
1306
  continue;
1229
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;
1230
1315
  let result;
1231
1316
  try {
1232
1317
  // Service-manager boot and automatic retry bypass the operator-facing
1233
1318
  // up/restart commands. Reconcile again immediately before every real
1234
1319
  // permanent harness attempt; the operation is idempotent and releases its
1235
1320
  // provisioning lease before the agent or owner channel binds.
1236
- if (attempt === runOnce) {
1321
+ if (attempt === runOnce && !taskMember) {
1237
1322
  const configPath = resolveConfigPath(dir, opts.configPath);
1238
1323
  const role = findRole(loadConfig(configPath), name);
1239
1324
  await reconcilePermanentRoleIdentities(role, undefined, deps.log);
1240
1325
  }
1241
- 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);
1242
1328
  }
1243
1329
  catch (e) {
1244
1330
  if (e instanceof SupervisorRecycleRequiredError) {
@@ -1260,7 +1346,7 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1260
1346
  mode: 'fresh',
1261
1347
  };
1262
1348
  }
1263
- if (stopping)
1349
+ if (stopping || (taskMember && (shouldStop() || result.retirementReason)))
1264
1350
  break;
1265
1351
  // Re-read: the attempt itself may have taken minutes, and an operator may
1266
1352
  // have reset the ledger meanwhile.
@@ -1278,7 +1364,8 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1278
1364
  await deps.sleep(HELD_DOWN_POLL_MS);
1279
1365
  continue;
1280
1366
  }
1281
- const fastFailSecs = fastFailSecsFor(name, opts.configPath);
1367
+ const fastFailSecs = taskMember ? getAdapter(loadTempRole(name).harness).exitPolicy.fastFailSecs
1368
+ : fastFailSecsFor(name, opts.configPath);
1282
1369
  // The fast-fail boundary starts a recovery episode; it must not also be
1283
1370
  // the boundary that declares recovery successful. Otherwise alternating
1284
1371
  // 19s and 20s deaths erase one another forever. Require the configured
@@ -1315,7 +1402,7 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1315
1402
  next.nextDelayMs = 0;
1316
1403
  writeRestartLedger(dir, next);
1317
1404
  deps.log(`[${name}] HELD DOWN after ${failures} immediate failures at ${next.openedAt} — ` +
1318
- `${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}`}`);
1319
1406
  continue;
1320
1407
  }
1321
1408
  writeRestartLedger(dir, next);
@@ -1330,7 +1417,7 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1330
1417
  process.off('SIGINT', requestStop);
1331
1418
  // An orderly shutdown clears the marker; an unhandled signal or OOM-kill
1332
1419
  // leaves it so the successor can identify an abrupt termination.
1333
- if (shouldStop())
1420
+ if (!taskMember && shouldStop())
1334
1421
  await deps.releaseAgentOurs(findRole(loadConfig(opts.configPath), name));
1335
1422
  releaseSupervisorRun(dir);
1336
1423
  }
@@ -1350,8 +1437,58 @@ function fastFailSecsFor(name, configPath) {
1350
1437
  }
1351
1438
  }
1352
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
+ }
1353
1450
  export async function runTemp(name, deps = {}, attempt = runOnce) {
1354
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
+ }
1355
1492
  await markTempSupervisorActive(dir);
1356
1493
  let signal;
1357
1494
  const onTerm = () => { signal = 'SIGTERM'; };
@@ -1378,7 +1515,8 @@ export async function runTemp(name, deps = {}, attempt = runOnce) {
1378
1515
  process.off('SIGTERM', onTerm);
1379
1516
  process.off('SIGINT', onInt);
1380
1517
  // A recycle replaces the supervisor process, not the logical temporary agent.
1381
- if (!(failure instanceof SupervisorRecycleRequiredError) || signal || deps.shouldStop?.()) {
1518
+ if (!((signal || deps.shouldStop?.()) && deps.suspendOnStop && !requestedTempStopReason(dir))
1519
+ && (!(failure instanceof SupervisorRecycleRequiredError) || signal || deps.shouldStop?.())) {
1382
1520
  const requested = requestedTempStopReason(dir);
1383
1521
  const reason = requested
1384
1522
  ?? result?.retirementReason
@@ -51,6 +51,7 @@ export interface AcpSessionOptions {
51
51
  inheritEnvironment?: boolean;
52
52
  stateDir: string;
53
53
  mode: 'fresh' | 'resume';
54
+ requireResume?: boolean;
54
55
  permissions: CommonPermissions;
55
56
  /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
56
57
  modeId?: string;
@@ -467,6 +467,7 @@ export class AcpSession {
467
467
  readiness: this.readiness === 'idle' && this.steeringOccupied
468
468
  ? 'running' : this.readiness,
469
469
  sessionId: this.sessionId,
470
+ ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.origin.kind } : {}),
470
471
  lastError: this.lastError,
471
472
  pendingPermissionId: this.pendingPermissions.keys().next().value,
472
473
  runtimeModel: this.runtimeModel,
@@ -581,6 +582,10 @@ export class AcpSession {
581
582
  * steering response from becoming a tight replay loop.
582
583
  */
583
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 });
584
589
  if (this.stallRecoveryClaimed)
585
590
  return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
586
591
  const steered = await this.steerPrompt(text);
@@ -1130,6 +1135,8 @@ export class AcpSession {
1130
1135
  const persisted = this.options.mode === 'resume' && existsSync(this.sessionFile)
1131
1136
  ? readFileSync(this.sessionFile, 'utf8').trim()
1132
1137
  : '';
1138
+ if (this.options.requireResume && (!persisted || !(this.agentCapabilities?.sessionCapabilities?.resume != null || this.agentCapabilities?.loadSession)))
1139
+ throw new Error('TASK_CONTEXT_RESUME_UNAVAILABLE');
1133
1140
  let advertisedConfigOptions;
1134
1141
  let advertisedModes;
1135
1142
  let advertisedModelId;
@@ -54,6 +54,11 @@ export declare class RoleTurnArbiter implements AgentSession {
54
54
  */
55
55
  retireStalledAdmission(): void;
56
56
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
57
+ /** Inbox traffic must not cancel or steer an active human console/voice turn.
58
+ * Queue it once; the monitor commits its cursor after that turn succeeds.
59
+ * Other origins retain their configured monitor policy.
60
+ */
61
+ private monitorAdmissionOptions;
57
62
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
58
63
  /**
59
64
  * Do not hold `exclusive` while ACP waits for a tool boundary: permission
@@ -90,8 +90,20 @@ export class RoleTurnArbiter {
90
90
  retired.retire();
91
91
  }
92
92
  queuePrompt(text, options = {}) {
93
- return this.exclusive(async () => { if (this.retiring)
94
- throw new Error('Chat is closing'); return this.track(await this.session.queuePrompt(text, options)); });
93
+ return this.exclusive(async () => {
94
+ if (this.retiring)
95
+ throw new Error('Chat is closing');
96
+ return this.track(await this.session.queuePrompt(text, this.monitorAdmissionOptions(options)));
97
+ });
98
+ }
99
+ /** Inbox traffic must not cancel or steer an active human console/voice turn.
100
+ * Queue it once; the monitor commits its cursor after that turn succeeds.
101
+ * Other origins retain their configured monitor policy.
102
+ */
103
+ monitorAdmissionOptions(options) {
104
+ return options.origin?.kind === 'fleet-monitor'
105
+ && this.session.snapshot().activePromptSource === 'owner-admin-console'
106
+ ? { ...options, interrupt: false, steer: false } : options;
95
107
  }
96
108
  async submitPrompt(text, options = {}) {
97
109
  return (await this.queuePrompt(text, options)).completion;
@@ -103,6 +115,8 @@ export class RoleTurnArbiter {
103
115
  submitPromptAfterTool(text, options = {}) {
104
116
  if (this.retiring)
105
117
  return Promise.reject(new Error('Chat is closing'));
118
+ if (this.monitorAdmissionOptions(options) !== options)
119
+ return this.submitPrompt(text, options);
106
120
  return this.session.submitPromptAfterTool?.(text, options)
107
121
  ?? this.session.submitPrompt(text, { ...options, interrupt: false, steer: false });
108
122
  }
@@ -11,6 +11,7 @@ export interface CodexAppServerSessionOptions {
11
11
  env: Record<string, string>;
12
12
  stateDir: string;
13
13
  mode: 'fresh' | 'resume';
14
+ requireResume?: boolean;
14
15
  permissions: CommonPermissions;
15
16
  permissionMode: NonNullable<SessionSnapshot['permissionMode']>;
16
17
  model?: string | null;
@@ -173,6 +173,7 @@ export class CodexAppServerSession {
173
173
  alive: this.isAlive(),
174
174
  readiness: this.readiness,
175
175
  sessionId: this.threadId,
176
+ ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.origin.kind } : {}),
176
177
  lastError: this.lastError,
177
178
  pendingPermissionId: this.pendingPermissions.keys().next().value,
178
179
  runtimeModel: this.runtimeModel,
@@ -406,6 +407,8 @@ export class CodexAppServerSession {
406
407
  this.transport.notify('initialized');
407
408
  const persisted = this.options.mode === 'resume' && existsSync(this.threadFile)
408
409
  ? readFileSync(this.threadFile, 'utf8').trim() : '';
410
+ if (this.options.requireResume && !persisted)
411
+ throw new Error('TASK_CONTEXT_RESUME_UNAVAILABLE');
409
412
  const common = {
410
413
  cwd: this.options.cwd,
411
414
  approvalPolicy: this.options.approvalPolicy,
@@ -425,6 +428,8 @@ export class CodexAppServerSession {
425
428
  this.threadId = string(thread?.id);
426
429
  if (!this.threadId)
427
430
  throw new Error('Codex app-server did not return a thread id');
431
+ if (this.options.requireResume && this.threadId !== persisted)
432
+ throw new Error('TASK_CONTEXT_RESUME_MISMATCH');
428
433
  writeFileSync(this.threadFile, this.threadId + '\n', { mode: 0o600 });
429
434
  const model = string(response.model) ?? this.options.model ?? undefined;
430
435
  if (model)
@@ -186,6 +186,8 @@ export interface SessionSnapshot {
186
186
  backend: SessionBackendId;
187
187
  alive: boolean;
188
188
  readiness: SessionReadiness;
189
+ /** Typed source of the active prompt; excludes prompt bodies and command IDs. */
190
+ activePromptSource?: PromptOrigin['kind'];
189
191
  sessionId?: string;
190
192
  lastError?: string;
191
193
  pendingPermissionId?: string;
package/dist/spawn.d.ts CHANGED
@@ -6,7 +6,7 @@ import { type CreationDeps, type CreationProvenance } from './creation.js';
6
6
  import './harness/claude-code.js';
7
7
  import './harness/codex.js';
8
8
  import './harness/hermes.js';
9
- import { type SupervisorLauncher } from './temp-lifecycle.js';
9
+ import { type SupervisorLauncher, type TaskSupervisorOwner } from './temp-lifecycle.js';
10
10
  /**
11
11
  * The provenance record written by the most recent spawn in this process, so
12
12
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -15,6 +15,8 @@ export declare let lastProvenance: CreationProvenance | undefined;
15
15
  export interface SpawnOpts {
16
16
  name: string;
17
17
  temp?: boolean;
18
+ /** Internal exact task-layout ownership, never a standalone manifest entry. */
19
+ taskOwner?: TaskSupervisorOwner;
18
20
  brain?: AgentSelection;
19
21
  role?: AgentSelection;
20
22
  /** Trusted canonical definition used by room provisioning; never a second schema. */
@@ -85,3 +87,5 @@ export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps, creation?: C
85
87
  export type { SupervisorLauncher } from './temp-lifecycle.js';
86
88
  /** Temp spawn: live state under ~/.ours-fleet/tmp, independent transient supervision. */
87
89
  export declare function spawnTemp(o: SpawnOpts, binPath: string, launch?: SupervisorLauncher, creation?: CreationDeps): Promise<string>;
90
+ /** Resume the exact durable task launch; no creation, invite issuance or state rotation. */
91
+ export declare function resumeTaskSupervisor(name: string, binPath: string): Promise<void>;
package/dist/spawn.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { taskSupervisorMayRun } from './task-supervision.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { storeRoomSecret, storeTemporaryLaunch, preparePermanentAssignment } from './agent-ours/service.js';
3
4
  import { ensureWorkspace } from './rooms-tasks/workspace.js';
@@ -16,7 +17,8 @@ import { recordGeneratedAgentSource } from './generated-agent-source.js';
16
17
  import './harness/claude-code.js';
17
18
  import './harness/codex.js';
18
19
  import './harness/hermes.js';
19
- import { archiveTempState, makeTempSupervisorLauncher, prepareTempSupervisor, reclaimStaleTempState, } from './temp-lifecycle.js';
20
+ import { getAdapter } from './harness/registry.js';
21
+ import { archiveTempState, makeTempSupervisorLauncher, prepareTempSupervisor, reclaimStaleTempState, stopTempSupervisor, secureStoppedTempArchive, readTempSupervisor, requestedTempStopReason, } from './temp-lifecycle.js';
20
22
  /**
21
23
  * The provenance record written by the most recent spawn in this process, so
22
24
  * the CLI can print the same summary it persisted rather than rebuilding it.
@@ -322,6 +324,8 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
322
324
  if (o.isolationFile)
323
325
  readIsolationFile(o.isolationFile); // fail before reserving
324
326
  const prepared = resolvedSpawn(o); // canonical validation before mutation
327
+ if ((o.taskOwner || o.roomMemberStartup?.task_id) && !getAdapter(prepared.role.harness).supportsResume)
328
+ throw Error('TASK_HARNESS_RESUME_UNSUPPORTED: task agents require a resumable harness');
325
329
  if (o.roomMemberStartup?.workspace) {
326
330
  const workspace = o.roomMemberStartup.workspace;
327
331
  if (prepared.role.cwd !== workspace.path)
@@ -368,7 +372,14 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
368
372
  lastProvenance = provenance;
369
373
  tx.record({
370
374
  stage: `temp state dir ${dir}`,
371
- undo: () => {
375
+ undo: async () => {
376
+ if (o.taskOwner || role.roomMemberStartup?.task_id) {
377
+ await stopTempSupervisor(o.name);
378
+ const supervisor = readTempSupervisor(dir);
379
+ if (supervisor)
380
+ await secureStoppedTempArchive(o.name, supervisor.launchId);
381
+ return;
382
+ }
372
383
  // A failed launch is still lifecycle evidence: briefing, provenance,
373
384
  // metadata and supervisor output explain what happened. Remove it from
374
385
  // the live roster by atomic archive, never recursive deletion.
@@ -381,7 +392,14 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
381
392
  // out; a lone temp spawn still waits zero (time-based gate).
382
393
  if (cfg.startStaggerMs > 0)
383
394
  writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
384
- prepareTempSupervisor(dir, o.name);
395
+ prepareTempSupervisor(dir, o.name, o.taskOwner ?? (o.roomMemberStartup?.task_id ? {
396
+ taskId: o.roomMemberStartup.task_id,
397
+ roomId: o.roomMemberStartup.room_id,
398
+ roomIdentityCid: o.roomMemberStartup.room_identity_cid,
399
+ creationActionId: o.creationActionId,
400
+ } : undefined));
401
+ if (o.taskOwner || o.roomMemberStartup?.task_id)
402
+ taskSupervisorMayRun(o.name);
385
403
  // Run the supervisor independently. On a service-managed host the temp
386
404
  // runner gets its own transient unit/job, so stopping the coordinator's unit
387
405
  // cannot kill a live worker in the coordinator's cgroup.
@@ -389,3 +407,14 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
389
407
  await launch(binPath, ['_run-temp', o.name], dir);
390
408
  return dir;
391
409
  }
410
+ /** Resume the exact durable task launch; no creation, invite issuance or state rotation. */
411
+ export async function resumeTaskSupervisor(name, binPath) {
412
+ if (!taskSupervisorMayRun(name))
413
+ throw Error('TASK_SUPERVISOR_RETIRED');
414
+ const dir = agentDir(name, true);
415
+ if (requestedTempStopReason(dir))
416
+ throw Error('TASK_SUPERVISOR_STOP_REQUESTED');
417
+ const launcher = readTempSupervisor(dir)?.kind === 'detached'
418
+ ? makeTempSupervisorLauncher({ spawnDetached, supervisor: 'none' }) : independentSupervisor;
419
+ await launcher(binPath, ['_run-temp', name], dir);
420
+ }
@@ -9,5 +9,5 @@ interface ReadinessDeps {
9
9
  env: NodeJS.ProcessEnv;
10
10
  }
11
11
  /** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
12
- export declare function waitForRoleDaemon(name: string, configPath?: string, partial?: Partial<ReadinessDeps>): Promise<void>;
12
+ export declare function waitForRoleDaemon(name: string, configPath?: string, partial?: Partial<ReadinessDeps>, temp?: boolean): Promise<void>;
13
13
  export {};
@@ -1,11 +1,11 @@
1
1
  import { agentDir } from './paths.js';
2
2
  import { findRole, loadConfig } from './config.js';
3
- import { resolveConfigPath } from './runner.js';
3
+ import { resolveConfigPath, loadTempRole } from './runner.js';
4
4
  import { probeDaemonGeneration } from './daemon-recovery.js';
5
5
  export const STARTUP_WAIT_MS = 240_000;
6
6
  export const STARTUP_RETRY_MS = 5_000;
7
7
  /** Read-only preflight: never binds identities, starts a harness or edits its ledger. */
8
- export async function waitForRoleDaemon(name, configPath, partial = {}) {
8
+ export async function waitForRoleDaemon(name, configPath, partial = {}, temp = false) {
9
9
  const deps = {
10
10
  probe: env => probeDaemonGeneration(((url, init) => globalThis.fetch(url, {
11
11
  ...init, signal: AbortSignal.timeout(10_000),
@@ -17,7 +17,7 @@ export async function waitForRoleDaemon(name, configPath, partial = {}) {
17
17
  ...partial,
18
18
  };
19
19
  const path = resolveConfigPath(agentDir(name), configPath);
20
- const role = findRole(loadConfig(path), name);
20
+ const role = temp ? loadTempRole(name) : findRole(loadConfig(path), name);
21
21
  const env = { ...deps.env, ...role.env };
22
22
  const deadline = deps.now() + STARTUP_WAIT_MS;
23
23
  let lastReason = '';
@@ -0,0 +1,8 @@
1
+ import { type Exec } from '../exec.js';
2
+ export declare function adoptConfiguredFleet(binPath: string, exec: Exec, platform: NodeJS.Platform, configPath?: string, selectedRole?: string): Promise<void>;
3
+ /** Boot resumes recorded transfers only. It does not rediscover removed agents
4
+ * or decide that every configured role should be started. */
5
+ export declare function resumeFleetTransfers(binPath: string, exec?: Exec, platform?: NodeJS.Platform): Promise<void>;
6
+ /** A remove racing the native-retired → registered seam must not be undone by
7
+ * boot receipt recovery. Serialize with that seam and close its cursor first. */
8
+ export declare function retirePermanentRegistration(name: string, exec: Exec): Promise<boolean>;