@ours.network/fleet 0.19.0-nightly.6 → 0.19.0-nightly.8

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,9 +1,9 @@
1
1
  {
2
- "version": "0.19.0-nightly.6",
3
- "buildId": "1165e05339e3",
4
- "commit": "188e186e43324119f08d38106f9bcd7606199e5c",
2
+ "version": "0.19.0-nightly.8",
3
+ "buildId": "d22ed5b7bff3",
4
+ "commit": "39cad43ae0d8f08341775953baeda7b77eb3ade4",
5
5
  "dirty": true,
6
- "builtAt": "2026-08-23T18:53:56.442Z",
6
+ "builtAt": "2026-08-23T23:06:12.416Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
@@ -2,9 +2,10 @@ import { readFileSync } from 'node:fs';
2
2
  import { loadConfig, ConfigError } from '../config.js';
3
3
  import { provisionMembers, cleanupMembers, getBinPath } from './provision.js';
4
4
  import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.js';
5
- import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, updateTaskRoom, } from './task-state.js';
5
+ import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, updateTaskRoom, updateTaskTemplate, } from './task-state.js';
6
6
  import { createRoomRecord, getRoomRecord, listRoomRecords, closeRoom as closeRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, } from './room-state.js';
7
7
  import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
8
+ import { TASK_TERMINAL_STATES } from './types.js';
8
9
  function die(e) {
9
10
  const msg = e instanceof Error ? e.message : String(e);
10
11
  process.stderr.write(`error: ${msg}\n`);
@@ -204,7 +205,7 @@ export function registerTaskCommands(parent, cOpt) {
204
205
  templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
205
206
  }
206
207
  else if (opts.room !== false) {
207
- const defaultTpl = cfg.tasks?.default_room_template ?? cfg.rooms?.defaults?.template ?? 'briefing';
208
+ const defaultTpl = cfg.tasks?.default_room_template ?? cfg.rooms?.defaults?.template ?? 'team';
208
209
  const t = resolveTemplate(defaultTpl, allTemplates(cfg));
209
210
  if (t) {
210
211
  const snap = snapshotTemplate(t);
@@ -534,6 +535,174 @@ export function registerTaskCommands(parent, cOpt) {
534
535
  die(e);
535
536
  }
536
537
  });
538
+ cOpt(taskCmd.command('work <id>'))
539
+ .description('ensure a task has a room and agents running')
540
+ .option('--template <name>', 'room template')
541
+ .option('--json', 'JSON output')
542
+ .action(async (id, opts) => {
543
+ try {
544
+ const cfg = loadCfg(opts);
545
+ let t = getTask(id);
546
+ if (TASK_TERMINAL_STATES.includes(t.state))
547
+ die(new Error(`task ${id} is in terminal state '${t.state}'`));
548
+ if (t.state === 'active' && t.room_id) {
549
+ // An explicit --template must agree with the room the task already
550
+ // runs in; a conflicting override is an error, not a silent no-op.
551
+ if (opts.template) {
552
+ const override = resolveTemplate(opts.template, allTemplates(cfg));
553
+ if (!override)
554
+ die(new Error(`template not found: ${opts.template}`));
555
+ const overrideSnap = snapshotTemplate(override);
556
+ const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
557
+ if (roomSnap && (roomSnap.name !== overrideSnap.name || roomSnap.content_hash !== overrideSnap.content_hash))
558
+ die(new Error(`template ${overrideSnap.name}@${overrideSnap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
559
+ }
560
+ if (opts.json) {
561
+ console.log(JSON.stringify({ schema_version: 1, task: t, status: 'already_active' }, null, 2));
562
+ return;
563
+ }
564
+ console.log('Task already has an active room');
565
+ return;
566
+ }
567
+ // Select and validate the template snapshot before any state change or
568
+ // provisioning. An explicit --template re-pins the task; a stored ref
569
+ // must still match its snapshot (same contract as `task start`).
570
+ const templateName = opts.template
571
+ ?? t.template?.name
572
+ ?? cfg.tasks?.default_room_template
573
+ ?? cfg.rooms?.defaults?.template
574
+ ?? 'single';
575
+ const resolved = resolveTemplate(templateName, allTemplates(cfg));
576
+ if (!resolved)
577
+ die(new Error(`template not found: ${templateName}`));
578
+ const snap = snapshotTemplate(resolved);
579
+ if (!opts.template && t.template && snap.content_hash !== t.template.content_hash)
580
+ die(new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`));
581
+ // A provisioned room is pinned to its snapshot: never resume or re-pin
582
+ // an existing room under a different template.
583
+ if (t.room_id) {
584
+ const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
585
+ if (roomSnap && (roomSnap.name !== snap.name || roomSnap.content_hash !== snap.content_hash))
586
+ die(new Error(`template ${snap.name}@${snap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
587
+ }
588
+ let templateRef = t.template;
589
+ if (!templateRef || templateRef.name !== snap.name || templateRef.content_hash !== snap.content_hash) {
590
+ templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
591
+ t = updateTaskTemplate(t.task_id, templateRef);
592
+ }
593
+ if (t.state === 'backlog') {
594
+ t = startTask(id);
595
+ }
596
+ if (!t.room_id) {
597
+ try {
598
+ await provisionRoom(cfg, {
599
+ name: t.title,
600
+ goal: t.title,
601
+ brief: t.brief,
602
+ template: snap,
603
+ taskId: t.task_id,
604
+ onCreated: room => {
605
+ t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
606
+ },
607
+ });
608
+ t = getTask(t.task_id);
609
+ }
610
+ catch (error) {
611
+ if (error instanceof CoworkUnavailableError) {
612
+ blockTask(t.task_id, 'Cowork management socket is unavailable');
613
+ }
614
+ throw error;
615
+ }
616
+ }
617
+ else if (t.state === 'provisioning') {
618
+ // The task already has a room mid-provisioning (e.g. seats were still
619
+ // pending on the last run). Resume it exactly as `task recover` does.
620
+ const room = getRoomRecord(t.room_id);
621
+ const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
622
+ if (room && resumable.includes(room.saga.phase)) {
623
+ try {
624
+ await provisionMembers({
625
+ cfg,
626
+ cowork: coworkFor(cfg),
627
+ roomId: room.room_id,
628
+ taskId: t.task_id,
629
+ template: snap,
630
+ binPath: getBinPath(),
631
+ brief: t.brief,
632
+ goal: t.title,
633
+ });
634
+ }
635
+ catch (error) {
636
+ if (error instanceof CoworkUnavailableError) {
637
+ blockTask(t.task_id, 'Cowork management socket is unavailable');
638
+ }
639
+ throw error;
640
+ }
641
+ t = getTask(t.task_id);
642
+ }
643
+ else {
644
+ die(new Error(`task ${id} has room ${t.room_id} in a non-resumable state — run 'task recover ${id}'`));
645
+ }
646
+ }
647
+ if (opts.json) {
648
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
649
+ return;
650
+ }
651
+ console.log(`Task ${t.task_id} · ${t.state}`);
652
+ if (templateRef)
653
+ console.log(`Template: ${templateRef.name}@${templateRef.version}`);
654
+ if (t.room_id)
655
+ console.log(`Room: ${t.room_id}`);
656
+ if (t.member_roles.length) {
657
+ console.log('Agents:');
658
+ for (const m of t.member_roles)
659
+ console.log(` ${m.name} (${m.cowork_role})`);
660
+ }
661
+ }
662
+ catch (e) {
663
+ die(e);
664
+ }
665
+ });
666
+ cOpt(taskCmd.command('finish <id>'))
667
+ .description('finish a task: transition to done and close its room')
668
+ .option('--summary <text>', 'completion summary')
669
+ .option('--summary-file <path>', 'completion summary from file')
670
+ .option('--json', 'JSON output')
671
+ .action(async (id, opts) => {
672
+ try {
673
+ let summary = opts.summary;
674
+ if (opts.summaryFile)
675
+ summary = readFileSync(opts.summaryFile, 'utf8');
676
+ const outcome = summary ? { summary } : undefined;
677
+ let t = getTask(id);
678
+ if (TASK_TERMINAL_STATES.includes(t.state))
679
+ die(new Error(`task ${id} is already in terminal state '${t.state}'`));
680
+ if (t.state === 'active') {
681
+ reviewTask(id);
682
+ }
683
+ t = completeTask(id, outcome);
684
+ if (t.room_id) {
685
+ const cfg = loadCfg(opts);
686
+ try {
687
+ await cleanupMembers({
688
+ roomId: t.room_id,
689
+ taskId: id,
690
+ closeCoworkRoom: true,
691
+ cowork: coworkFor(cfg),
692
+ });
693
+ }
694
+ catch { /* room cleanup is best-effort */ }
695
+ }
696
+ if (opts.json) {
697
+ console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
698
+ return;
699
+ }
700
+ console.log(`Task ${t.task_id} · done`);
701
+ }
702
+ catch (e) {
703
+ die(e);
704
+ }
705
+ });
537
706
  }
538
707
  export function registerRoomCommands(parent, cOpt) {
539
708
  const roomCmd = parent.command('room').description('room orchestration operations');
@@ -96,71 +96,90 @@ export async function provisionMembers(input) {
96
96
  const plan = expandMembers(template, prefix);
97
97
  const createdIdentities = [];
98
98
  const members = [];
99
- // Phase 4: create_members
100
- advanceSaga(roomId, 'create_members', 3);
101
- try {
99
+ // A room that already reached wait_seats keeps its member identities, seats
100
+ // and accepted invitations; recreating them on a retry would collide. Resume
101
+ // from the persisted seats and re-check seat activity instead.
102
+ const existing = getRoomRecord(roomId);
103
+ const persistedSeats = existing?.member_seats ?? [];
104
+ const resuming = existing !== undefined
105
+ && ['wait_seats', 'launch_work', 'activate'].includes(existing.saga.phase)
106
+ && plan.length > 0
107
+ && plan.every(p => persistedSeats.some(s => s.role_name === p.name));
108
+ let seats;
109
+ if (resuming) {
102
110
  for (const planned of plan) {
103
- const bio = `Fleet task member: ${planned.coworkRole} for ${taskId ?? roomId}`;
104
- const { cid } = await createMemberIdentity(planned.name, bio);
105
- createdIdentities.push(planned.name);
106
- members.push({ ...planned, cid });
111
+ const seat = persistedSeats.find(s => s.role_name === planned.name);
112
+ members.push({ ...planned, cid: seat.identity_cid });
107
113
  }
114
+ seats = persistedSeats;
108
115
  }
109
- catch (error) {
110
- for (const name of createdIdentities)
111
- await removeMemberIdentity(name);
112
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member identity creation failed. Retry with `task recover`.', 'member_failed');
113
- if (taskId)
114
- failTask(taskId, error instanceof Error ? error.message : String(error));
115
- throw error;
116
- }
117
- const seats = members.map(m => ({
118
- role_name: m.name,
119
- identity_cid: m.cid,
120
- slot: m.slot,
121
- cowork_role: m.coworkRole,
122
- seat_state: 'pending',
123
- }));
124
- updateMemberSeats(roomId, seats);
125
- if (taskId) {
126
- const taskMembers = members.map(m => ({
127
- name: m.name,
116
+ else {
117
+ // Phase 4: create_members
118
+ advanceSaga(roomId, 'create_members', 3);
119
+ try {
120
+ for (const planned of plan) {
121
+ const bio = `Fleet task member: ${planned.coworkRole} for ${taskId ?? roomId}`;
122
+ const { cid } = await createMemberIdentity(planned.name, bio);
123
+ createdIdentities.push(planned.name);
124
+ members.push({ ...planned, cid });
125
+ }
126
+ }
127
+ catch (error) {
128
+ for (const name of createdIdentities)
129
+ await removeMemberIdentity(name);
130
+ setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member identity creation failed. Retry with `task recover`.', 'member_failed');
131
+ if (taskId)
132
+ failTask(taskId, error instanceof Error ? error.message : String(error));
133
+ throw error;
134
+ }
135
+ seats = members.map(m => ({
136
+ role_name: m.name,
128
137
  identity_cid: m.cid,
129
138
  slot: m.slot,
130
139
  cowork_role: m.coworkRole,
140
+ seat_state: 'pending',
131
141
  }));
132
- updateTaskMembers(taskId, taskMembers);
133
- }
134
- // Phase 5: join_role_groups serial by Cowork role
135
- advanceSaga(roomId, 'join_role_groups', 4);
136
- const roleGroups = new Map();
137
- for (const m of members) {
138
- const group = roleGroups.get(m.coworkRole) ?? [];
139
- group.push(m);
140
- roleGroups.set(m.coworkRole, group);
141
- }
142
- try {
143
- for (const [coworkRole, group] of roleGroups) {
144
- const { invite } = await cowork.issueInvite(roomId, {
145
- role: coworkRole,
146
- min_accepts: group.length,
147
- });
148
- // invite is used in-memory only — NEVER persisted
149
- for (const member of group) {
150
- await cowork.acceptInvite(roomId, invite, {
142
+ updateMemberSeats(roomId, seats);
143
+ if (taskId) {
144
+ const taskMembers = members.map(m => ({
145
+ name: m.name,
146
+ identity_cid: m.cid,
147
+ slot: m.slot,
148
+ cowork_role: m.coworkRole,
149
+ }));
150
+ updateTaskMembers(taskId, taskMembers);
151
+ }
152
+ // Phase 5: join_role_groups — serial by Cowork role
153
+ advanceSaga(roomId, 'join_role_groups', 4);
154
+ const roleGroups = new Map();
155
+ for (const m of members) {
156
+ const group = roleGroups.get(m.coworkRole) ?? [];
157
+ group.push(m);
158
+ roleGroups.set(m.coworkRole, group);
159
+ }
160
+ try {
161
+ for (const [coworkRole, group] of roleGroups) {
162
+ const { invite } = await cowork.issueInvite(roomId, {
151
163
  role: coworkRole,
152
- expected_cid: member.cid,
164
+ min_accepts: group.length,
153
165
  });
166
+ // invite is used in-memory only — NEVER persisted
167
+ for (const member of group) {
168
+ await cowork.acceptInvite(roomId, invite, {
169
+ role: coworkRole,
170
+ expected_cid: member.cid,
171
+ });
172
+ }
154
173
  }
155
174
  }
156
- }
157
- catch (error) {
158
- for (const name of createdIdentities)
159
- await removeMemberIdentity(name);
160
- setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role-group admission failed. Retry with `task recover`.', 'member_failed');
161
- if (taskId)
162
- failTask(taskId, error instanceof Error ? error.message : String(error));
163
- throw error;
175
+ catch (error) {
176
+ for (const name of createdIdentities)
177
+ await removeMemberIdentity(name);
178
+ setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role-group admission failed. Retry with `task recover`.', 'member_failed');
179
+ if (taskId)
180
+ failTask(taskId, error instanceof Error ? error.message : String(error));
181
+ throw error;
182
+ }
164
183
  }
165
184
  // Phase 6: wait_seats
166
185
  advanceSaga(roomId, 'wait_seats', 5);
@@ -28,5 +28,6 @@ export declare function completeTask(id: string, outcome?: TaskOutcome): TaskRec
28
28
  export declare function cancelTask(id: string): TaskRecord;
29
29
  export declare function failTask(id: string, error: string): TaskRecord;
30
30
  export declare function updateTaskRoom(id: string, roomId: string, roomIdentityCid: string): TaskRecord;
31
+ export declare function updateTaskTemplate(id: string, template: TaskTemplateRef): TaskRecord;
31
32
  export declare function updateTaskMembers(id: string, members: TaskMemberRole[]): TaskRecord;
32
33
  export declare function deleteTask(id: string): void;
@@ -161,6 +161,12 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
161
161
  writeTask(t);
162
162
  return t;
163
163
  }
164
+ export function updateTaskTemplate(id, template) {
165
+ const t = readTask(id);
166
+ t.template = template;
167
+ writeTask(t);
168
+ return t;
169
+ }
164
170
  export function updateTaskMembers(id, members) {
165
171
  const t = readTask(id);
166
172
  t.member_roles = members;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- const BRIEFING = {
3
- name: 'briefing',
2
+ const TEAM = {
3
+ name: 'team',
4
4
  version: 1,
5
5
  builtin: true,
6
6
  description: 'Phased task pipeline: Architect specifies, Developer implements, Tester verifies',
@@ -17,8 +17,8 @@ const BRIEFING = {
17
17
  { slot: 'tester', role: 'Tester', count: 1, role_ref: 'Tester' },
18
18
  ],
19
19
  };
20
- const CONSILIUM = {
21
- name: 'consilium',
20
+ const PAIR = {
21
+ name: 'pair',
22
22
  version: 1,
23
23
  builtin: true,
24
24
  description: 'Deliberation pair: Secretary writes code, Critic reviews — every decision deliberated together',
@@ -34,7 +34,21 @@ const CONSILIUM = {
34
34
  { slot: 'critic', role: 'Critic', count: 1, role_ref: 'Critic' },
35
35
  ],
36
36
  };
37
- export const BUILTIN_TEMPLATES = [BRIEFING, CONSILIUM];
37
+ const SINGLE = {
38
+ name: 'single',
39
+ version: 1,
40
+ builtin: true,
41
+ description: 'Solo agent: one general-purpose agent works the task with owner oversight',
42
+ room: { quiet_membership: false, anonymous: false },
43
+ contract: [
44
+ 'Agent works the task autonomously, consulting the owner when blocked.',
45
+ 'Owner reviews and approves completion.',
46
+ ].join('\n'),
47
+ members: [
48
+ { slot: 'agent', role: 'Agent', count: 1, role_ref: 'Agent' },
49
+ ],
50
+ };
51
+ export const BUILTIN_TEMPLATES = [TEAM, PAIR, SINGLE];
38
52
  export function hashTemplate(t) {
39
53
  const canonical = JSON.stringify({
40
54
  name: t.name, version: t.version, description: t.description,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.19.0-nightly.6",
3
+ "version": "0.19.0-nightly.8",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",