@ours.network/fleet 1.1.0-nightly.5 → 1.1.0-nightly.7

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.
@@ -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';
@@ -14,6 +15,7 @@ import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, } from '../session/t
14
15
  import { VERSION } from '../version.js';
15
16
  import { renderMarkdownFailure, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
16
17
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
18
+ import { FleetCommandAuditStore, renderFleetAuditInvocation, renderFleetAuditOutcome, } from '../fleet-command-audit.js';
17
19
  import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
18
20
  import { ownerNotices, } from './notices.js';
19
21
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
@@ -21,6 +23,24 @@ import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
21
23
  import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
22
24
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
23
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
+ }
24
44
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
25
45
  const OWNER_UPDATE_MAX_COUNT = 20;
26
46
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -76,11 +96,22 @@ export class OwnerChannel {
76
96
  completionTasks = new Set();
77
97
  activeRequests = new Map();
78
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;
79
107
  ready = false;
108
+ startedOnce = false;
109
+ shutdownDegraded = false;
80
110
  binder;
81
111
  binderOwnedInternally = false;
82
112
  fleetOps;
83
113
  prepareRestart;
114
+ commandAudits;
84
115
  constructor(options) {
85
116
  this.options = options;
86
117
  this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
@@ -96,6 +127,7 @@ export class OwnerChannel {
96
127
  await lifecycle.prepareRestart({ roleIds: [role], mode });
97
128
  });
98
129
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
130
+ this.commandAudits = new FleetCommandAuditStore(join(options.stateDir, '.owner-channel-command-audit.json'));
99
131
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
100
132
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
101
133
  this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
@@ -124,6 +156,7 @@ export class OwnerChannel {
124
156
  }
125
157
  async start() {
126
158
  this.stopping = false;
159
+ this.shutdownDegraded = false;
127
160
  this.binderOwnedInternally = !this.options.binderLease;
128
161
  this.binder = this.options.binderLease ?? await acquireOwnerBinderLease(this.options.stateDir, this.options.role, this.options.config.identity, this.options.binderDeps);
129
162
  try {
@@ -164,11 +197,12 @@ export class OwnerChannel {
164
197
  this.attachmentRecovery.cleanup(Date.now(), this.attachmentConfig.retention_ms);
165
198
  void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
166
199
  }
200
+ this.startedOnce = true;
167
201
  this.ready = true;
168
202
  // Do not make role startup wait for an old owner request to finish a turn.
169
203
  // watchLoop itself drains before every establishment, including this first
170
204
  // one, so there is no drain-to-tip race.
171
- this.watchTask = this.watchLoop();
205
+ this.watchTask = this.watchLoop(++this.watchGeneration);
172
206
  }
173
207
  drain() {
174
208
  this.drainRequested = true;
@@ -183,28 +217,50 @@ export class OwnerChannel {
183
217
  return this.drainTask;
184
218
  }
185
219
  async close() {
220
+ if (this.recoveryTask)
221
+ this.shutdownDegraded = true;
186
222
  this.stopping = true;
187
223
  this.ready = false;
188
- this.watchAbort?.abort();
189
- await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
224
+ this.recoveryToken++;
225
+ this.watchGeneration++;
226
+ const staleWatch = this.watchTask;
190
227
  this.watchTask = undefined;
191
- 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;
192
233
  try {
193
- 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}`);
194
250
  }
195
251
  finally {
196
- if (this.binderOwnedInternally)
252
+ if (disposed && this.binderOwnedInternally)
197
253
  this.binder?.release();
198
- this.binder = undefined;
254
+ if (disposed)
255
+ this.binder = undefined;
199
256
  }
200
257
  }
258
+ binderReleaseSafe() { return this.releaseSafe; }
201
259
  manage(request) {
202
- const run = this.managementTail.then(() => this.manageNow(request));
203
- this.managementTail = run.then(() => undefined, () => undefined);
204
- return run;
260
+ return this.queueManagement(() => this.manageNow(request));
205
261
  }
206
262
  notifyFleetSpawn(event) {
207
- const run = this.managementTail.then(async () => {
263
+ return this.queueManagement(async () => {
208
264
  if (!this.ready || this.stopping)
209
265
  throw new Error('owner-channel MCP client is unavailable');
210
266
  const model = event.model ? `, model ${event.model}` : '';
@@ -220,9 +276,187 @@ export class OwnerChannel {
220
276
  await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
221
277
  + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
222
278
  });
279
+ }
280
+ beginFleetCommandAudit(requestId, argv) {
281
+ const run = this.managementTail.then(async () => {
282
+ let attempt = this.commandAudits.begin(requestId, this.options.role, argv);
283
+ if (attempt.invocation === 'delivered')
284
+ return attempt;
285
+ if (attempt.invocation === 'uncertain')
286
+ throw new Error('fleet command invocation delivery is uncertain; execution is denied');
287
+ if (!this.ready || this.stopping) {
288
+ this.commandAudits.invocation(attempt.correlationId, this.options.role, 'uncertain');
289
+ this.options.log(`[${this.options.role}] fleet command invocation ${attempt.correlationId} `
290
+ + 'delivery unavailable; execution denied');
291
+ throw new Error('owner-channel MCP client is unavailable; invocation recorded uncertain');
292
+ }
293
+ try {
294
+ await this.sendProactiveMessage(renderFleetAuditInvocation(attempt), `fleet-command-invocation\0${attempt.correlationId}`, 0);
295
+ }
296
+ catch (error) {
297
+ this.commandAudits.invocation(attempt.correlationId, this.options.role, 'uncertain');
298
+ throw error;
299
+ }
300
+ attempt = this.commandAudits.invocation(attempt.correlationId, this.options.role, 'delivered');
301
+ return attempt;
302
+ });
303
+ this.managementTail = run.then(() => undefined, () => undefined);
304
+ return run;
305
+ }
306
+ finishFleetCommandAudit(input) {
307
+ const run = this.managementTail.then(async () => {
308
+ let attempt = this.commandAudits.finish(input.correlationId, this.options.role, {
309
+ class: input.class, effect: input.effect,
310
+ ...(input.exitCode === undefined ? {} : { exitCode: input.exitCode }),
311
+ ...(input.resourceIds ? { resourceIds: input.resourceIds } : {}),
312
+ ...(input.presentation ? { presentation: input.presentation } : {}),
313
+ });
314
+ if (attempt.outcome?.delivery === 'delivered')
315
+ return attempt;
316
+ if (attempt.outcome?.delivery === 'uncertain')
317
+ throw new Error(`fleet command outcome delivery is uncertain after execution; effect status=${attempt.outcome.effect}`);
318
+ if (!this.ready || this.stopping) {
319
+ this.commandAudits.outcome(attempt.correlationId, this.options.role, 'uncertain');
320
+ this.options.log(`[${this.options.role}] fleet command outcome ${attempt.correlationId} `
321
+ + `delivery unavailable after execution; effect status=${attempt.outcome?.effect}`);
322
+ throw new Error(`owner-channel unavailable after execution; effect status=${attempt.outcome?.effect}`);
323
+ }
324
+ try {
325
+ await this.sendProactiveMessage(renderFleetAuditOutcome(attempt), `fleet-command-outcome\0${attempt.correlationId}`, 0);
326
+ }
327
+ catch (error) {
328
+ this.commandAudits.outcome(attempt.correlationId, this.options.role, 'uncertain');
329
+ throw new Error(`fleet command outcome delivery is uncertain after execution; effect status=${attempt.outcome?.effect}`, { cause: error });
330
+ }
331
+ attempt = this.commandAudits.outcome(attempt.correlationId, this.options.role, 'delivered');
332
+ return attempt;
333
+ });
223
334
  this.managementTail = run.then(() => undefined, () => undefined);
224
335
  return run;
225
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
+ }
226
460
  async manageNow(request) {
227
461
  if (!this.ready || this.stopping)
228
462
  throw new Error('owner-channel MCP client is unavailable');
@@ -1876,7 +2110,7 @@ export class OwnerChannel {
1876
2110
  }
1877
2111
  return undefined;
1878
2112
  }
1879
- async watchLoop() {
2113
+ async watchLoop(generation) {
1880
2114
  const sleep = this.options.binderDeps?.sleep
1881
2115
  ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
1882
2116
  const restored = this.readWatchState();
@@ -1885,7 +2119,7 @@ export class OwnerChannel {
1885
2119
  state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
1886
2120
  let delayMs = 1_000;
1887
2121
  let attempts = 0;
1888
- while (!this.stopping) {
2122
+ while (!this.stopping && generation === this.watchGeneration) {
1889
2123
  const ctrl = new AbortController();
1890
2124
  this.watchAbort = ctrl;
1891
2125
  try {
@@ -1895,12 +2129,12 @@ export class OwnerChannel {
1895
2129
  // fall into a gap. Persistent history plus fleet's body-free claim
1896
2130
  // journal is authoritative; durable wire-ID dedupe makes hints harmless.
1897
2131
  await this.drain();
1898
- if (this.stopping)
2132
+ if (this.stopping || generation !== this.watchGeneration)
1899
2133
  return;
1900
2134
  state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
1901
2135
  attempts++;
1902
2136
  for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
1903
- if (this.stopping)
2137
+ if (this.stopping || generation !== this.watchGeneration)
1904
2138
  return;
1905
2139
  state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
1906
2140
  delayMs = 1_000;
@@ -1909,11 +2143,11 @@ export class OwnerChannel {
1909
2143
  // recorded before a managed request is dispatched.
1910
2144
  await this.drain();
1911
2145
  }
1912
- if (!this.stopping)
2146
+ if (!this.stopping && generation === this.watchGeneration)
1913
2147
  throw new Error('ours SDK notification stream ended');
1914
2148
  }
1915
2149
  catch (error) {
1916
- if (this.stopping)
2150
+ if (this.stopping || generation !== this.watchGeneration)
1917
2151
  return;
1918
2152
  // SDK 2 deliberately hides transport status behind its typed stream.
1919
2153
  // Do not parse error prose to rediscover it: every failure follows the