@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.
- package/README.md +20 -2
- package/dist/application/task-room-service.d.ts +30 -1
- package/dist/application/task-room-service.js +167 -104
- package/dist/build-info.json +4 -4
- package/dist/cli.js +9 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +20 -2
- package/dist/fleet-command-audit.d.ts +11 -0
- package/dist/fleet-command-audit.js +47 -3
- package/dist/owner-channel/channel.d.ts +4 -0
- package/dist/owner-channel/channel.js +90 -8
- package/dist/owner-channel/commands.d.ts +7 -2
- package/dist/owner-channel/commands.js +36 -2
- package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
- package/dist/owner-channel/lifecycle-outbox.js +87 -0
- package/dist/rooms-tasks/cli.js +139 -106
- package/dist/rooms-tasks/external-worker.d.ts +8 -0
- package/dist/rooms-tasks/external-worker.js +38 -0
- package/dist/rooms-tasks/provision.js +14 -25
- package/dist/runner.js +2 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/control.js +14 -1
- package/package.json +1 -1
|
@@ -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
|
|
423
|
-
await ctx.reply(
|
|
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
|
+
}
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -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,
|
|
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,
|
|
43
|
-
import {
|
|
44
|
-
import { createCoworkAdapter
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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] : [])];
|