@ours.network/fleet 1.1.0-nightly.25 → 1.1.0-nightly.26

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.
@@ -1,7 +1,7 @@
1
1
  import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
2
2
  import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent, TaskListRecord } from '../rooms-tasks/types.js';
3
3
  import { type OwnerCommentsState } from './notices.js';
4
- import type { CreateRoomRequest, CreateTaskRequest, TaskRoomApplicationService } from '../application/task-room-service.js';
4
+ import type { CreateRoomRequest, CreateTaskRequest, TaskProvisioningOutcome, TaskRoomApplicationService } from '../application/task-room-service.js';
5
5
  import type { TaskState } from '../rooms-tasks/types.js';
6
6
  /**
7
7
  * Fleet-level effects a deterministic owner command may trigger. Production
@@ -19,6 +19,9 @@ export interface OwnerFleetOps {
19
19
  settleTask(taskId: string): Promise<void>;
20
20
  /** Settle an accepted task deletion outside the caller role's supervisor lifecycle. */
21
21
  settleTaskDeletion(taskId: string): Promise<void>;
22
+ /** Continue an accepted provisioning saga until convergence. */
23
+ provisionTask(taskId: string): Promise<void>;
24
+ /** Explicit recovery may also route terminal and deletion intents. */
22
25
  recoverTask(taskId: string): Promise<void>;
23
26
  }
24
27
  /**
@@ -58,7 +61,9 @@ export interface OwnerCommandContext {
58
61
  terminalTask(taskId: string, kind: TaskTerminalIntent['kind'], outcome?: TaskOutcome): Promise<void>;
59
62
  recoverTask(taskId: string): Promise<void>;
60
63
  createTask(input: Omit<CreateTaskRequest, 'actor'>): Promise<TaskRecord>;
61
- startTask(taskId: string): Promise<TaskRecord>;
64
+ startTask(taskId: string): Promise<TaskProvisioningOutcome | TaskRecord>;
65
+ awaitTask?(taskId: string): Promise<TaskProvisioningOutcome>;
66
+ taskProvisioningOutcome?(taskId: string): TaskProvisioningOutcome;
62
67
  listTasks(filter?: {
63
68
  state?: TaskState | TaskState[];
64
69
  list?: string;
@@ -106,6 +106,24 @@ const taskAction = (title, task, fields = []) => renderMarkdownResult({
106
106
  ...fields,
107
107
  ],
108
108
  });
109
+ const taskProvisioningAction = (outcome) => {
110
+ if (outcome.kind === 'failed')
111
+ return renderMarkdownFailure({
112
+ kind: 'state', subject: `/task await ${outcome.task.task_id}`,
113
+ detail: outcome.blocker ?? 'Provisioning reached a terminal failure.',
114
+ action: outcome.next_action ?? `Run /task recover ${outcome.task.task_id}.`,
115
+ });
116
+ return taskAction(outcome.kind === 'ready' ? 'Room provisioning complete'
117
+ : outcome.next_action ? 'Room provisioning needs attention' : 'Room provisioning continues', outcome.task, [
118
+ ...(outcome.room ? [{ label: 'Room', value: `${outcome.room.room_id} — ${outcome.room.room_name}` }] : []),
119
+ ...(outcome.launch.template ? [{ label: 'Template', value: outcome.launch.template, kind: 'code' }] : []),
120
+ { label: 'Members', value: `${outcome.members.active}/${outcome.members.expected} active; ${outcome.members.launched}/${outcome.members.expected} launched` },
121
+ ...(outcome.blocker ? [{ label: 'Blocker', value: outcome.blocker }] : []),
122
+ ...(outcome.next_action ? [{ label: 'Next action', value: outcome.next_action }] : []),
123
+ ...(outcome.kind === 'in_progress'
124
+ ? [{ label: 'Await', value: `/task await ${outcome.task.task_id}`, kind: 'code' }] : []),
125
+ ]);
126
+ };
109
127
  const roomAction = (title, room, fields = []) => renderMarkdownResult({
110
128
  icon: '🏠', title,
111
129
  fields: [
@@ -319,6 +337,12 @@ export const ownerCommands = [
319
337
  title: titleParts.join(' '), origin: { type: 'owner_channel' },
320
338
  backlog, template, noRoom, brief: trailingLines, list,
321
339
  });
340
+ const provisioning = !backlog && !noRoom && t.room_id
341
+ ? ctx.taskProvisioningOutcome?.(t.task_id) : undefined;
342
+ if (provisioning) {
343
+ await ctx.reply(taskProvisioningAction(provisioning));
344
+ break;
345
+ }
322
346
  await ctx.reply(taskAction('Task created', t, [
323
347
  { label: 'Title', value: t.title },
324
348
  ...(template ? [{ label: 'Template', value: template, kind: 'code' }] : []),
@@ -419,8 +443,17 @@ export const ownerCommands = [
419
443
  case 'start': {
420
444
  if (!rest[0])
421
445
  throw new OwnerCommandUsageError('usage: /task start <id>');
422
- const t = await ctx.startTask(rest[0]);
423
- await ctx.reply(taskAction('Task started', t));
446
+ const started = await ctx.startTask(rest[0]);
447
+ await ctx.reply('kind' in started
448
+ ? taskProvisioningAction(started) : taskAction('Task started', started));
449
+ break;
450
+ }
451
+ case 'await': {
452
+ if (!rest[0])
453
+ throw new OwnerCommandUsageError('usage: /task await <id>');
454
+ if (!ctx.awaitTask)
455
+ throw new Error('task await is unavailable');
456
+ await ctx.reply(taskProvisioningAction(await ctx.awaitTask(rest[0])));
424
457
  break;
425
458
  }
426
459
  case 'block': {
@@ -731,6 +764,7 @@ export function fleetCliOps(role, configPath) {
731
764
  closeRoom: roomId => launchFleetWorker(['room', '_delete', roomId], `room-delete-${roomId}`, configPath),
732
765
  settleTask: taskId => launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath),
733
766
  settleTaskDeletion: taskId => launchFleetWorker(['task', '_settle_delete', taskId], `task-delete-${taskId}`, configPath),
767
+ provisionTask: taskId => launchFleetWorker(['task', '_provision', taskId], `task-provision-${taskId}`, configPath),
734
768
  recoverTask: taskId => launchFleetWorker(['task', '_recover', taskId], `task-recover-${taskId}`, configPath),
735
769
  };
736
770
  }
@@ -0,0 +1,24 @@
1
+ import { type FleetAuditPresentation } from '../fleet-command-audit.js';
2
+ type LifecycleDelivery = 'pending' | 'delivered' | 'uncertain';
3
+ interface LifecycleOutboxEntry {
4
+ digest: string;
5
+ presentation: FleetAuditPresentation;
6
+ delivery: LifecycleDelivery;
7
+ }
8
+ /** Durable lifecycle acceptance queue; plaintext is limited to validated concise presentations. */
9
+ export declare class FleetLifecycleOutbox {
10
+ private readonly path;
11
+ private entries;
12
+ private corruptReason?;
13
+ constructor(path: string);
14
+ integrity(): {
15
+ ok: boolean;
16
+ error?: string;
17
+ };
18
+ enqueue(presentations: FleetAuditPresentation[]): void;
19
+ pending(): readonly LifecycleOutboxEntry[];
20
+ finish(digest: string, delivery: Exclude<LifecycleDelivery, 'pending'>): void;
21
+ private assertHealthy;
22
+ private persist;
23
+ }
24
+ export {};
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, readFileSync } from 'node:fs';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { lifecycleEventDigestBasis, validateFleetAuditPresentations, } from '../fleet-command-audit.js';
5
+ const DIGEST = /^[a-f0-9]{64}$/u;
6
+ const LIMIT = 5_000;
7
+ /** Durable lifecycle acceptance queue; plaintext is limited to validated concise presentations. */
8
+ export class FleetLifecycleOutbox {
9
+ path;
10
+ entries = [];
11
+ corruptReason;
12
+ constructor(path) {
13
+ this.path = path;
14
+ if (!existsSync(path))
15
+ return;
16
+ try {
17
+ const value = JSON.parse(readFileSync(path, 'utf8'));
18
+ if (value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > LIMIT)
19
+ throw new Error('invalid lifecycle outbox');
20
+ const digests = new Set();
21
+ for (const entry of value.entries) {
22
+ validateFleetAuditPresentations([entry?.presentation]);
23
+ if (!DIGEST.test(entry.digest)
24
+ || !['pending', 'delivered', 'uncertain'].includes(entry.delivery)
25
+ || digests.has(entry.digest))
26
+ throw new Error('invalid lifecycle outbox entry');
27
+ digests.add(entry.digest);
28
+ }
29
+ this.entries = structuredClone(value.entries);
30
+ chmodSync(path, 0o600);
31
+ }
32
+ catch {
33
+ this.corruptReason = 'invalid persisted Fleet lifecycle outbox';
34
+ this.entries = [];
35
+ try {
36
+ chmodSync(path, 0o600);
37
+ }
38
+ catch { /* remain fail-closed */ }
39
+ }
40
+ }
41
+ integrity() {
42
+ return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
43
+ }
44
+ enqueue(presentations) {
45
+ this.assertHealthy();
46
+ validateFleetAuditPresentations(presentations);
47
+ let changed = false;
48
+ for (const presentation of presentations) {
49
+ const digest = createHash('sha256').update(lifecycleEventDigestBasis(presentation)).digest('hex');
50
+ if (this.entries.some(entry => entry.digest === digest))
51
+ continue;
52
+ if (this.entries.length >= LIMIT) {
53
+ const settled = this.entries.findIndex(entry => entry.delivery !== 'pending');
54
+ if (settled < 0)
55
+ throw new Error(`Fleet lifecycle outbox is limited to ${LIMIT} pending events`);
56
+ this.entries.splice(settled, 1);
57
+ }
58
+ this.entries.push({ digest, presentation: structuredClone(presentation), delivery: 'pending' });
59
+ changed = true;
60
+ }
61
+ if (changed)
62
+ this.persist();
63
+ }
64
+ pending() {
65
+ this.assertHealthy();
66
+ return this.entries.filter(entry => entry.delivery === 'pending').map(entry => structuredClone(entry));
67
+ }
68
+ finish(digest, delivery) {
69
+ this.assertHealthy();
70
+ const entry = this.entries.find(candidate => candidate.digest === digest);
71
+ if (!entry)
72
+ throw new Error('unknown Fleet lifecycle outbox entry');
73
+ if (entry.delivery === delivery)
74
+ return;
75
+ if (entry.delivery !== 'pending')
76
+ throw new Error('conflicting Fleet lifecycle delivery state');
77
+ entry.delivery = delivery;
78
+ this.persist();
79
+ }
80
+ assertHealthy() {
81
+ if (this.corruptReason)
82
+ throw new Error(this.corruptReason);
83
+ }
84
+ persist() {
85
+ replaceFileAtomically(this.path, `${JSON.stringify({ version: 1, entries: this.entries })}\n`, 0o600);
86
+ }
87
+ }
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
3
4
  import { loadConfig, ConfigError } from '../config.js';
4
5
  import { provisionMembers, getBinPath } from './provision.js';
5
- import { launchFleetWorker } from './external-worker.js';
6
- import { resolveTemplate, sealTemplateSnapshot, snapshotTemplate, } from './templates.js';
7
- import { acquireLaunchSnapshotLock, releaseLaunchSnapshot } from './launch-snapshot.js';
6
+ import { launchFleetWorker, presentFleetWorkerLifecycle } from './external-worker.js';
7
+ import { resolveTemplate, snapshotTemplate, } from './templates.js';
8
8
  import { parseGroupedMemberArgs, readMembersFile } from './member-overrides.js';
9
9
  const MEMBER_ARG_FLAGS = new Set([
10
10
  '--member', '--agent-template', '--brain', '--role', '--approval', '--filesystem', '--unattended',
@@ -39,14 +39,16 @@ function cliAnonymousOverride(argv) {
39
39
  throw new Error('--anonymous and --no-anonymous are mutually exclusive');
40
40
  return enabled ? true : disabled ? false : undefined;
41
41
  }
42
- import { getTask, getDeletingTask, activateTask, TaskStateError, } from './task-state.js';
43
- import { createRoomRecord, getRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
44
- import { createCoworkAdapter, CoworkProtocolError } from './cowork-adapter.js';
42
+ import { getTask, getDeletingTask, TaskStateError, } from './task-state.js';
43
+ import { getRoomRecord, RoomStateError, } from './room-state.js';
44
+ import { createCoworkAdapter } from './cowork-adapter.js';
45
45
  import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from './markdown.js';
46
- import { TaskRoomApplicationError, TaskRoomApplicationService } from '../application/task-room-service.js';
46
+ import { recordTaskProvisioningOutcome, TaskRoomApplicationError, TaskRoomApplicationService, } from '../application/task-room-service.js';
47
47
  import { TaskListError } from './task-lists.js';
48
- import { recordFleetAuditFailure, recordFleetAuditPresentation, recordFleetAuditResource, } from '../fleet-command-audit.js';
48
+ import { beginFleetAuditCollection, consumeFleetAuditCollection, recordFleetAuditFailure, recordFleetAuditPresentation, recordFleetAuditResource, } from '../fleet-command-audit.js';
49
49
  import { renderAgentConfiguration } from '../lifecycle-summary.js';
50
+ import { withFileLock } from '../atomic-file.js';
51
+ import { stateRoot } from '../paths.js';
50
52
  class TaskRoomPublicError extends Error {
51
53
  code;
52
54
  fields;
@@ -65,6 +67,11 @@ function taskRoomPublicError(code, fields = {}) {
65
67
  }));
66
68
  return new TaskRoomPublicError(code, validated);
67
69
  }
70
+ async function presentDetachedProvisioningOutcome(outcome) {
71
+ beginFleetAuditCollection();
72
+ recordTaskProvisioningOutcome(outcome);
73
+ await presentFleetWorkerLifecycle(consumeFleetAuditCollection().presentations ?? []);
74
+ }
68
75
  function taskRoomPublicFailure(error) {
69
76
  const f = error.fields;
70
77
  switch (error.code) {
@@ -251,6 +258,36 @@ const taskActionMarkdown = (title, task, fields = []) => {
251
258
  ],
252
259
  });
253
260
  };
261
+ function provisioningMarkdown(outcome) {
262
+ const room = outcome.room;
263
+ if (outcome.kind === 'failed')
264
+ return renderMarkdownFailure({
265
+ kind: 'state', subject: `task start ${outcome.task.task_id}`,
266
+ detail: outcome.blocker ?? 'Provisioning reached a terminal failure.',
267
+ action: outcome.next_action ?? `Run ours-fleet task recover ${outcome.task.task_id}.`,
268
+ });
269
+ return renderMarkdownResult({
270
+ icon: outcome.kind === 'ready' ? '✅' : outcome.next_action ? '⚠️' : '⏳',
271
+ title: outcome.kind === 'ready' ? 'Room provisioning complete'
272
+ : outcome.next_action ? 'Room provisioning needs attention' : 'Room provisioning continues',
273
+ fields: [
274
+ { label: 'Task', value: `${outcome.task.task_id} — ${outcome.task.title}` },
275
+ ...(room ? [{ label: 'Room', value: `${room.room_id} — ${room.room_name}` }] : []),
276
+ ...(outcome.launch.template ? [{ label: 'Template', value: outcome.launch.template, kind: 'code' }] : []),
277
+ { label: 'Launch', value: `anonymous=${outcome.launch.anonymous ? 'yes' : 'no'}, owner=${outcome.launch.owner_attached ? 'attached' : 'not-attached'}` },
278
+ { label: 'Lifecycle', value: outcome.kind },
279
+ { label: 'Members', value: `${outcome.members.active}/${outcome.members.expected} active; ${outcome.members.launched}/${outcome.members.expected} launched` },
280
+ ...(outcome.blocker ? [{ label: 'Blocker', value: outcome.blocker }] : []),
281
+ ...(outcome.next_action ? [{ label: 'Next action', value: outcome.next_action }] : []),
282
+ ...(outcome.kind === 'in_progress'
283
+ ? [{ label: 'Await', value: outcome.handle.command, kind: 'code' }] : []),
284
+ ],
285
+ });
286
+ }
287
+ async function continueProvisioningInBackground(taskId, configPath) {
288
+ await launchFleetWorker(['task', '_provision', taskId], `task-provision-${taskId}`, configPath);
289
+ }
290
+ const taskProvisioningWorkerLockPath = (taskId) => join(stateRoot(), 'locks', 'task-provisioning-worker', encodeURIComponent(taskId));
254
291
  /**
255
292
  * Shared launch-configuration line for CLI member listings. Pre-upgrade seats
256
293
  * without a captured presentation keep the minimal listing rather than
@@ -524,98 +561,6 @@ function roomStartupSections(room) {
524
561
  }
525
562
  return sections;
526
563
  }
527
- async function provisionRoom(cfg, input) {
528
- const rooms = cfg.rooms;
529
- if (!rooms)
530
- throw new ConfigError('rooms: configuration is required');
531
- const attachOwner = rooms.defaults?.attach_owner !== false;
532
- if (attachOwner && !cfg.ownerInvite)
533
- throw new ConfigError('rooms.owner: public_invite or public_invite_file is required when attach_owner is enabled');
534
- const cowork = coworkFor(cfg);
535
- const goal = input.goal?.trim() || input.name;
536
- const unlockSnapshot = input.template ? acquireLaunchSnapshotLock() : undefined;
537
- let launchTemplate;
538
- let record;
539
- try {
540
- launchTemplate = input.template
541
- ? sealTemplateSnapshot(input.template, cfg.agentTemplates ?? {}) : undefined;
542
- const briefing = input.brief?.trim() || launchTemplate?.contract?.trim() || goal;
543
- const created = await cowork.createRoom({
544
- room_name: input.name,
545
- goal,
546
- briefing,
547
- quiet_membership: launchTemplate?.room?.quiet_membership,
548
- anonymous: launchTemplate?.room?.anonymous,
549
- });
550
- record = createRoomRecord({
551
- room_id: created.room_id,
552
- room_name: input.name,
553
- room_identity_cid: created.identity_cid,
554
- task_id: input.taskId,
555
- template_snapshot: launchTemplate,
556
- });
557
- input.onCreated?.(record);
558
- }
559
- catch (error) {
560
- unlockSnapshot?.();
561
- if (launchTemplate?.launch_snapshot_hash)
562
- releaseLaunchSnapshot(launchTemplate.launch_snapshot_hash);
563
- throw error;
564
- }
565
- unlockSnapshot?.();
566
- record = advanceSaga(record.room_id, 'create_room', 1);
567
- if (attachOwner) {
568
- try {
569
- record = advanceSaga(record.room_id, 'attach_owner', 2);
570
- const accepted = await cowork.acceptInvite(record.room_id, cfg.ownerInvite, {
571
- role: rooms.owner.role,
572
- expected_cid: rooms.owner.expected_cid,
573
- });
574
- record = setOwnerSeat(record.room_id, accepted.seat_cid, cfg.ownerInviteFingerprint ?? '');
575
- }
576
- catch (error) {
577
- const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
578
- setSagaError(record.room_id, error instanceof Error ? error.message : String(error), mismatch
579
- ? 'Verify rooms.owner.expected_cid and rotate the configured invite if necessary.'
580
- : 'Rotate rooms.owner.public_invite or public_invite_file, then run room recover.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
581
- const failed = getRoomRecord(record.room_id);
582
- if (failed)
583
- recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Room',
584
- id: failed.room_id, state: failed.state, category: 'provision_failed',
585
- eventId: `${failed.created_at}:${failed.saga.phase}:${failed.saga.step_index}` });
586
- throw error;
587
- }
588
- }
589
- record = advanceSaga(record.room_id, 'create_members', 3);
590
- if (launchTemplate && launchTemplate.members.length > 0) {
591
- try {
592
- record = await provisionMembers({
593
- cfg,
594
- cowork,
595
- roomId: record.room_id,
596
- taskId: input.taskId,
597
- template: launchTemplate,
598
- binPath: getBinPath(),
599
- brief: input.brief,
600
- goal: input.goal,
601
- });
602
- }
603
- catch (error) {
604
- const failed = getRoomRecord(record.room_id);
605
- if (failed)
606
- recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Room',
607
- id: failed.room_id, state: failed.state, category: 'provision_failed',
608
- eventId: `${failed.created_at}:${failed.saga.phase}:${failed.saga.step_index}` });
609
- throw error;
610
- }
611
- }
612
- else {
613
- record = activateRoom(record.room_id);
614
- if (input.taskId)
615
- activateTask(input.taskId);
616
- }
617
- return record;
618
- }
619
564
  export function registerTemplateCommands(parent, cOpt) {
620
565
  const templateCmd = parent.command('template').description('room template operations');
621
566
  cOpt(templateCmd.command('list'))
@@ -721,7 +666,8 @@ export function registerTaskCommands(parent, cOpt) {
721
666
  .action(async (opts, command) => {
722
667
  try {
723
668
  const members = cliMemberOverrides(opts.membersFile, commandArgv(command));
724
- const record = await taskRoomService(opts.configuration).createTask({
669
+ const service = taskRoomService(opts.configuration);
670
+ const record = await service.createTask({
725
671
  actor: { kind: 'local_control', surface: 'cli' }, title: opts.title,
726
672
  brief: opts.brief, briefFile: opts.briefFile, template: opts.template,
727
673
  backlog: opts.backlog, noRoom: opts.room === false,
@@ -730,8 +676,19 @@ export function registerTaskCommands(parent, cOpt) {
730
676
  list: opts.list,
731
677
  members,
732
678
  });
679
+ const provisioning = !opts.backlog && opts.room !== false && record.room_id
680
+ ? service.taskProvisioningOutcome(record.task_id) : undefined;
681
+ if (provisioning?.kind === 'in_progress' && !provisioning.next_action)
682
+ await continueProvisioningInBackground(record.task_id, opts.configuration);
683
+ if (provisioning)
684
+ recordTaskProvisioningOutcome(provisioning);
733
685
  if (opts.json) {
734
- console.log(JSON.stringify({ schema_version: 1, task: record }, null, 2));
686
+ console.log(JSON.stringify({ schema_version: 1, task: record,
687
+ ...(provisioning ? { provisioning } : {}) }, null, 2));
688
+ return;
689
+ }
690
+ if (provisioning) {
691
+ console.log(provisioningMarkdown(provisioning));
735
692
  return;
736
693
  }
737
694
  console.log(taskActionMarkdown('Task created', record, [
@@ -936,16 +893,21 @@ export function registerTaskCommands(parent, cOpt) {
936
893
  .option('--json', 'JSON output')
937
894
  .action(async (id, opts, command) => {
938
895
  try {
939
- const t = await taskRoomService(opts.configuration).startTask({
896
+ const service = taskRoomService(opts.configuration);
897
+ const t = await service.startTask({
940
898
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
941
899
  template: opts.template, members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
942
900
  anonymous: cliAnonymousOverride(commandArgv(command)),
943
901
  });
902
+ const provisioning = service.taskProvisioningOutcome(t.task_id);
903
+ if (provisioning.kind === 'in_progress' && !provisioning.next_action)
904
+ await continueProvisioningInBackground(t.task_id, opts.configuration);
905
+ recordTaskProvisioningOutcome(provisioning);
944
906
  if (opts.json) {
945
- console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
907
+ console.log(JSON.stringify({ schema_version: 1, task: t, provisioning }, null, 2));
946
908
  return;
947
909
  }
948
- console.log(taskActionMarkdown('Task started', t));
910
+ console.log(provisioningMarkdown(provisioning));
949
911
  }
950
912
  catch (e) {
951
913
  if (e instanceof TaskRoomApplicationError && e.code === 'task_template_drift')
@@ -958,6 +920,37 @@ export function registerTaskCommands(parent, cOpt) {
958
920
  dieTaskRoom(e);
959
921
  }
960
922
  });
923
+ cOpt(taskCmd.command('await <id>'))
924
+ .description('await the same bounded room-provisioning operation')
925
+ .option('--wait-ms <ms>', 'bounded wait in milliseconds', '60000')
926
+ .option('--json', 'JSON output')
927
+ .action(async (id, opts) => {
928
+ try {
929
+ const waitMs = Number(opts.waitMs);
930
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 600_000)
931
+ throw new Error('--wait-ms must be an integer from 0 to 600000');
932
+ const service = taskRoomService(opts.configuration);
933
+ const initial = service.taskProvisioningOutcome(id);
934
+ if (initial.kind === 'in_progress')
935
+ await continueProvisioningInBackground(id, opts.configuration);
936
+ const outcome = await service.awaitTaskProvisioning({
937
+ actor: { kind: 'local_control', surface: 'cli' }, taskId: id, waitMs,
938
+ });
939
+ recordTaskProvisioningOutcome(outcome);
940
+ if (opts.json) {
941
+ console.log(JSON.stringify({ schema_version: 1, provisioning: outcome }, null, 2));
942
+ return;
943
+ }
944
+ console.log(provisioningMarkdown(outcome));
945
+ if (outcome.kind === 'failed')
946
+ process.exitCode = 1;
947
+ }
948
+ catch (e) {
949
+ if (opts.json)
950
+ die(e);
951
+ dieTaskRoom(e);
952
+ }
953
+ });
961
954
  taskCmd.command('block <id>')
962
955
  .description('mark a task as blocked')
963
956
  .requiredOption('--reason <reason>', 'blocking reason')
@@ -1191,8 +1184,11 @@ export function registerTaskCommands(parent, cOpt) {
1191
1184
  })()
1192
1185
  : begin.result;
1193
1186
  const t = recovered.task;
1194
- auditTask('recover', t, previous);
1195
1187
  const room = recovered.room;
1188
+ if (previous === 'provisioning' && t.state === 'active' && room?.state === 'active')
1189
+ recordTaskProvisioningOutcome(app.taskProvisioningOutcome(t.task_id));
1190
+ else
1191
+ auditTask('recover', t, previous);
1196
1192
  const recoveryActions = recovered.issues.map(issue => {
1197
1193
  if (issue.code === 'terminal_pending')
1198
1194
  return `Terminal intent remains pending — retry task recover ${id}`;
@@ -1262,6 +1258,43 @@ export function registerTaskCommands(parent, cOpt) {
1262
1258
  dieTaskRoom(e);
1263
1259
  }
1264
1260
  });
1261
+ cOpt(taskCmd.command('_provision <id>', { hidden: true }))
1262
+ .description('internal: continue an accepted room-provisioning operation')
1263
+ .option('--json', 'JSON output')
1264
+ .action(async (id, opts) => {
1265
+ try {
1266
+ await withFileLock(taskProvisioningWorkerLockPath(id), async () => {
1267
+ const app = taskRoomService(opts.configuration);
1268
+ for (;;) {
1269
+ const before = app.taskProvisioningOutcome(id);
1270
+ if (before.kind !== 'in_progress') {
1271
+ await presentDetachedProvisioningOutcome(before);
1272
+ return;
1273
+ }
1274
+ const recovery = await app.beginTaskRecovery({
1275
+ actor: { kind: 'internal_worker', surface: 'cli' }, taskId: id,
1276
+ });
1277
+ // Provisioning continuation never settles deletion or terminal
1278
+ // intents; explicit task recover retains that broader authority.
1279
+ if (recovery.kind !== 'final')
1280
+ return;
1281
+ const outcome = app.taskProvisioningOutcome(id);
1282
+ if (outcome.kind !== 'in_progress') {
1283
+ await presentDetachedProvisioningOutcome(outcome);
1284
+ return;
1285
+ }
1286
+ if (outcome.next_action)
1287
+ return;
1288
+ await sleep(1_000);
1289
+ }
1290
+ }, {}, 10 * 60_000);
1291
+ }
1292
+ catch (e) {
1293
+ if (opts.json)
1294
+ die(e);
1295
+ dieTaskRoom(e);
1296
+ }
1297
+ });
1265
1298
  cOpt(taskCmd.command('_settle_delete <id>', { hidden: true }))
1266
1299
  .description('internal: settle a previously accepted task deletion')
1267
1300
  .option('--json', 'JSON output')
@@ -1,3 +1,11 @@
1
+ import type { FleetAuditPresentation } from '../fleet-command-audit.js';
2
+ /** Lifecycle-only return path for a trusted worker; never authorizes a fresh Fleet attempt. */
3
+ export declare const FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV = "OURS_FLEET_WORKER_LIFECYCLE_STATE_DIR";
1
4
  export declare function fleetWorkerEnv(source?: NodeJS.ProcessEnv): Record<string, string>;
5
+ /** Present a detached worker's durable outcome through the authenticated Owner sink. */
6
+ export declare function presentFleetWorkerLifecycle(presentations: FleetAuditPresentation[], deps?: {
7
+ now(): number;
8
+ sleep(ms: number): Promise<void>;
9
+ }): Promise<void>;
2
10
  /** Launch a Fleet CLI operation outside the caller's supervisor/session lifecycle. */
3
11
  export declare function launchFleetWorker(args: string[], operation: string, configPath?: string): Promise<void>;
@@ -1,18 +1,56 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV } from '../fleet-proxy.js';
4
+ import { controlRequest } from '../session/control.js';
5
+ import { SessionControlError } from '../session/types.js';
6
+ /** Lifecycle-only return path for a trusted worker; never authorizes a fresh Fleet attempt. */
7
+ export const FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV = 'OURS_FLEET_WORKER_LIFECYCLE_STATE_DIR';
8
+ const LIFECYCLE_ACCEPT_TIMEOUT_MS = 10 * 60_000;
9
+ const LIFECYCLE_RETRY_MS = 250;
4
10
  export function fleetWorkerEnv(source = process.env) {
5
11
  const inheritedKeys = [
6
12
  'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_CONFIG',
7
13
  'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_COWORK_CONFIG',
8
14
  ];
9
15
  const env = Object.fromEntries(inheritedKeys.flatMap(key => source[key] === undefined ? [] : [[key, source[key]]]));
16
+ const lifecycleStateDir = source[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV]
17
+ ?? source[FLEET_PROXY_STATE_DIR_ENV];
18
+ if (lifecycleStateDir)
19
+ env[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV] = lifecycleStateDir;
10
20
  // Explicit defense: trusted settle/recover/delete/close workers are effects
11
21
  // nested inside the outer audited command, never fresh agent attempts.
12
22
  delete env[FLEET_PROXY_STATE_DIR_ENV];
13
23
  delete env[FLEET_PROXY_CALLER_ENV];
14
24
  return env;
15
25
  }
26
+ /** Present a detached worker's durable outcome through the authenticated Owner sink. */
27
+ export async function presentFleetWorkerLifecycle(presentations, deps = {
28
+ now: Date.now, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
29
+ }) {
30
+ const stateDir = process.env[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV];
31
+ if (!stateDir || !presentations.length)
32
+ return;
33
+ const deadline = deps.now() + LIFECYCLE_ACCEPT_TIMEOUT_MS;
34
+ for (;;) {
35
+ try {
36
+ const response = await controlRequest(stateDir, {
37
+ command: 'fleet_audit_present', audit: { presentations },
38
+ }, 15_000);
39
+ if (!response.ok)
40
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'detached Fleet lifecycle delivery failed');
41
+ return;
42
+ }
43
+ catch (error) {
44
+ // Missing token/socket and refused connect are classified before any
45
+ // request can cross the server boundary, so only this class is safe to
46
+ // retry. Timeout/backend/response failures may be uncertain and stop.
47
+ if (!(error instanceof SessionControlError)
48
+ || error.kind !== 'control-unavailable' || deps.now() >= deadline)
49
+ throw error;
50
+ await deps.sleep(LIFECYCLE_RETRY_MS);
51
+ }
52
+ }
53
+ }
16
54
  /** Launch a Fleet CLI operation outside the caller's supervisor/session lifecycle. */
17
55
  export function launchFleetWorker(args, operation, configPath) {
18
56
  const cli = [process.argv[1], ...args, ...(configPath ? ['-c', configPath] : [])];