@ours.network/fleet 1.2.0-nightly.82 → 1.2.0-nightly.84

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.
@@ -25,9 +25,6 @@ export declare function resumedConversationPage(stateDir: string, request: {
25
25
  }, live: ConversationPageView, expectedSessionId: string): Promise<ConversationPageView>;
26
26
  /** Download authorization requires an actual published attachment, never an orphan copy. */
27
27
  export declare function conversationHasAttachment(stateDir: string, attachment: import('../file-delivery/types.js').DeliveredFile): Promise<boolean>;
28
- /** Passive, bounded last assistant text for directory previews. Never opens the
29
- * ledger writer/recovery path or acquires a session/controller. An incomplete
30
- * newest append is not committed yet; completed malformed records still fail. */
31
28
  export declare function conversationPreview(stateDir: string, signal: AbortSignal): Promise<{
32
29
  text: string;
33
30
  at: string;
@@ -1,5 +1,5 @@
1
1
  import { createReadStream } from 'node:fs';
2
- import { open, readFile, readdir } from 'node:fs/promises';
2
+ import { open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
5
  const HISTORICAL_KINDS = ['file.attached', 'fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'];
@@ -40,8 +40,15 @@ async function readLinesBackward(path, visit, signal) {
40
40
  await file.close();
41
41
  }
42
42
  }
43
+ // Rotated segments never change, so their last cursor is reused until the file itself changes.
44
+ const lastSeqs = new Map();
43
45
  /** The last event cursor in a segment, read from its tail; undefined when none is readable. */
44
46
  async function lastSeq(path) {
47
+ const info = await stat(path).catch(() => undefined);
48
+ const key = info && `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`;
49
+ const known = key ? lastSeqs.get(path) : undefined;
50
+ if (key && known?.key === key)
51
+ return known.seq;
45
52
  let seq;
46
53
  await readLinesBackward(path, line => { try {
47
54
  const value = Number(JSON.parse(line).seq);
@@ -51,6 +58,12 @@ async function lastSeq(path) {
51
58
  }
52
59
  }
53
60
  catch { } return true; });
61
+ if (key) {
62
+ lastSeqs.delete(path);
63
+ lastSeqs.set(path, { key, seq });
64
+ if (lastSeqs.size > 4096)
65
+ lastSeqs.delete(lastSeqs.keys().next().value);
66
+ }
54
67
  return seq;
55
68
  }
56
69
  /**
@@ -230,6 +243,60 @@ export async function conversationHasAttachment(stateDir, attachment) {
230
243
  /** Passive, bounded last assistant text for directory previews. Never opens the
231
244
  * ledger writer/recovery path or acquires a session/controller. An incomplete
232
245
  * newest append is not committed yet; completed malformed records still fail. */
246
+ /** Bytes of a segment's tail a preview may consider, and the newest events it reads. */
247
+ const PREVIEW_WINDOW_BYTES = 2 * 1024 * 1024;
248
+ const PREVIEW_EVENTS = 200;
249
+ /**
250
+ * Newest-first completed events from the last PREVIEW_WINDOW_BYTES of one
251
+ * segment, read backwards in chunks until `need` events are found. A record
252
+ * cut by the window start is not complete and is skipped. Only the newest
253
+ * segment can have an in-flight append; elsewhere a partial record fails.
254
+ */
255
+ async function previewSegmentEvents(path, newest, need, signal) {
256
+ const events = [];
257
+ const file = await open(path, 'r');
258
+ try {
259
+ const size = (await file.stat()).size, floor = Math.max(0, size - PREVIEW_WINDOW_BYTES);
260
+ let position = size, carry = Buffer.alloc(0), trailing = true;
261
+ const take = (line) => {
262
+ const event = JSON.parse(line);
263
+ if (event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq))
264
+ throw Error('preview ledger invalid');
265
+ events.push(event);
266
+ };
267
+ while (position > floor && events.length < need) {
268
+ signal.throwIfAborted();
269
+ const length = Math.min(64 * 1024, position - floor);
270
+ position -= length;
271
+ const chunk = Buffer.alloc(length);
272
+ await file.read(chunk, 0, length, position);
273
+ const buffer = Buffer.concat([chunk, carry]);
274
+ let end = buffer.length;
275
+ for (let i = buffer.length - 1; i >= 0 && events.length < need; i--) {
276
+ if (buffer[i] !== 10)
277
+ continue;
278
+ if (trailing) {
279
+ if (end > i + 1 && !newest)
280
+ throw Error('preview ledger has partial record');
281
+ trailing = false;
282
+ }
283
+ else
284
+ take(buffer.toString('utf8', i + 1, end));
285
+ end = i;
286
+ }
287
+ carry = buffer.subarray(0, end);
288
+ }
289
+ if (trailing && position === floor && (!newest || floor > 0) && size > 0)
290
+ throw Error('preview ledger has partial record');
291
+ // At the file start the remaining bytes are the first complete record.
292
+ if (!trailing && floor === 0 && position === 0 && events.length < need)
293
+ take(carry.toString('utf8'));
294
+ }
295
+ finally {
296
+ await file.close();
297
+ }
298
+ return events;
299
+ }
233
300
  export async function conversationPreview(stateDir, signal) {
234
301
  signal.throwIfAborted();
235
302
  const dir = join(stateDir, '.conversation');
@@ -245,30 +312,8 @@ export async function conversationPreview(stateDir, signal) {
245
312
  const events = [];
246
313
  for (const name of files) {
247
314
  signal.throwIfAborted();
248
- const file = await open(join(dir, name), 'r');
249
- try {
250
- const size = (await file.stat()).size, length = Math.min(size, 2 * 1024 * 1024), buffer = Buffer.alloc(length);
251
- await file.read(buffer, 0, length, size - length);
252
- const completeEnd = buffer.lastIndexOf(10) + 1;
253
- // Only the newest segment can have an in-flight append. If its tail
254
- // fills the bounded window, we cannot establish the previous preview.
255
- if (completeEnd < length && (name !== files[0] || (size > length && completeEnd === 0)))
256
- throw Error('preview ledger has partial record');
257
- const lines = buffer.toString('utf8', 0, completeEnd).split('\n');
258
- lines.pop();
259
- if (size > length)
260
- lines.shift();
261
- for (let i = lines.length - 1; i >= 0 && events.length < 200; i--) {
262
- const event = JSON.parse(lines[i]);
263
- if (event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq))
264
- throw Error('preview ledger invalid');
265
- events.push(event);
266
- }
267
- }
268
- finally {
269
- await file.close();
270
- }
271
- if (events.length >= 200)
315
+ events.push(...await previewSegmentEvents(join(dir, name), name === files[0], PREVIEW_EVENTS - events.length, signal));
316
+ if (events.length >= PREVIEW_EVENTS)
272
317
  break;
273
318
  }
274
319
  signal.throwIfAborted();
@@ -16,6 +16,8 @@ export declare class RoleRepository {
16
16
  constructor(options?: RoleRepositoryOptions);
17
17
  list(strict?: boolean): Promise<RoleRecord[]>;
18
18
  get(id: string): Promise<RoleRecord | undefined>;
19
+ private scan;
20
+ private record;
19
21
  stateDir(record: RoleRecord): string | undefined;
20
22
  private detect;
21
23
  }
@@ -104,6 +104,20 @@ export class RoleRepository {
104
104
  this.options = options;
105
105
  }
106
106
  async list(strict = false) {
107
+ const scan = this.scan(strict);
108
+ const names = [...new Set([...scan.configuredByName.keys(), ...scan.permanent, ...scan.temporary])].sort();
109
+ return boundedMap(names, this.options.concurrency ?? 8, name => this.record(scan, name, strict));
110
+ }
111
+ // One role's record is built from its own state and probe; the other roles are never probed.
112
+ async get(id) {
113
+ if (!ROLE_NAME_RE.test(id))
114
+ return undefined;
115
+ const scan = this.scan(false);
116
+ if (!scan.configuredByName.has(id) && !scan.permanent.has(id) && !scan.temporary.has(id))
117
+ return undefined;
118
+ return this.record(scan, id, false);
119
+ }
120
+ scan(strict) {
107
121
  const permanentRoot = this.options.permanentRoot ?? agentsRoot();
108
122
  const temporaryRoot = this.options.temporaryRoot ?? tmpRoot();
109
123
  let configured = [];
@@ -116,60 +130,58 @@ export class RoleRepository {
116
130
  throw error;
117
131
  configProblem = { code: 'config_invalid', severity: 'error', source: 'config', detail: error.message };
118
132
  }
119
- const permanent = new Set(safeDirs(permanentRoot, strict));
120
- const temporary = new Set(safeDirs(temporaryRoot, strict));
121
- const configuredByName = new Map(configured.map(role => [role.name, role]));
122
- const names = [...new Set([...configuredByName.keys(), ...permanent, ...temporary])].sort();
123
- return boundedMap(names, this.options.concurrency ?? 8, async (name) => {
124
- const config = configuredByName.get(name);
125
- const inPermanent = permanent.has(name);
126
- const inTemporary = temporary.has(name);
127
- const problems = configProblem ? [configProblem] : [];
128
- let tempRole;
129
- let stateHealth = inPermanent || inTemporary ? 'present' : 'missing';
130
- if (inTemporary) {
131
- const read = snapshot(join(temporaryRoot, name, 'role.yaml'), name);
132
- tempRole = read.role;
133
- if (strict && read.problem)
134
- throw new Error('temporary role snapshot unavailable');
135
- if (read.problem) {
136
- problems.push(read.problem);
137
- stateHealth = 'corrupt';
138
- }
139
- }
140
- if (inPermanent && inTemporary)
141
- problems.push({ code: 'duplicate_state', severity: 'error', detail: 'both permanent and temporary state exist' });
142
- const intended = config?.session ?? tempRole?.session;
143
- const detected = await this.detect(name, intended);
144
- if (intended && detected !== 'none' && detected !== intended && detected !== 'ambiguous')
145
- problems.push({
146
- code: 'backend_mismatch', severity: 'warning',
147
- detail: `configured ${intended}, detected ${detected}`,
148
- });
149
- const stateRef = inTemporary ? { lifetime: 'temporary' }
150
- : inPermanent ? { lifetime: 'permanent' } : undefined;
151
- const tempStateDir = inTemporary ? join(temporaryRoot, name) : undefined;
152
- const taskId = !config && tempStateDir ? retainedTaskId(tempStateDir, name) : undefined;
153
- return {
154
- id: name,
155
- lifetime: config ? 'permanent' : inTemporary ? 'temporary' : 'orphan',
156
- configured: Boolean(config),
157
- ...(taskId ? { taskId } : {}),
158
- config: config ? view(config) : tempRole ? view(tempRole) : undefined,
159
- stateRef,
160
- stateHealth,
161
- configuredBackend: intended,
162
- detectedBackend: detected,
163
- compatibility: { compatible: true },
164
- problems,
165
- lineage: tempStateDir ? readSpawnLineage(tempStateDir) : undefined,
166
- };
167
- });
133
+ return {
134
+ temporaryRoot, configProblem,
135
+ permanent: new Set(safeDirs(permanentRoot, strict)),
136
+ temporary: new Set(safeDirs(temporaryRoot, strict)),
137
+ configuredByName: new Map(configured.map(role => [role.name, role])),
138
+ };
168
139
  }
169
- async get(id) {
170
- if (!ROLE_NAME_RE.test(id))
171
- return undefined;
172
- return (await this.list()).find(role => role.id === id);
140
+ async record(scan, name, strict) {
141
+ const { temporaryRoot, configProblem } = scan;
142
+ const config = scan.configuredByName.get(name);
143
+ const inPermanent = scan.permanent.has(name);
144
+ const inTemporary = scan.temporary.has(name);
145
+ const problems = configProblem ? [configProblem] : [];
146
+ let tempRole;
147
+ let stateHealth = inPermanent || inTemporary ? 'present' : 'missing';
148
+ if (inTemporary) {
149
+ const read = snapshot(join(temporaryRoot, name, 'role.yaml'), name);
150
+ tempRole = read.role;
151
+ if (strict && read.problem)
152
+ throw new Error('temporary role snapshot unavailable');
153
+ if (read.problem) {
154
+ problems.push(read.problem);
155
+ stateHealth = 'corrupt';
156
+ }
157
+ }
158
+ if (inPermanent && inTemporary)
159
+ problems.push({ code: 'duplicate_state', severity: 'error', detail: 'both permanent and temporary state exist' });
160
+ const intended = config?.session ?? tempRole?.session;
161
+ const detected = await this.detect(name, intended);
162
+ if (intended && detected !== 'none' && detected !== intended && detected !== 'ambiguous')
163
+ problems.push({
164
+ code: 'backend_mismatch', severity: 'warning',
165
+ detail: `configured ${intended}, detected ${detected}`,
166
+ });
167
+ const stateRef = inTemporary ? { lifetime: 'temporary' }
168
+ : inPermanent ? { lifetime: 'permanent' } : undefined;
169
+ const tempStateDir = inTemporary ? join(temporaryRoot, name) : undefined;
170
+ const taskId = !config && tempStateDir ? retainedTaskId(tempStateDir, name) : undefined;
171
+ return {
172
+ id: name,
173
+ lifetime: config ? 'permanent' : inTemporary ? 'temporary' : 'orphan',
174
+ configured: Boolean(config),
175
+ ...(taskId ? { taskId } : {}),
176
+ config: config ? view(config) : tempRole ? view(tempRole) : undefined,
177
+ stateRef,
178
+ stateHealth,
179
+ configuredBackend: intended,
180
+ detectedBackend: detected,
181
+ compatibility: { compatible: true },
182
+ problems,
183
+ lineage: tempStateDir ? readSpawnLineage(tempStateDir) : undefined,
184
+ };
173
185
  }
174
186
  stateDir(record) {
175
187
  if (!record.stateRef)
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.82",
3
- "buildId": "79b7bd25e769",
4
- "commit": "0836a5d1d38495f31697ef62f286b9aa87442fc0",
2
+ "version": "1.2.0-nightly.84",
3
+ "buildId": "a63a3595510b",
4
+ "commit": "6db38803d52345eada7d87cc364a8a98d1144666",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-20T14:46:35.360Z",
6
+ "builtAt": "2026-10-20T19:57:42.666Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -62,6 +62,8 @@ export interface ResourceWireFrame {
62
62
  export declare const RESOURCE_FRAME_BYTES: number;
63
63
  export declare const RESOURCE_PENDING_BYTES: number;
64
64
  export declare const RESOURCE_ASSEMBLY_BYTES: number;
65
+ /** Minimum spacing of activity-only follow-up captures of one view. */
66
+ export declare const ACTIVITY_CAPTURE_INTERVAL_MS = 1000;
65
67
  /** Lazily yield byte chunks so backpressure never queues an entire large record.
66
68
  * Base64 prevents splitting a Unicode scalar from corrupting JSON/SSE parsing. */
67
69
  export declare function resourceFrames(envelope: ResourceEnvelope): Generator<ResourceWireFrame>;
@@ -109,9 +111,10 @@ export declare class FleetResourceViews {
109
111
  private scope;
110
112
  private project;
111
113
  private configurationInputs;
114
+ private activityCaptureIntervalMs;
112
115
  readonly producerGeneration: `${string}-${string}-${string}-${string}-${string}`;
113
116
  private connections;
114
- constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown>, configurationInputs?: () => readonly string[]);
117
+ constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown>, configurationInputs?: () => readonly string[], activityCaptureIntervalMs?: number);
115
118
  open(response: ServerResponse, owner: string, authorized: () => boolean): {
116
119
  connectionId: `${string}-${string}-${string}-${string}-${string}`;
117
120
  close: () => void;
@@ -23,6 +23,8 @@ export const RESOURCE_FRAME_BYTES = 512 * 1024;
23
23
  export const RESOURCE_PENDING_BYTES = 8 * 1024 * 1024;
24
24
  export const RESOURCE_ASSEMBLY_BYTES = 16 * 1024 * 1024;
25
25
  const CHUNK_BYTES = 256 * 1024;
26
+ /** Minimum spacing of activity-only follow-up captures of one view. */
27
+ export const ACTIVITY_CAPTURE_INTERVAL_MS = 1000;
26
28
  /** Lazily yield byte chunks so backpressure never queues an entire large record.
27
29
  * Base64 prevents splitting a Unicode scalar from corrupting JSON/SSE parsing. */
28
30
  export function* resourceFrames(envelope) {
@@ -187,12 +189,14 @@ export class FleetResourceViews {
187
189
  scope;
188
190
  project;
189
191
  configurationInputs;
192
+ activityCaptureIntervalMs;
190
193
  producerGeneration = randomUUID();
191
194
  connections = new Map();
192
- constructor(scope, project, configurationInputs = () => []) {
195
+ constructor(scope, project, configurationInputs = () => [], activityCaptureIntervalMs = ACTIVITY_CAPTURE_INTERVAL_MS) {
193
196
  this.scope = scope;
194
197
  this.project = project;
195
198
  this.configurationInputs = configurationInputs;
199
+ this.activityCaptureIntervalMs = activityCaptureIntervalMs;
196
200
  }
197
201
  open(response, owner, authorized) {
198
202
  const id = randomUUID(), controller = new AbortController();
@@ -334,7 +338,7 @@ export class FleetResourceViews {
334
338
  return connection;
335
339
  }
336
340
  current(connection, view) { return !connection.controller.signal.aborted && !view.controller.signal.aborted && connection.views.get(view.descriptor.viewId) === view; }
337
- retire(view) { view.controller.abort(); view.rejectInitial(new ResourceBindingError(409, 'resource binding retired')); }
341
+ retire(view) { clearTimeout(view.activityTimer); view.activityTimer = undefined; view.controller.abort(); view.rejectInitial(new ResourceBindingError(409, 'resource binding retired')); }
338
342
  emit(connection, view, kind, payload, source) {
339
343
  return connection.output.send({ protocolVersion: 1, producerGeneration: this.producerGeneration, viewId: view.descriptor.viewId, subscriptionEpoch: view.epoch, baselineId: view.baselineId, sequence: view.sequence + 1, revision: view.revision, kind, payload, ...(source ? { source } : {}) }, () => { view.sequence++; if (source || kind === 'ready')
340
344
  view.qualifiedAt = Date.now(); });
@@ -388,10 +392,10 @@ export class FleetResourceViews {
388
392
  return false;
389
393
  // Only classify known activity writes under the configured role roots.
390
394
  // Parent/unknown events and every other file retain full invalidation.
391
- // The canonical web producer atomically replaces hashed checkpoint files.
392
- // These delivery acknowledgements are not role/task/config membership;
393
- // retain full wakes without requiring an outbox-quiet capture window.
394
- if (structural && /^[a-f0-9]{64}\.json$/.test(relative(resolve(stateRoot(), 'web', 'notification-outboxes'), path)))
395
+ // The canonical web producer atomically replaces hashed checkpoint files,
396
+ // once per ledger advance of any role. No projection reads these delivery
397
+ // acknowledgements, so they neither fence a capture nor wake a follow-up.
398
+ if (/^[a-f0-9]{64}\.json$/.test(relative(resolve(stateRoot(), 'web', 'notification-outboxes'), path)))
395
399
  return false;
396
400
  for (const root of roots) {
397
401
  const parts = relative(resolve(root), path).split(sep);
@@ -421,9 +425,28 @@ export class FleetResourceViews {
421
425
  }
422
426
  return true;
423
427
  }
424
- schedule(connection, view) {
428
+ schedule(connection, view, due = false) {
425
429
  if (!this.current(connection, view) || view.failed)
426
430
  return;
431
+ // Agent activity (ledger appends, status files) changes previews and status
432
+ // continuously while an agent works. Without a structural change since the
433
+ // latest capture began, coalesce those wakes into one trailing capture per
434
+ // interval instead of re-projecting every role after every append.
435
+ const wait = !due && view.captureSource !== view.source && view.capturedAt !== undefined
436
+ && view.captureSource.snapshot().position === view.capturedStructure
437
+ ? this.activityCaptureIntervalMs - (Date.now() - view.capturedAt) : 0;
438
+ if (wait > 0) {
439
+ view.scheduledPosition = view.source.snapshot().position;
440
+ view.dirty = true;
441
+ if (!view.running && !view.activityTimer) {
442
+ // The trailing capture runs when its timer fires, whatever the clock reads.
443
+ view.activityTimer = setTimeout(() => { view.activityTimer = undefined; this.schedule(connection, view, true); }, Math.min(wait, this.activityCaptureIntervalMs));
444
+ view.activityTimer.unref?.();
445
+ }
446
+ return;
447
+ }
448
+ clearTimeout(view.activityTimer);
449
+ view.activityTimer = undefined;
427
450
  view.scheduledPosition = view.source.snapshot().position;
428
451
  view.dirty = true;
429
452
  if (view.running)
@@ -446,6 +469,8 @@ export class FleetResourceViews {
446
469
  if (!view.dirty || !this.current(connection, view))
447
470
  return;
448
471
  view.dirty = false;
472
+ view.capturedAt = Date.now();
473
+ view.capturedStructure = view.captureSource.snapshot().position;
449
474
  let payload, source;
450
475
  for (let attempt = 0; attempt < 3; attempt++) {
451
476
  view.controller.signal.throwIfAborted();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.82",
3
+ "version": "1.2.0-nightly.84",
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",