@ours.network/fleet 1.1.0-nightly.6 → 1.1.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.
Files changed (50) hide show
  1. package/README.md +23 -4
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/fleet-query-service.js +13 -1
  5. package/dist/application/task-room-service.d.ts +1 -1
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.js +6 -2
  8. package/dist/config-yaml.d.ts +1 -1
  9. package/dist/config.d.ts +1 -1
  10. package/dist/config.js +49 -2
  11. package/dist/daemon-recovery.d.ts +93 -0
  12. package/dist/daemon-recovery.js +328 -0
  13. package/dist/docs.d.ts +1 -1
  14. package/dist/docs.js +26 -3
  15. package/dist/doctor.js +14 -0
  16. package/dist/init-guidance.d.ts +1 -1
  17. package/dist/init-guidance.js +4 -3
  18. package/dist/owner-channel/channel.d.ts +40 -0
  19. package/dist/owner-channel/channel.js +210 -16
  20. package/dist/owner-channel/commands.js +3 -9
  21. package/dist/preset-bootstrap.d.ts +10 -0
  22. package/dist/preset-bootstrap.js +85 -0
  23. package/dist/rooms-tasks/cli.js +4 -4
  24. package/dist/rooms-tasks/config.js +0 -4
  25. package/dist/rooms-tasks/templates.d.ts +0 -1
  26. package/dist/rooms-tasks/templates.js +9 -76
  27. package/dist/rooms-tasks/types.d.ts +2 -1
  28. package/dist/runner.d.ts +8 -1
  29. package/dist/runner.js +74 -3
  30. package/dist/temp-lifecycle.d.ts +1 -1
  31. package/dist/web/fleet-config-service.js +4 -3
  32. package/package.json +2 -1
  33. package/presets/fleet/agents/Agent.yaml +3 -0
  34. package/presets/fleet/agents/Architect.yaml +3 -0
  35. package/presets/fleet/agents/Critic.yaml +3 -0
  36. package/presets/fleet/agents/Developer.yaml +3 -0
  37. package/presets/fleet/agents/Secretary.yaml +3 -0
  38. package/presets/fleet/agents/Tester.yaml +3 -0
  39. package/presets/fleet/brains/claude-default.yaml +5 -0
  40. package/presets/fleet/roles/Agent.yaml +6 -0
  41. package/presets/fleet/roles/Architect.yaml +6 -0
  42. package/presets/fleet/roles/Critic.yaml +6 -0
  43. package/presets/fleet/roles/Developer.yaml +6 -0
  44. package/presets/fleet/roles/Secretary.yaml +6 -0
  45. package/presets/fleet/roles/Tester.yaml +6 -0
  46. package/presets/fleet/room_templates/pair.yaml +11 -0
  47. package/presets/fleet/room_templates/single.yaml +8 -0
  48. package/presets/fleet/room_templates/team.yaml +12 -0
  49. package/presets/fleet.yaml +7 -0
  50. package/presets/manifest.json +5 -0
@@ -1,5 +1,6 @@
1
1
  export const INIT_COMPLETION_GUIDANCE = `
2
2
  Next:
3
- cp "$(npm root -g)/@ours.network/fleet/examples/fleet.yaml" ~/fleet.yaml
4
- cp -R "$(npm root -g)/@ours.network/fleet/examples/fleet" ~/fleet
5
- edit ~/fleet.yaml and ~/fleet/{agents,roles,brains}/*.yaml, then: ours-fleet up`;
3
+ ours-fleet config
4
+ ours-fleet template list
5
+ edit the seeded manifest and fleet/{agents,roles,brains,room_templates}/*.yaml
6
+ configure rooms.owner, then create a task with --template single, pair, or team`;
@@ -1,6 +1,7 @@
1
1
  import { type OwnerChannelConfig } from '../config.js';
2
2
  import { type AgentSession } from '../session/types.js';
3
3
  import { type OwnerFleetOps } from './commands.js';
4
+ import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
4
5
  import { type FleetAuditAttempt, type FleetAuditPresentation, type FleetCommandOutcomeClass } from '../fleet-command-audit.js';
5
6
  import { type OursOps } from './ours-client.js';
6
7
  import { type OwnerUpdatePhase } from './notices.js';
@@ -27,12 +28,24 @@ export interface OwnerChannelOptions {
27
28
  binderDeps?: OwnerBinderDeps;
28
29
  /** Pre-acquired by the runner so the predecessor control socket remains reachable while waiting. */
29
30
  binderLease?: OwnerBinderLease;
31
+ recoveryDeps?: {
32
+ now(): number;
33
+ setTimer(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
34
+ clearTimer(timer: ReturnType<typeof setTimeout>): void;
35
+ deadlineMs?: number;
36
+ };
30
37
  }
31
38
  export interface OwnerChannelHandle {
32
39
  start(): Promise<void>;
33
40
  drain(): Promise<void>;
34
41
  close(): Promise<void>;
42
+ /** Reattach this same supervisor-owned channel after a daemon generation change. */
43
+ recover?(epoch: string): Promise<void>;
44
+ /** False when shutdown skipped client disposal because quiescence was unproven. */
45
+ binderReleaseSafe?(): boolean;
35
46
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
47
+ /** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
48
+ notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
36
49
  beginFleetCommandAudit?(requestId: string, argv: string[]): Promise<FleetAuditAttempt>;
37
50
  finishFleetCommandAudit?(input: {
38
51
  correlationId: string;
@@ -43,6 +56,17 @@ export interface OwnerChannelHandle {
43
56
  presentation?: FleetAuditPresentation;
44
57
  }): Promise<FleetAuditAttempt>;
45
58
  }
59
+ export declare const OWNER_RECOVERY_QUEUE_CAPACITY = 32;
60
+ export declare const OWNER_RECOVERY_DEGRADED = "OWNER_RECOVERY_DEGRADED";
61
+ export declare class OwnerRecoveryDegradedError extends Error {
62
+ readonly code = "OWNER_RECOVERY_DEGRADED";
63
+ constructor();
64
+ }
65
+ export declare class OwnerRecoveryTimeoutError extends Error {
66
+ readonly stage: string;
67
+ readonly code = "OWNER_RECOVERY_DEGRADED";
68
+ constructor(stage: string);
69
+ }
46
70
  export type OwnerChannelManagementRequest = {
47
71
  action: 'contact_list';
48
72
  } | {
@@ -169,7 +193,17 @@ export declare class OwnerChannel implements OwnerChannelHandle {
169
193
  private readonly completionTasks;
170
194
  private readonly activeRequests;
171
195
  private managementTail;
196
+ private recoveryEpoch?;
197
+ private recoveryTask?;
198
+ private recoveryToken;
199
+ private recoveryQueued;
200
+ private watchGeneration;
201
+ /** A timed-out client operation that must settle before replacement is safe. */
202
+ private recoveryQuiescence?;
203
+ private releaseSafe;
172
204
  private ready;
205
+ private startedOnce;
206
+ private shutdownDegraded;
173
207
  private binder?;
174
208
  private binderOwnedInternally;
175
209
  private readonly fleetOps;
@@ -179,7 +213,9 @@ export declare class OwnerChannel implements OwnerChannelHandle {
179
213
  start(): Promise<void>;
180
214
  drain(): Promise<void>;
181
215
  close(): Promise<void>;
216
+ binderReleaseSafe(): boolean;
182
217
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
218
+ notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
183
219
  beginFleetCommandAudit(requestId: string, argv: string[]): Promise<FleetAuditAttempt>;
184
220
  finishFleetCommandAudit(input: {
185
221
  correlationId: string;
@@ -189,6 +225,10 @@ export declare class OwnerChannel implements OwnerChannelHandle {
189
225
  resourceIds?: Record<string, string>;
190
226
  presentation?: FleetAuditPresentation;
191
227
  }): Promise<FleetAuditAttempt>;
228
+ recover(epoch: string): Promise<void>;
229
+ private recoveryStage;
230
+ private writeShutdownState;
231
+ private queueManagement;
192
232
  private manageNow;
193
233
  /**
194
234
  * The daemon reports established contacts and pending introductions as two
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { canonicalCid } from '../config.js';
6
+ import { DAEMON_RECOVERY_DEADLINE_MS } from '../daemon-recovery.js';
6
7
  import { replaceFileAtomically } from '../atomic-file.js';
7
8
  import { TaskRoomApplicationService } from '../application/task-room-service.js';
8
9
  import { RoleLifecycleService } from '../application/role-command-service.js';
@@ -22,6 +23,24 @@ import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
22
23
  import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
23
24
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
24
25
  import { MessageRecoveryState } from './message-recovery.js';
26
+ export const OWNER_RECOVERY_QUEUE_CAPACITY = 32;
27
+ export const OWNER_RECOVERY_DEGRADED = 'OWNER_RECOVERY_DEGRADED';
28
+ export class OwnerRecoveryDegradedError extends Error {
29
+ code = OWNER_RECOVERY_DEGRADED;
30
+ constructor() {
31
+ super('owner channel recovery is degraded');
32
+ this.name = 'OwnerRecoveryDegradedError';
33
+ }
34
+ }
35
+ export class OwnerRecoveryTimeoutError extends Error {
36
+ stage;
37
+ code = OWNER_RECOVERY_DEGRADED;
38
+ constructor(stage) {
39
+ super(`owner channel recovery timed out at ${stage}`);
40
+ this.stage = stage;
41
+ this.name = 'OwnerRecoveryTimeoutError';
42
+ }
43
+ }
25
44
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
26
45
  const OWNER_UPDATE_MAX_COUNT = 20;
27
46
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -77,7 +96,17 @@ export class OwnerChannel {
77
96
  completionTasks = new Set();
78
97
  activeRequests = new Map();
79
98
  managementTail = Promise.resolve();
99
+ recoveryEpoch;
100
+ recoveryTask;
101
+ recoveryToken = 0;
102
+ recoveryQueued = 0;
103
+ watchGeneration = 0;
104
+ /** A timed-out client operation that must settle before replacement is safe. */
105
+ recoveryQuiescence;
106
+ releaseSafe = true;
80
107
  ready = false;
108
+ startedOnce = false;
109
+ shutdownDegraded = false;
81
110
  binder;
82
111
  binderOwnedInternally = false;
83
112
  fleetOps;
@@ -127,6 +156,7 @@ export class OwnerChannel {
127
156
  }
128
157
  async start() {
129
158
  this.stopping = false;
159
+ this.shutdownDegraded = false;
130
160
  this.binderOwnedInternally = !this.options.binderLease;
131
161
  this.binder = this.options.binderLease ?? await acquireOwnerBinderLease(this.options.stateDir, this.options.role, this.options.config.identity, this.options.binderDeps);
132
162
  try {
@@ -167,11 +197,12 @@ export class OwnerChannel {
167
197
  this.attachmentRecovery.cleanup(Date.now(), this.attachmentConfig.retention_ms);
168
198
  void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
169
199
  }
200
+ this.startedOnce = true;
170
201
  this.ready = true;
171
202
  // Do not make role startup wait for an old owner request to finish a turn.
172
203
  // watchLoop itself drains before every establishment, including this first
173
204
  // one, so there is no drain-to-tip race.
174
- this.watchTask = this.watchLoop();
205
+ this.watchTask = this.watchLoop(++this.watchGeneration);
175
206
  }
176
207
  drain() {
177
208
  this.drainRequested = true;
@@ -186,25 +217,65 @@ export class OwnerChannel {
186
217
  return this.drainTask;
187
218
  }
188
219
  async close() {
220
+ if (this.recoveryTask)
221
+ this.shutdownDegraded = true;
189
222
  this.stopping = true;
190
223
  this.ready = false;
191
- this.watchAbort?.abort();
192
- await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
224
+ this.recoveryToken++;
225
+ this.watchGeneration++;
226
+ const staleWatch = this.watchTask;
193
227
  this.watchTask = undefined;
194
- await this.managementTail;
228
+ this.watchAbort?.abort();
229
+ const now = this.options.recoveryDeps?.now ?? (() => Date.now());
230
+ const deadlineAt = now()
231
+ + (this.options.recoveryDeps?.deadlineMs ?? DAEMON_RECOVERY_DEADLINE_MS);
232
+ let disposed = false;
195
233
  try {
196
- await this.client.close();
234
+ await this.recoveryStage('shutdown_quiescence', Promise.all([
235
+ staleWatch?.catch(error => this.logError('watch shutdown failed', error)),
236
+ this.drainTask?.catch(error => this.logError('drain shutdown failed', error)),
237
+ this.managementTail.then(() => undefined),
238
+ this.recoveryQuiescence,
239
+ ]), deadlineAt);
240
+ await this.recoveryStage('shutdown_close', this.client.close(), deadlineAt);
241
+ disposed = true;
242
+ this.writeShutdownState('closed', 'OWNER_SHUTDOWN_CLOSED', now());
243
+ }
244
+ catch (error) {
245
+ this.shutdownDegraded = true;
246
+ this.releaseSafe = false;
247
+ const stage = error instanceof OwnerRecoveryTimeoutError ? error.stage : 'shutdown_unknown';
248
+ this.writeShutdownState('degraded', `OWNER_${stage.toUpperCase()}_TIMEOUT`, now());
249
+ this.options.log(`[${this.options.role}] owner shutdown degraded stage=${stage}`);
197
250
  }
198
251
  finally {
199
- if (this.binderOwnedInternally)
252
+ if (disposed && this.binderOwnedInternally)
200
253
  this.binder?.release();
201
- this.binder = undefined;
254
+ if (disposed)
255
+ this.binder = undefined;
202
256
  }
203
257
  }
258
+ binderReleaseSafe() { return this.releaseSafe; }
204
259
  manage(request) {
205
- const run = this.managementTail.then(() => this.manageNow(request));
206
- this.managementTail = run.then(() => undefined, () => undefined);
207
- return run;
260
+ return this.queueManagement(() => this.manageNow(request));
261
+ }
262
+ notifyFleetSpawn(event) {
263
+ return this.queueManagement(async () => {
264
+ if (!this.ready || this.stopping)
265
+ throw new Error('owner-channel MCP client is unavailable');
266
+ const model = event.model ? `, model ${event.model}` : '';
267
+ const monitorPolicy = event.monitor.interrupt === true
268
+ ? ' with interruption'
269
+ : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
270
+ const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
271
+ const permission = event.permissionMode
272
+ ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
273
+ : '';
274
+ const inherited = event.inherited.length
275
+ ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
276
+ await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
277
+ + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
278
+ });
208
279
  }
209
280
  beginFleetCommandAudit(requestId, argv) {
210
281
  const run = this.managementTail.then(async () => {
@@ -263,6 +334,129 @@ export class OwnerChannel {
263
334
  this.managementTail = run.then(() => undefined, () => undefined);
264
335
  return run;
265
336
  }
337
+ recover(epoch) {
338
+ if (this.stopping)
339
+ return Promise.reject(new Error('owner channel is stopping'));
340
+ if (this.recoveryEpoch === epoch && this.recoveryTask)
341
+ return this.recoveryTask;
342
+ this.recoveryEpoch = epoch;
343
+ this.ready = false;
344
+ const token = ++this.recoveryToken;
345
+ const watchGeneration = ++this.watchGeneration;
346
+ const staleWatch = this.watchTask;
347
+ this.watchTask = undefined;
348
+ this.watchAbort?.abort();
349
+ const superseded = () => this.stopping || token !== this.recoveryToken;
350
+ const now = this.options.recoveryDeps?.now ?? (() => Date.now());
351
+ const deadlineAt = now()
352
+ + (this.options.recoveryDeps?.deadlineMs ?? DAEMON_RECOVERY_DEADLINE_MS);
353
+ const run = this.managementTail.then(async () => {
354
+ if (superseded())
355
+ throw new Error('owner recovery epoch superseded');
356
+ if (this.recoveryQuiescence) {
357
+ const debt = this.recoveryQuiescence;
358
+ await this.recoveryStage('prior_quiescence', debt, deadlineAt);
359
+ if (this.recoveryQuiescence === debt)
360
+ this.recoveryQuiescence = undefined;
361
+ }
362
+ // Quiesce every stale-client reader before replacing its transport. A
363
+ // watch can be inside the shared drain task when its abort lands.
364
+ await this.recoveryStage('stale_quiescence', Promise.all([
365
+ staleWatch?.catch(error => this.logError('stale watch shutdown failed', error)),
366
+ this.drainTask?.catch(error => this.logError('stale drain shutdown failed', error)),
367
+ ]), deadlineAt);
368
+ if (superseded())
369
+ throw new Error('owner recovery epoch superseded');
370
+ await this.recoveryStage('close', this.client.close(), deadlineAt);
371
+ if (superseded())
372
+ throw new Error('owner recovery epoch superseded');
373
+ try {
374
+ await this.recoveryStage('start', this.client.start(), deadlineAt);
375
+ if (superseded())
376
+ throw new Error('owner recovery epoch superseded');
377
+ await this.recoveryStage('bind', this.client.bindIdentity(this.options.config.identity), deadlineAt);
378
+ if (superseded())
379
+ throw new Error('owner recovery epoch superseded');
380
+ // Journal-aware drain is the only operation allowed to mark owner mail
381
+ // read. The final listing is a typed, read-only channel probe.
382
+ await this.recoveryStage('drain', this.drain(), deadlineAt);
383
+ await this.recoveryStage('list', this.client.listIncomingMessages(), deadlineAt);
384
+ if (superseded())
385
+ throw new Error('owner recovery epoch superseded');
386
+ this.ready = true;
387
+ this.watchTask = this.watchLoop(watchGeneration);
388
+ }
389
+ catch (error) {
390
+ this.ready = false;
391
+ if (token === this.recoveryToken)
392
+ this.recoveryToken++;
393
+ // A timed-out operation may still be using this client. Never overlap
394
+ // its disposal; the recorded quiescence debt gates the next retry.
395
+ if (!this.recoveryQuiescence) {
396
+ try {
397
+ await this.recoveryStage('close_after_failure', this.client.close(), deadlineAt);
398
+ }
399
+ catch (closeError) {
400
+ this.logError('recovery client close failed', closeError);
401
+ }
402
+ }
403
+ throw error;
404
+ }
405
+ });
406
+ this.managementTail = run.then(() => undefined, () => undefined);
407
+ const task = run.finally(() => {
408
+ if (this.recoveryTask === task)
409
+ this.recoveryTask = undefined;
410
+ });
411
+ this.recoveryTask = task;
412
+ return task;
413
+ }
414
+ recoveryStage(stage, operation, deadlineAt) {
415
+ const now = this.options.recoveryDeps?.now ?? (() => Date.now());
416
+ const remaining = Math.max(0, deadlineAt - now());
417
+ const setTimer = this.options.recoveryDeps?.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
418
+ const clearTimer = this.options.recoveryDeps?.clearTimer ?? (timer => clearTimeout(timer));
419
+ let timer;
420
+ const timeout = new Promise((_resolve, reject) => {
421
+ timer = setTimer(() => reject(new OwnerRecoveryTimeoutError(stage)), remaining);
422
+ });
423
+ return Promise.race([operation, timeout]).catch(error => {
424
+ if (error instanceof OwnerRecoveryTimeoutError) {
425
+ const debt = operation.then(() => undefined, () => undefined);
426
+ this.recoveryQuiescence = debt;
427
+ }
428
+ throw error;
429
+ }).finally(() => clearTimer(timer));
430
+ }
431
+ writeShutdownState(state, reason, at) {
432
+ try {
433
+ replaceFileAtomically(join(this.options.stateDir, '.owner-channel-shutdown.json'), JSON.stringify({ version: 1, state, reason, at: new Date(at).toISOString() }, null, 2) + '\n');
434
+ }
435
+ catch (error) {
436
+ this.logError('shutdown status write failed', error);
437
+ }
438
+ }
439
+ queueManagement(operation) {
440
+ const deferred = this.recoveryTask !== undefined && !this.ready;
441
+ if (!this.ready && !deferred)
442
+ return Promise.reject(!this.startedOnce || (this.stopping && !this.shutdownDegraded)
443
+ ? new Error('owner-channel MCP client is unavailable')
444
+ : new OwnerRecoveryDegradedError());
445
+ if (deferred && this.recoveryQueued >= OWNER_RECOVERY_QUEUE_CAPACITY)
446
+ return Promise.reject(new Error('owner recovery queue is full'));
447
+ if (deferred)
448
+ this.recoveryQueued++;
449
+ const run = this.managementTail.then(() => {
450
+ if (!this.ready || this.stopping)
451
+ throw new OwnerRecoveryDegradedError();
452
+ return operation();
453
+ });
454
+ this.managementTail = run.then(() => undefined, () => undefined);
455
+ return run.finally(() => {
456
+ if (deferred)
457
+ this.recoveryQueued = Math.max(0, this.recoveryQueued - 1);
458
+ });
459
+ }
266
460
  async manageNow(request) {
267
461
  if (!this.ready || this.stopping)
268
462
  throw new Error('owner-channel MCP client is unavailable');
@@ -1916,7 +2110,7 @@ export class OwnerChannel {
1916
2110
  }
1917
2111
  return undefined;
1918
2112
  }
1919
- async watchLoop() {
2113
+ async watchLoop(generation) {
1920
2114
  const sleep = this.options.binderDeps?.sleep
1921
2115
  ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
1922
2116
  const restored = this.readWatchState();
@@ -1925,7 +2119,7 @@ export class OwnerChannel {
1925
2119
  state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
1926
2120
  let delayMs = 1_000;
1927
2121
  let attempts = 0;
1928
- while (!this.stopping) {
2122
+ while (!this.stopping && generation === this.watchGeneration) {
1929
2123
  const ctrl = new AbortController();
1930
2124
  this.watchAbort = ctrl;
1931
2125
  try {
@@ -1935,12 +2129,12 @@ export class OwnerChannel {
1935
2129
  // fall into a gap. Persistent history plus fleet's body-free claim
1936
2130
  // journal is authoritative; durable wire-ID dedupe makes hints harmless.
1937
2131
  await this.drain();
1938
- if (this.stopping)
2132
+ if (this.stopping || generation !== this.watchGeneration)
1939
2133
  return;
1940
2134
  state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
1941
2135
  attempts++;
1942
2136
  for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
1943
- if (this.stopping)
2137
+ if (this.stopping || generation !== this.watchGeneration)
1944
2138
  return;
1945
2139
  state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
1946
2140
  delayMs = 1_000;
@@ -1949,11 +2143,11 @@ export class OwnerChannel {
1949
2143
  // recorded before a managed request is dispatched.
1950
2144
  await this.drain();
1951
2145
  }
1952
- if (!this.stopping)
2146
+ if (!this.stopping && generation === this.watchGeneration)
1953
2147
  throw new Error('ours SDK notification stream ended');
1954
2148
  }
1955
2149
  catch (error) {
1956
- if (this.stopping)
2150
+ if (this.stopping || generation !== this.watchGeneration)
1957
2151
  return;
1958
2152
  // SDK 2 deliberately hides transport status behind its typed stream.
1959
2153
  // Do not parse error prose to rediscover it: every failure follows the
@@ -598,10 +598,7 @@ export const ownerCommands = [
598
598
  const templates = ctx.listTemplateQueries();
599
599
  if (!templates.length)
600
600
  return ctx.reply('📐 No templates.');
601
- const lines = templates.map(t => {
602
- const tag = t.builtin ? ' (built-in)' : '';
603
- return `${t.name}@${t.version}${tag} ${t.description}`;
604
- });
601
+ const lines = templates.map(t => `${t.name}@${t.version} ${t.description} [file: ${t.sourceFile ?? 'manifest'}]`);
605
602
  await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
606
603
  }),
607
604
  },
@@ -627,7 +624,7 @@ export const ownerCommands = [
627
624
  const lines = [
628
625
  `📐 Template: ${t.name}@${t.version}`,
629
626
  `Description: ${t.description}`,
630
- ...(t.builtin ? ['Source: built-in'] : []),
627
+ `Source: ${t.sourceFile ?? 'manifest'}`,
631
628
  ...(t.contract ? [`Contract: ${t.contract}`] : []),
632
629
  'Members:',
633
630
  ...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → `
@@ -646,10 +643,7 @@ export const ownerCommands = [
646
643
  const templates = ctx.listTemplateQueries();
647
644
  if (!templates.length)
648
645
  return ctx.reply('📐 No templates.');
649
- const lines = templates.map(t => {
650
- const tag = t.builtin ? ' (built-in)' : '';
651
- return `${t.name}@${t.version}${tag} ${t.description}`;
652
- });
646
+ const lines = templates.map(t => `${t.name}@${t.version} ${t.description} [file: ${t.sourceFile ?? 'manifest'}]`);
653
647
  await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
654
648
  break;
655
649
  }
@@ -0,0 +1,10 @@
1
+ export interface BootstrapResult {
2
+ revision: number;
3
+ sourceRoot: string;
4
+ configPath: string;
5
+ created: string[];
6
+ preserved: string[];
7
+ }
8
+ export declare const packagedPresetRoot: () => string;
9
+ /** Seed packaged defaults without replacing a single existing byte. */
10
+ export declare function bootstrapPresets(configuration?: string): BootstrapResult;
@@ -0,0 +1,85 @@
1
+ import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, chmodSync, } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { defaultConfigPath } from './paths.js';
5
+ import { splitRootFor } from './config.js';
6
+ export const packagedPresetRoot = () => fileURLToPath(new URL('../presets', import.meta.url));
7
+ function assertSafeExisting(path, kind) {
8
+ const stat = lstatSync(path);
9
+ if (stat.isSymbolicLink())
10
+ throw new Error(`preset bootstrap refuses symlink target: ${path}`);
11
+ if (kind === 'file' ? !stat.isFile() : !stat.isDirectory())
12
+ throw new Error(`preset bootstrap expected ${kind}: ${path}`);
13
+ const uid = process.getuid?.();
14
+ if (uid !== undefined && stat.uid !== uid)
15
+ throw new Error(`preset bootstrap refuses target owned by uid ${stat.uid}: ${path}`);
16
+ if ((stat.mode & 0o022) !== 0)
17
+ throw new Error(`preset bootstrap refuses group/world-writable target: ${path}`);
18
+ }
19
+ function ensureDirectory(path) {
20
+ const parent = dirname(path);
21
+ if (parent !== path && !existsSync(parent))
22
+ ensureDirectory(parent);
23
+ if (existsSync(path)) {
24
+ assertSafeExisting(path, 'directory');
25
+ return;
26
+ }
27
+ mkdirSync(path, { mode: 0o700 });
28
+ }
29
+ function seedFile(source, target, result) {
30
+ ensureDirectory(dirname(target));
31
+ if (existsSync(target)) {
32
+ assertSafeExisting(target, 'file');
33
+ result.preserved.push(target);
34
+ return;
35
+ }
36
+ try {
37
+ copyFileSync(source, target, constants.COPYFILE_EXCL);
38
+ chmodSync(target, 0o600);
39
+ result.created.push(target);
40
+ }
41
+ catch (error) {
42
+ if (error.code !== 'EEXIST')
43
+ throw error;
44
+ assertSafeExisting(target, 'file');
45
+ result.preserved.push(target);
46
+ }
47
+ }
48
+ function presetFiles(root) {
49
+ const files = [];
50
+ const visit = (directory) => {
51
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
52
+ const path = join(directory, entry.name);
53
+ if (entry.isDirectory())
54
+ visit(path);
55
+ else if (entry.isFile())
56
+ files.push(path);
57
+ }
58
+ };
59
+ visit(root);
60
+ return files.sort();
61
+ }
62
+ /** Seed packaged defaults without replacing a single existing byte. */
63
+ export function bootstrapPresets(configuration = defaultConfigPath()) {
64
+ const sourceRoot = packagedPresetRoot();
65
+ const manifest = JSON.parse(readFileSync(join(sourceRoot, 'manifest.json'), 'utf8'));
66
+ if (manifest.schema_version !== 1 || !Number.isInteger(manifest.preset_revision))
67
+ throw new Error(`invalid packaged preset manifest: ${join(sourceRoot, 'manifest.json')}`);
68
+ const configPath = resolve(configuration);
69
+ const result = {
70
+ revision: manifest.preset_revision, sourceRoot, configPath, created: [], preserved: [],
71
+ };
72
+ ensureDirectory(dirname(configPath));
73
+ seedFile(join(sourceRoot, 'fleet.yaml'), configPath, result);
74
+ const sourceSplit = join(sourceRoot, 'fleet');
75
+ const targetSplit = splitRootFor(configPath);
76
+ if (existsSync(targetSplit))
77
+ assertSafeExisting(targetSplit, 'directory');
78
+ for (const source of presetFiles(sourceSplit)) {
79
+ const rel = relative(sourceSplit, source);
80
+ if (rel.startsWith('..') || rel.includes(`..${sep}`))
81
+ throw new Error(`invalid packaged preset path: ${source}`);
82
+ seedFile(source, join(targetSplit, rel), result);
83
+ }
84
+ return result;
85
+ }
@@ -43,7 +43,7 @@ function taskRoomPublicFailure(error) {
43
43
  case 'template_not_found': return {
44
44
  legacy: `template not found: ${f.template ?? 'requested-template'}`, kind: 'not_found',
45
45
  detail: `The requested template ${f.template ?? ''}`.trim() + ' was not found.',
46
- action: 'Run ours-fleet template list and retry with an available template.',
46
+ action: 'Run ours-fleet init to seed missing file-backed presets, then run ours-fleet template list.',
47
47
  };
48
48
  case 'template_mismatch': return {
49
49
  legacy: `template ${f.requested ?? 'requested-template'} does not match room ${f.room ?? 'requested-room'}'s provisioned template ${f.provisioned ?? 'recorded-template'}`,
@@ -446,12 +446,11 @@ export function registerTemplateCommands(parent, cOpt) {
446
446
  return;
447
447
  }
448
448
  if (!templates.length) {
449
- console.log('No templates configured.');
449
+ console.log('No templates configured. Run ours-fleet init to seed missing file-backed presets.');
450
450
  return;
451
451
  }
452
452
  for (const t of templates) {
453
- const tag = t.builtin ? ' (built-in)' : '';
454
- console.log(`${t.name}@${t.version}${tag} ${t.description}`);
453
+ console.log(`${t.name}@${t.version} ${t.description} [file: ${t.sourceFile ?? 'manifest'}]`);
455
454
  }
456
455
  }
457
456
  catch (e) {
@@ -469,6 +468,7 @@ export function registerTemplateCommands(parent, cOpt) {
469
468
  return;
470
469
  }
471
470
  console.log(`${t.name}@${t.version} ${t.description}`);
471
+ console.log(`Source: ${t.sourceFile ?? 'manifest'}`);
472
472
  if (t.contract)
473
473
  console.log(`\nContract:\n${t.contract}`);
474
474
  console.log(`\nMembers:`);
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
4
- import { BUILTIN_TEMPLATES } from './templates.js';
5
4
  import { isSensitiveConfigKey } from '../sensitive-config.js';
6
5
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
7
6
  const CID_RE = /^[0-9a-fA-F]{64}$/;
@@ -117,9 +116,6 @@ export function validateRoomTemplatesConfig(raw, path) {
117
116
  if (!isPlainObject(tplRaw))
118
117
  throw new RoomsTasksConfigError(path, `room_templates.${name}: must be a mapping`);
119
118
  rejectUnknown(tplRaw, [...TPK, 'name'], path, `room_templates.${name}`);
120
- const isBuiltinOverride = BUILTIN_TEMPLATES.some(b => b.name === name);
121
- if (isBuiltinOverride && !tplRaw.override_builtin)
122
- throw new RoomsTasksConfigError(path, `room_templates.${name}: overrides built-in template; set override_builtin: true and bump version`);
123
119
  const version = tplRaw.version;
124
120
  if (version === undefined || !Number.isInteger(version) || version < 1)
125
121
  throw new RoomsTasksConfigError(path, `room_templates.${name}.version: required positive integer`);
@@ -1,5 +1,4 @@
1
1
  import type { TemplateDefinition, TemplateSnapshot } from './types.js';
2
- export declare const BUILTIN_TEMPLATES: readonly TemplateDefinition[];
3
2
  export declare function hashTemplate(t: TemplateDefinition): string;
4
3
  export declare function snapshotTemplate(t: TemplateDefinition): TemplateSnapshot;
5
4
  export declare function resolveTemplate(name: string, customTemplates: Record<string, TemplateDefinition>): TemplateDefinition | undefined;