@ours.network/fleet 1.2.0-nightly.79 → 1.2.0-nightly.80

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.
@@ -13,6 +13,9 @@ export interface FleetResourceScope {
13
13
  roots?: string[];
14
14
  /** Overrides full Fleet discovery for narrow owners such as task delivery. */
15
15
  paths?: string[];
16
+ /** Path routing precedes a coalesced committed callback. Undefined means
17
+ * unknown/recovered coverage and requires conservative discovery. */
18
+ onInvalidated?: (path: string | undefined) => void;
16
19
  watch?: (path: string, listener: (event: string, filename: string | null) => void) => Pick<FSWatcher, 'on' | 'close'>;
17
20
  }
18
21
  /** Only call after durable persistence succeeds. Observers cannot fail a commit. */
@@ -231,6 +231,7 @@ export function observeFleetResources(scope, onCommitted, signal) {
231
231
  return;
232
232
  }
233
233
  state = { ...state, position: state.position + 1, health: 'healthy' };
234
+ scope.onInvalidated?.(undefined);
234
235
  // Reinstallation precedes one checkpoint recovery pass; later work is
235
236
  // event driven again. Degraded retries inspect directory metadata only.
236
237
  onCommitted({ ...state });
@@ -240,6 +241,7 @@ export function observeFleetResources(scope, onCommitted, signal) {
240
241
  const wake = (path) => {
241
242
  if (disposed || state.health !== 'healthy')
242
243
  return;
244
+ scope.onInvalidated?.(path);
243
245
  state = { ...state, position: state.position + 1 };
244
246
  for (const cursor of cursors)
245
247
  if (path === undefined || cursor.accepts(path))
@@ -283,6 +285,7 @@ export function observeFleetResources(scope, onCommitted, signal) {
283
285
  if (!added)
284
286
  return;
285
287
  state = { ...state, position: state.position + 1 };
288
+ scope.onInvalidated?.(undefined);
286
289
  for (const cursor of cursors)
287
290
  cursor.position = state.position;
288
291
  }
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.79",
3
- "buildId": "ed6b63d22064",
4
- "commit": "62a21f66895ff79cd5e5ea190febcfc59611e946",
2
+ "version": "1.2.0-nightly.80",
3
+ "buildId": "6177cc5cdd30",
4
+ "commit": "8e7ff4b04a6f6bf26e2de2710d18f9b5f0994657",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-19T21:53:54.074Z",
6
+ "builtAt": "2026-10-19T22:26:47.749Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -10,12 +10,21 @@ export declare class FleetNotificationProducer {
10
10
  private readonly queues;
11
11
  private readonly observation;
12
12
  private continuation?;
13
+ private continuationAt;
14
+ private readonly dirty;
15
+ private readonly retryAt;
16
+ private discoveryNeeded;
17
+ private taskRoles?;
13
18
  private stopped;
14
19
  private readonly existing;
15
20
  private cleanup?;
16
21
  constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
17
22
  private names;
23
+ private segments;
18
24
  poll(): void;
25
+ private accept;
26
+ private flush;
27
+ private scanRole;
19
28
  private resume;
20
29
  drain(): Promise<void>;
21
30
  close(): Promise<void>;
@@ -2,9 +2,12 @@ import { observeFleetResources } from '../application/resource-observer.js';
2
2
  import { roomsDir } from '../rooms-tasks/room-state.js';
3
3
  import { cleanupDir, drainNotificationTargetCleanup, notificationAgentId, hasPendingNotificationTargetCleanup } from './target-cleanup.js';
4
4
  import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { basename, join, relative, resolve, sep } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { NotificationOutbox } from './outbox.js';
8
+ const BATCH_BYTES = 256 * 1024;
9
+ const TURN_ROLES = 4, TURN_MS = 8;
10
+ const RUNTIME_FILES = new Set(['.monitor-status', '.monitor-state.json', '.notify-cursor', '.session-events.jsonl', '.session-events.jsonl.1', '.subscription-usage.json']);
8
11
  /** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
9
12
  function taskRoleAssociations() {
10
13
  const dir = roomsDir(), result = new Map();
@@ -49,23 +52,53 @@ export class FleetNotificationProducer {
49
52
  queues = new Map();
50
53
  observation = new AbortController();
51
54
  continuation;
55
+ continuationAt = Infinity;
56
+ dirty = new Set();
57
+ retryAt = new Map();
58
+ discoveryNeeded = true;
59
+ taskRoles;
52
60
  stopped = false;
53
- existing = new Set();
61
+ existing = new Map();
54
62
  cleanup;
55
63
  constructor(roots, stateDir, config, warn = () => { }) {
56
64
  this.roots = roots;
57
65
  this.stateDir = stateDir;
58
66
  this.config = config;
59
67
  this.warn = warn;
60
- observeFleetResources({ retryWatchFailures: true, roots, paths: [...roots, roomsDir(), cleanupDir()] }, state => {
61
- if (state.health === 'healthy')
62
- this.poll();
63
- else
68
+ let healthy = true;
69
+ observeFleetResources({ retryWatchFailures: true, roots, paths: [...roots, roomsDir(), cleanupDir()],
70
+ onInvalidated: path => this.accept(path) }, state => {
71
+ if (state.health !== 'healthy') {
72
+ healthy = false;
64
73
  this.warn('Notification discovery observation unavailable; checkpoint retained');
74
+ return;
75
+ }
76
+ if (!healthy || this.dirty.size || this.discoveryNeeded) {
77
+ // Unknown wakes and watch reinstallation advance coverage without a
78
+ // pathname. Keep one conservative discovery pass for those barriers.
79
+ if (!healthy)
80
+ this.discoveryNeeded = true;
81
+ healthy = true;
82
+ this.resume();
83
+ }
65
84
  }, this.observation.signal);
85
+ // Freeze startup history boundaries before deferring any role's content
86
+ // work. Later appends/segments must not become part of the ignored history.
87
+ // This pass reads directory/file metadata only, never ledger contents.
66
88
  for (const root of roots)
67
- for (const name of this.names(root))
68
- this.existing.add(join(root, name));
89
+ for (const name of this.names(root)) {
90
+ const roleDir = join(root, name), dir = join(roleDir, '.conversation');
91
+ try {
92
+ if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
93
+ continue;
94
+ const segment = this.segments(dir).at(-1);
95
+ if (segment)
96
+ this.existing.set(roleDir, { segment, offset: statSync(join(dir, segment)).size, prompts: [] });
97
+ }
98
+ catch {
99
+ this.existing.set(roleDir, null);
100
+ }
101
+ }
69
102
  this.poll();
70
103
  }
71
104
  names(root) {
@@ -76,7 +109,41 @@ export class FleetNotificationProducer {
76
109
  return [];
77
110
  }
78
111
  }
112
+ segments(dir) {
113
+ return readdirSync(dir).filter(n => /^events-\d{6}\.jsonl$/.test(n) && !lstatSync(join(dir, n)).isSymbolicLink()).sort();
114
+ }
79
115
  poll() {
116
+ if (this.stopped)
117
+ return;
118
+ this.discoveryNeeded = true;
119
+ this.flush();
120
+ }
121
+ accept(path) {
122
+ if (path === undefined) {
123
+ this.discoveryNeeded = true;
124
+ return true;
125
+ }
126
+ for (const root of this.roots) {
127
+ const local = relative(resolve(root), path), parts = local.split(sep);
128
+ if (local === '..' || local.startsWith('..' + sep))
129
+ continue;
130
+ if (parts.length === 2 && RUNTIME_FILES.has(parts[1]))
131
+ return false;
132
+ if (parts.length >= 2 && parts[1] === '.conversation') {
133
+ if (parts.length <= 3) {
134
+ this.dirty.add(join(root, parts[0]));
135
+ return true;
136
+ }
137
+ }
138
+ // Role replacement, unknown paths and metadata retain discovery. Room
139
+ // association and target-cleanup paths take the same conservative route.
140
+ this.discoveryNeeded = true;
141
+ return true;
142
+ }
143
+ this.discoveryNeeded = true;
144
+ return true;
145
+ }
146
+ flush() {
80
147
  if (this.stopped)
81
148
  return;
82
149
  this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (target) => {
@@ -122,114 +189,161 @@ export class FleetNotificationProducer {
122
189
  }
123
190
  catch { }
124
191
  });
125
- let taskRoles;
126
- try {
127
- taskRoles = taskRoleAssociations();
192
+ if (this.discoveryNeeded) {
193
+ this.discoveryNeeded = false;
194
+ try {
195
+ this.taskRoles = taskRoleAssociations();
196
+ }
197
+ catch {
198
+ this.taskRoles = undefined;
199
+ this.warn('Notification task association unavailable; delivering unclassified events');
200
+ }
201
+ for (const root of this.roots)
202
+ for (const name of this.names(root))
203
+ this.dirty.add(join(root, name));
128
204
  }
129
- catch {
130
- this.warn('Notification task association unavailable; delivering unclassified events');
205
+ // A turn reads at most four 256KiB batches (1MiB). The time bound is checked
206
+ // between roles; a synchronous filesystem operation cannot be preempted.
207
+ const started = performance.now();
208
+ let roles = 0;
209
+ for (const roleDir of this.dirty) {
210
+ if (roles >= TURN_ROLES || (roles && performance.now() - started >= TURN_MS))
211
+ break;
212
+ if ((this.retryAt.get(roleDir) ?? 0) > Date.now())
213
+ continue;
214
+ this.dirty.delete(roleDir);
215
+ this.retryAt.delete(roleDir);
216
+ roles++;
217
+ this.scanRole(roleDir, started + TURN_MS);
131
218
  }
132
- for (const root of this.roots)
133
- for (const roleId of this.names(root)) {
134
- const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
135
- try {
136
- if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
137
- continue;
138
- const segments = readdirSync(dir).filter(n => /^events-\d{6}\.jsonl$/.test(n) && !lstatSync(join(dir, n)).isSymbolicLink()).sort();
139
- if (!segments.length)
140
- continue;
141
- let outbox = this.queues.get(roleDir);
142
- if (!outbox) {
143
- const key = createHash('sha256').update(roleDir).digest('hex');
144
- outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
145
- ...value, target: { kind: 'agent', agentId: roleId } }), this.warn);
146
- if (!outbox.checkpoint) {
147
- const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
148
- outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
149
- }
150
- this.queues.set(roleDir, outbox);
151
- }
152
- const saved = outbox.checkpoint;
153
- const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
154
- let index = segments.indexOf(current.segment);
155
- if (index < 0) {
156
- this.warn('Notification ledger segment unavailable; checkpoint retained');
157
- continue;
158
- }
159
- const file = join(dir, current.segment), size = statSync(file).size;
160
- if (size < current.offset) {
161
- this.warn('Notification ledger truncated; checkpoint retained');
162
- continue;
163
- }
164
- if (size === current.offset && index < segments.length - 1) {
165
- current.segment = segments[++index];
166
- current.offset = 0;
167
- }
168
- const fd = openSync(join(dir, current.segment), 'r'), buffer = Buffer.alloc(256 * 1024);
169
- let count;
170
- try {
171
- count = readSync(fd, buffer, 0, buffer.length, current.offset);
172
- }
173
- finally {
174
- closeSync(fd);
175
- }
176
- if (current.offset + count < statSync(join(dir, current.segment)).size || index < segments.length - 1)
177
- this.resume();
178
- let position = 0;
179
- while (position < count) {
180
- const end = buffer.indexOf(10, position);
181
- if (end < 0 || end >= count) {
182
- // Oversized model/media events are irrelevant to prompt/completion
183
- // correlation. Skip in bounded chunks; retain partial small lines.
184
- if (current.discarding || (position === 0 && count === buffer.length)) {
185
- current.offset += count - position;
186
- current.discarding = true;
187
- this.warn('Notification observer skipping oversized ledger record');
188
- }
189
- break;
190
- }
191
- if (current.discarding) {
192
- current.offset += end - position + 1;
193
- position = end + 1;
194
- current.discarding = false;
195
- continue;
196
- }
197
- const event = JSON.parse(buffer.subarray(position, end).toString('utf8'));
198
- if (event.schemaVersion !== 1 || event.roleId !== roleId)
199
- throw new Error('invalid ledger event');
200
- const prompt = `${event.sessionGeneration}:${event.promptId}`;
201
- if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
202
- if (!current.prompts.includes(prompt))
203
- current.prompts.push(prompt);
204
- }
205
- if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
206
- current.messages[prompt] = event.messageId;
207
- if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
208
- if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
209
- this.resume(1000);
210
- break;
211
- }
212
- current.prompts = current.prompts.filter(p => p !== prompt);
213
- delete current.messages[prompt];
214
- }
215
- current.offset += end - position + 1;
216
- position = end + 1;
219
+ if (this.dirty.size) {
220
+ let next = Infinity;
221
+ for (const roleDir of this.dirty)
222
+ next = Math.min(next, this.retryAt.get(roleDir) ?? Date.now());
223
+ this.resume(Math.max(0, next - Date.now()));
224
+ }
225
+ }
226
+ scanRole(roleDir, deadline) {
227
+ const roleId = basename(roleDir), dir = join(roleDir, '.conversation');
228
+ try {
229
+ if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
230
+ return;
231
+ const segments = this.segments(dir);
232
+ if (!segments.length)
233
+ return;
234
+ let outbox = this.queues.get(roleDir);
235
+ if (!outbox) {
236
+ const key = createHash('sha256').update(roleDir).digest('hex');
237
+ outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
238
+ ...value, target: { kind: 'agent', agentId: roleId } }), this.warn);
239
+ this.queues.set(roleDir, outbox);
240
+ }
241
+ if (!outbox.checkpoint) {
242
+ const initial = this.existing.get(roleDir);
243
+ if (initial === null)
244
+ throw Error('Notification startup ledger boundary unavailable');
245
+ outbox.saveCheckpoint(initial ?? { segment: segments[0], offset: 0, prompts: [] });
246
+ }
247
+ const saved = outbox.checkpoint;
248
+ const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
249
+ let index = segments.indexOf(current.segment);
250
+ if (index < 0) {
251
+ this.warn('Notification ledger segment unavailable; checkpoint retained');
252
+ return;
253
+ }
254
+ const file = join(dir, current.segment), size = statSync(file).size;
255
+ if (size < current.offset) {
256
+ this.warn('Notification ledger truncated; checkpoint retained');
257
+ return;
258
+ }
259
+ let remaining = size - current.offset;
260
+ while (remaining === 0 && index < segments.length - 1) {
261
+ current.segment = segments[++index];
262
+ current.offset = 0;
263
+ remaining = statSync(join(dir, current.segment)).size;
264
+ }
265
+ if (remaining === 0) {
266
+ if (current.segment !== saved.segment)
267
+ outbox.saveCheckpoint(current);
268
+ return; // Latest EOF: no ledger open, allocation or read.
269
+ }
270
+ const fd = openSync(join(dir, current.segment), 'r'), buffer = Buffer.alloc(Math.min(BATCH_BYTES, remaining));
271
+ let count;
272
+ try {
273
+ count = readSync(fd, buffer, 0, buffer.length, current.offset);
274
+ }
275
+ finally {
276
+ closeSync(fd);
277
+ }
278
+ let position = 0, blocked = false;
279
+ while (position < count) {
280
+ const end = buffer.indexOf(10, position);
281
+ if (end < 0 || end >= count) {
282
+ // Oversized model/media events are irrelevant to prompt/completion
283
+ // correlation. Skip in bounded chunks; retain partial small lines.
284
+ if (current.discarding || (position === 0 && count === BATCH_BYTES)) {
285
+ current.offset += count - position;
286
+ current.discarding = true;
287
+ this.warn('Notification observer skipping oversized ledger record');
217
288
  }
218
- if (current.segment !== saved.segment || current.offset !== saved.offset)
219
- outbox.saveCheckpoint(current);
289
+ break;
220
290
  }
221
- catch {
222
- this.warn('Notification ledger admission pending; checkpoint will replay');
223
- // Retained, not-yet-admitted ledger work needs its own next-due wake;
224
- // the outbox sender can retry only events that were already admitted.
225
- this.resume(1000);
291
+ if (current.discarding) {
292
+ current.offset += end - position + 1;
293
+ position = end + 1;
294
+ current.discarding = false;
295
+ continue;
296
+ }
297
+ const event = JSON.parse(buffer.subarray(position, end).toString('utf8'));
298
+ if (event.schemaVersion !== 1 || event.roleId !== roleId)
299
+ throw new Error('invalid ledger event');
300
+ const prompt = `${event.sessionGeneration}:${event.promptId}`;
301
+ if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
302
+ if (!current.prompts.includes(prompt))
303
+ current.prompts.push(prompt);
226
304
  }
305
+ if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
306
+ current.messages[prompt] = event.messageId;
307
+ if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
308
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(this.taskRoles ? { taskId: this.taskRoles.get(roleId) ?? null } : {}), anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
309
+ blocked = true;
310
+ break;
311
+ }
312
+ current.prompts = current.prompts.filter(p => p !== prompt);
313
+ delete current.messages[prompt];
314
+ }
315
+ current.offset += end - position + 1;
316
+ position = end + 1;
317
+ if (performance.now() >= deadline)
318
+ break;
227
319
  }
320
+ if (current.segment !== saved.segment || current.offset !== saved.offset)
321
+ outbox.saveCheckpoint(current);
322
+ if (blocked) {
323
+ this.retryAt.set(roleDir, Date.now() + 1000);
324
+ this.dirty.add(roleDir);
325
+ }
326
+ else if ((current.segment !== saved.segment || current.offset !== saved.offset)
327
+ && (current.offset < statSync(join(dir, current.segment)).size || index < segments.length - 1))
328
+ this.dirty.add(roleDir);
329
+ }
330
+ catch {
331
+ this.warn('Notification ledger admission pending; checkpoint will replay');
332
+ // Retained, not-yet-admitted ledger work needs its own next-due wake;
333
+ // the outbox sender can retry only events that were already admitted.
334
+ this.retryAt.set(roleDir, Date.now() + 1000);
335
+ this.dirty.add(roleDir);
336
+ }
228
337
  }
229
338
  resume(delay = 0) {
230
- if (this.continuation || this.stopped)
339
+ if (this.stopped)
340
+ return;
341
+ const at = Date.now() + delay;
342
+ if (this.continuation && this.continuationAt <= at)
231
343
  return;
232
- this.continuation = setTimeout(() => { this.continuation = undefined; this.poll(); }, delay);
344
+ clearTimeout(this.continuation);
345
+ this.continuationAt = at;
346
+ this.continuation = setTimeout(() => { this.continuation = undefined; this.continuationAt = Infinity; this.flush(); }, delay);
233
347
  this.continuation.unref();
234
348
  }
235
349
  async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
@@ -47,6 +47,9 @@ export declare class FleetConfigService {
47
47
  readonly path: string;
48
48
  private readonly preflight;
49
49
  private readonly beforeMutation?;
50
+ private validationInputs;
51
+ /** Cached during canonical reads; observer filtering must never parse YAML. */
52
+ resourceValidationInputs(): readonly string[];
50
53
  constructor(options?: FleetConfigServiceOptions);
51
54
  read(includeDefinitions?: boolean): ConfigReadResult;
52
55
  preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, } from 'node:fs';
3
- import { basename, dirname, extname, join } from 'node:path';
3
+ import { basename, dirname, extname, join, resolve } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
6
6
  import { loadConfig, ROLE_NAME_RE, splitRootFor } from '../config.js';
@@ -18,6 +18,9 @@ export class FleetConfigService {
18
18
  path;
19
19
  preflight;
20
20
  beforeMutation;
21
+ validationInputs = [];
22
+ /** Cached during canonical reads; observer filtering must never parse YAML. */
23
+ resourceValidationInputs() { return this.validationInputs; }
21
24
  constructor(options = {}) {
22
25
  this.path = options.configPath ?? defaultConfigPath();
23
26
  this.preflight = options.preflight ?? (async () => emptyReport());
@@ -25,13 +28,24 @@ export class FleetConfigService {
25
28
  }
26
29
  read(includeDefinitions = false) {
27
30
  const sources = readSources(this.path);
31
+ let raw;
28
32
  try {
33
+ const manifest = parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false });
34
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
35
+ const vars = record(manifest.vars);
36
+ const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
37
+ const invite = record(record(manifest.rooms).owner).public_invite_file;
38
+ const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
39
+ // Record even invalid/missing references before validation, so repairing an
40
+ // already observed dependency can never retain a certified stale capture.
41
+ this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
42
+ ...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
29
43
  loadConfig(this.path, { yamlMode: 'strict' });
44
+ raw = modelFrom(sources, includeDefinitions);
30
45
  }
31
46
  catch (error) {
32
47
  throw safeError(error, sourceSensitiveValues(sources));
33
48
  }
34
- const raw = modelFrom(sources, includeDefinitions);
35
49
  const redacted = redactModel(raw);
36
50
  return {
37
51
  path: basename(this.path), exists: true, firstRun: false,
@@ -108,9 +108,10 @@ export declare class ResourceBindingError extends Error {
108
108
  export declare class FleetResourceViews {
109
109
  private scope;
110
110
  private project;
111
+ private configurationInputs;
111
112
  readonly producerGeneration: `${string}-${string}-${string}-${string}-${string}`;
112
113
  private connections;
113
- constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown>);
114
+ constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown>, configurationInputs?: () => readonly string[]);
114
115
  open(response: ServerResponse, owner: string, authorized: () => boolean): {
115
116
  connectionId: `${string}-${string}-${string}-${string}-${string}`;
116
117
  close: () => void;
@@ -1,5 +1,7 @@
1
1
  import { relative, resolve, sep } from 'node:path';
2
- import { agentsRoot, tmpRoot, stateRoot } from '../paths.js';
2
+ import { agentsRoot, tmpRoot, stateRoot, defaultConfigPath, fleetDDir } from '../paths.js';
3
+ import { splitRootFor } from '../config.js';
4
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { homedir } from 'node:os';
5
7
  import { observeFleetResources } from '../application/resource-observer.js';
@@ -184,11 +186,13 @@ export class ResourceBindingError extends Error {
184
186
  export class FleetResourceViews {
185
187
  scope;
186
188
  project;
189
+ configurationInputs;
187
190
  producerGeneration = randomUUID();
188
191
  connections = new Map();
189
- constructor(scope, project) {
192
+ constructor(scope, project, configurationInputs = () => []) {
190
193
  this.scope = scope;
191
194
  this.project = project;
195
+ this.configurationInputs = configurationInputs;
192
196
  }
193
197
  open(response, owner, authorized) {
194
198
  const id = randomUUID(), controller = new AbortController();
@@ -348,6 +352,21 @@ export class FleetResourceViews {
348
352
  } });
349
353
  }
350
354
  affects(descriptor, path, structural = false) {
355
+ if (descriptor.resource === 'configuration') {
356
+ const manifest = resolve(this.scope.configPath ?? defaultConfigPath());
357
+ const root = resolve(splitRootFor(manifest)), changed = resolve(path);
358
+ const within = (candidate, parent) => {
359
+ const local = relative(parent, candidate);
360
+ return local === '' || (local !== '..' && !local.startsWith('..' + sep));
361
+ };
362
+ // Configuration and provenance read the manifest and split definitions;
363
+ // loadConfig also rejects legacy YAML files in the global fleet.d root.
364
+ // Ancestor replacement/unknown directory wakes remain conservative.
365
+ const legacy = resolve(fleetDDir());
366
+ return within(changed, root) || within(root, changed) || within(manifest, changed)
367
+ || within(changed, legacy) || within(legacy, changed)
368
+ || this.configurationInputs().some(input => within(resolve(input), changed));
369
+ }
351
370
  // An explicitly selected manifest may use any pathname, including one
352
371
  // otherwise reserved for an independent task receipt.
353
372
  if (this.scope.configPath && resolve(path) === resolve(this.scope.configPath))
@@ -389,10 +408,8 @@ export class FleetResourceViews {
389
408
  return false;
390
409
  if (descriptor.resource === 'previews')
391
410
  return (descriptor.ids ?? []).includes(parts[0]);
392
- // Configuration/provenance reads no conversation data. Directory and
393
- // roles intentionally remain conservative: live status includes activity.
394
- if (descriptor.resource === 'configuration')
395
- return false;
411
+ // Directory and roles intentionally remain conservative: live status
412
+ // includes activity. Configuration was classified by its inputs above.
396
413
  }
397
414
  return true;
398
415
  }
@@ -404,7 +421,14 @@ export class FleetResourceViews {
404
421
  if (view.running)
405
422
  return;
406
423
  view.running = true;
407
- const task = connection.tail.then(() => this.refresh(connection, view));
424
+ const task = connection.tail.then(async () => {
425
+ // Resolved projection promises must not starve HTTP, timers or native
426
+ // watch delivery. Preserve serialization and coalesce this event batch.
427
+ await yieldToEventLoop();
428
+ if (!this.current(connection, view) || view.failed)
429
+ return;
430
+ await this.refresh(connection, view);
431
+ });
408
432
  connection.tail = task.catch(() => { });
409
433
  void task.finally(() => { view.running = false; if (view.dirty)
410
434
  this.schedule(connection, view); }).catch(connection.close);
@@ -424,7 +424,7 @@ export async function buildWebServer(services, boundary, options = {}) {
424
424
  }
425
425
  }
426
426
  };
427
- const resources = new FleetResourceViews(services.resourceScope ?? {}, project);
427
+ const resources = new FleetResourceViews(services.resourceScope ?? {}, project, () => services.configuration?.resourceValidationInputs?.() ?? []);
428
428
  app.addHook('preClose', async () => resources.close());
429
429
  app.get('/api/v1/resource-events', async (request, reply) => {
430
430
  const browser = auth.authenticate(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.79",
3
+ "version": "1.2.0-nightly.80",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",