@ours.network/fleet 2.2.1 → 2.2.3

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.
@@ -7,39 +7,155 @@ const HISTORICAL_KINDS = ['file.attached', 'fleet.task_created', 'prompt.admitte
7
7
  export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
8
8
  return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
9
9
  }
10
- /** Newest-first lines of a file, read in chunks from its end; `visit` returns false to stop. */
11
- async function readLinesBackward(path, visit, signal) {
10
+ // A seek attempt is transactional: its caller discards all collected rows and
11
+ // performs one ordinary scan if a probe or the file identity is no longer safe.
12
+ class SeekFallback extends Error {
13
+ }
14
+ const stamp = (info) => `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
15
+ /** Strict lower bound in an admitted monotonic writer segment, on this FD only. */
16
+ async function seekBefore(file, size, before, signal) {
17
+ const read = async (position, length) => {
18
+ signal?.throwIfAborted();
19
+ const bytes = Buffer.alloc(length);
20
+ const { bytesRead } = await file.read(bytes, 0, length, position);
21
+ signal?.throwIfAborted();
22
+ if (bytesRead !== length)
23
+ throw new SeekFallback('segment changed during seek');
24
+ return bytes;
25
+ };
26
+ if (!size)
27
+ return 0;
28
+ // An active partial append is not a safe boundary, even if the first event
29
+ // would let us skip this whole segment.
30
+ if ((await read(size - 1, 1))[0] !== 10)
31
+ throw new SeekFallback('partial segment');
32
+ const record = async (position) => {
33
+ let start = position, end = position;
34
+ const left = [], right = [];
35
+ while (start > 0) {
36
+ const length = Math.min(4096, start), chunk = await read(start - length, length);
37
+ const newline = chunk.lastIndexOf(10);
38
+ left.push(chunk.subarray(newline + 1));
39
+ start -= length;
40
+ if (newline >= 0) {
41
+ start += newline + 1;
42
+ break;
43
+ }
44
+ }
45
+ while (end < size) {
46
+ const chunk = await read(end, Math.min(4096, size - end)), newline = chunk.indexOf(10);
47
+ right.push(newline < 0 ? chunk : chunk.subarray(0, newline));
48
+ end += newline < 0 ? chunk.length : newline + 1;
49
+ if (newline >= 0)
50
+ break;
51
+ }
52
+ let event;
53
+ try {
54
+ event = JSON.parse(Buffer.concat([...left.reverse(), ...right]).toString('utf8'));
55
+ }
56
+ catch {
57
+ throw new SeekFallback('unreadable seek record');
58
+ }
59
+ if (!event || event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq) || event.seq <= 0)
60
+ throw new SeekFallback('legacy seek record');
61
+ return { start, end, seq: event.seq };
62
+ };
63
+ const first = await record(0);
64
+ if (first.seq >= before)
65
+ return 0;
66
+ let low = first.end, high = size, lower = first.seq, upper = Infinity;
67
+ while (low < high) {
68
+ const found = await record(Math.floor((low + high) / 2));
69
+ if (found.start < low || found.end > high || found.seq <= lower || found.seq >= upper)
70
+ throw new SeekFallback('non-monotonic seek record');
71
+ if (found.seq < before) {
72
+ low = found.end;
73
+ lower = found.seq;
74
+ }
75
+ else {
76
+ high = found.start;
77
+ upper = found.seq;
78
+ }
79
+ }
80
+ return low;
81
+ }
82
+ /** Newest-first lines; optional finite-before seek is only for a healthy writer. */
83
+ async function readLinesBackward(path, visit, signal, seek) {
12
84
  signal?.throwIfAborted();
13
- const file = await open(path, 'r');
85
+ let file;
86
+ try {
87
+ file = await open(path, 'r');
88
+ }
89
+ catch (error) {
90
+ signal?.throwIfAborted();
91
+ if (seek)
92
+ throw new SeekFallback('segment disappeared');
93
+ throw error;
94
+ }
14
95
  try {
15
- let position = (await file.stat()).size;
16
- let carry = Buffer.alloc(0);
17
- while (position > 0) {
96
+ const initial = await file.stat(), key = stamp(initial);
97
+ let position = seek ? await seekBefore(file, initial.size, seek.before, signal) : initial.size;
98
+ let carry = Buffer.alloc(0), more = true;
99
+ scan: while (position > 0) {
18
100
  signal?.throwIfAborted();
19
101
  const size = Math.min(256 * 1024, position);
20
102
  position -= size;
21
103
  const chunk = Buffer.alloc(size);
22
- await file.read(chunk, 0, size, position);
104
+ const { bytesRead } = await file.read(chunk, 0, size, position);
23
105
  signal?.throwIfAborted();
24
- let buffer = Buffer.concat([chunk, carry]);
106
+ if (seek && bytesRead !== size)
107
+ throw new SeekFallback('short segment read');
108
+ const buffer = Buffer.concat([chunk.subarray(0, bytesRead), carry]);
25
109
  let end = buffer.length;
26
110
  for (let i = buffer.length - 1; i >= 0; i--) {
27
111
  if (buffer[i] !== 10)
28
112
  continue;
29
- if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end)))
30
- return false;
113
+ if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end))) {
114
+ more = false;
115
+ break scan;
116
+ }
31
117
  end = i;
32
118
  }
33
119
  carry = buffer.subarray(0, end);
34
120
  }
35
- if (carry.length && !visit(carry.toString('utf8')))
36
- return false;
37
- return true;
121
+ if (more && carry.length && !visit(carry.toString('utf8')))
122
+ more = false;
123
+ if (seek) {
124
+ signal?.throwIfAborted();
125
+ const current = await stat(path).catch(() => undefined);
126
+ if (stamp(await file.stat()) !== key || !current || stamp(current) !== key)
127
+ throw new SeekFallback('segment changed during read');
128
+ seek.checked.push({ path, key });
129
+ }
130
+ return more;
131
+ }
132
+ catch (error) {
133
+ signal?.throwIfAborted();
134
+ if (seek && !(error instanceof SeekFallback))
135
+ throw new SeekFallback('seek read unavailable');
136
+ throw error;
38
137
  }
39
138
  finally {
40
139
  await file.close();
41
140
  }
42
141
  }
142
+ /** Existing writer provenance, never a substitute for the captured barrier. */
143
+ async function seekManifest(dir, files, signal) {
144
+ try {
145
+ signal?.throwIfAborted();
146
+ const raw = await readFile(join(dir, 'manifest.json'), { encoding: 'utf8', signal }), value = JSON.parse(raw);
147
+ if (value.schemaVersion !== 1 || !Number.isSafeInteger(value.nextSeq) || value.nextSeq < 1
148
+ || !Array.isArray(value.segments) || value.segments.length !== files.length
149
+ || value.segments.some((name, i) => name !== files[files.length - 1 - i])
150
+ || (value.failedGenerations !== undefined && (!Array.isArray(value.failedGenerations) || value.failedGenerations.length > 0)))
151
+ return;
152
+ return raw;
153
+ }
154
+ catch {
155
+ signal?.throwIfAborted();
156
+ return;
157
+ }
158
+ }
43
159
  // Rotated segments never change, so their last cursor is reused until the file itself changes.
44
160
  const lastSeqs = new Map();
45
161
  /** The last event cursor in a segment, read from its tail; undefined when none is readable. */
@@ -72,6 +188,17 @@ async function lastSeq(path) {
72
188
  * `nextCursor` is the newest ledger position seen, for forward `after` paging.
73
189
  */
74
190
  export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId, signal) {
191
+ try {
192
+ return await tailPage(stateDir, request, snapshot, resumedSessionId, signal, true);
193
+ }
194
+ catch (error) {
195
+ signal?.throwIfAborted();
196
+ if (!(error instanceof SeekFallback))
197
+ throw error;
198
+ return tailPage(stateDir, request, snapshot, resumedSessionId, signal, false);
199
+ }
200
+ }
201
+ async function tailPage(stateDir, request, snapshot, resumedSessionId, signal, allowSeek) {
75
202
  signal?.throwIfAborted();
76
203
  const before = request.before === undefined ? Infinity : Number(request.before);
77
204
  if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
@@ -93,6 +220,11 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
93
220
  // generation on every open. Legacy/incomplete snapshots still need context.
94
221
  const complete = !snapshot.observationFailure && Number.isSafeInteger(barrier) && barrier >= 0
95
222
  && Number.isSafeInteger(observation.stateRevision) && observation.stateRevision >= 0;
223
+ const dir = join(stateDir, '.conversation');
224
+ const manifest = allowSeek && before !== Infinity && complete && !snapshot.historyDegraded
225
+ ? await seekManifest(dir, files, signal) : undefined;
226
+ const seek = manifest === undefined ? undefined : { before, checked: [] };
227
+ let previousSeq = Infinity;
96
228
  const needsCapabilities = !complete || !Array.isArray(observation.commands);
97
229
  const pending = new Set(complete && Array.isArray(observation.pendingPermissions)
98
230
  && snapshot.pendingPermissionIds.every(id => observation.pendingPermissions.some(permission => permission.permissionId === id))
@@ -117,9 +249,16 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
117
249
  event = JSON.parse(line);
118
250
  }
119
251
  catch {
252
+ if (seek)
253
+ throw new SeekFallback('unreadable page record');
120
254
  return true;
121
255
  }
122
256
  const seq = Number(event.seq);
257
+ if (seek) {
258
+ if (event.schemaVersion !== 1 || !Number.isSafeInteger(seq) || seq <= 0 || seq >= previousSeq)
259
+ throw new SeekFallback('non-monotonic page record');
260
+ previousSeq = seq;
261
+ }
123
262
  if (!Number.isSafeInteger(seq))
124
263
  return true;
125
264
  if (barrier !== undefined && seq > barrier)
@@ -142,10 +281,22 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
142
281
  hasOlder = true;
143
282
  }
144
283
  return !hasOlder || wantsContext();
145
- }, signal);
284
+ }, signal, seek);
146
285
  if (!more)
147
286
  break;
148
287
  }
288
+ if (seek) {
289
+ signal?.throwIfAborted();
290
+ const currentFiles = (await readdir(dir).catch(() => { throw new SeekFallback('ledger disappeared'); })).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort().reverse();
291
+ if (await seekManifest(dir, currentFiles, signal) !== manifest || currentFiles.join() !== files.join())
292
+ throw new SeekFallback('segments rotated during page');
293
+ for (const checked of seek.checked) {
294
+ signal?.throwIfAborted();
295
+ const current = await stat(checked.path).catch(() => undefined);
296
+ if (!current || stamp(current) !== checked.key)
297
+ throw new SeekFallback('segment replaced during page');
298
+ }
299
+ }
149
300
  events.reverse();
150
301
  context.reverse();
151
302
  return { events, snapshot, hasOlder, context, hasMore: false,
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.2.1",
3
- "buildId": "31d6e72bc212",
4
- "commit": "14d8f16953e773c6806d9df5661b8a4fe08af2b4",
2
+ "version": "2.2.3",
3
+ "buildId": "3ac91b025945",
4
+ "commit": "576470bf1cd528fb2d517d081358b3e5c667eea3",
5
5
  "dirty": false,
6
- "builtAt": "2026-10-29T07:06:09.148Z",
6
+ "builtAt": "2026-10-29T08:13:26.500Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -11,6 +11,10 @@ export declare class TaskReviewError extends Error {
11
11
  export declare function reviewArtifactMime(name: string): string;
12
12
  export declare function checkArtifactName(name: string): string;
13
13
  export declare function listTaskProgress(taskId: string): TaskProgressEntry[];
14
+ /** Browser snapshot: entry bodies and their saved read state come from one task record. */
15
+ export declare function taskProgressSnapshot(taskId: string): Array<Omit<TaskProgressEntry, 'request_key'> & {
16
+ unread: boolean;
17
+ }>;
14
18
  /**
15
19
  * Append one milestone. Text is kept exactly as written; whitespace-only text is
16
20
  * rejected. A retry with the same author-scoped request_key returns the original
@@ -73,6 +73,16 @@ export function listTaskProgress(taskId) {
73
73
  assertTaskId(taskId);
74
74
  return getTask(taskId).progress ?? [];
75
75
  }
76
+ /** Browser snapshot: entry bodies and their saved read state come from one task record. */
77
+ export function taskProgressSnapshot(taskId) {
78
+ assertTaskId(taskId);
79
+ const task = getTask(taskId);
80
+ const reads = task.contribution_reads;
81
+ const seen = new Set(reads?.progress_read);
82
+ return (task.progress ?? []).map(entry => ({
83
+ ...publicProgressEntry(entry), unread: !!reads && !seen.has(entry.id),
84
+ }));
85
+ }
76
86
  /**
77
87
  * Append one milestone. Text is kept exactly as written; whitespace-only text is
78
88
  * rejected. A retry with the same author-scoped request_key returns the original
@@ -384,13 +384,15 @@ export class FleetResourceViews {
384
384
  // otherwise reserved for an independent task receipt.
385
385
  if (this.scope.configPath && resolve(path) === resolve(this.scope.configPath))
386
386
  return true;
387
- // Adopted Codex history, telemetry and cache commits are not canonical
388
- // Fleet preview inputs. Keep credentials, unknown paths and directory-level
389
- // events conservative, and never override explicit config or role roots.
387
+ // These projections read role config/private status and conversation ledgers,
388
+ // not adopted Codex telemetry or this gateway's auth/audit bookkeeping. Those
389
+ // writes otherwise invalidate every in-flight directory capture on a busy host.
390
+ // Keep explicit config, declared role roots and unknown/ancestor wakes fenced.
390
391
  const roots = this.scope.roots ?? [agentsRoot(), tmpRoot()];
391
- if (descriptor.resource === 'previews'
392
- && /^(?:history\.jsonl|models_cache\.json|(?:thread_history|state|logs|queue|goals)_\d+\.sqlite(?:-wal|-shm|-journal)?)$/.test(relative(resolve(homedir(), '.codex'), path))
393
- && !roots.some(root => { const local = relative(resolve(root), path); return local === '' || (local !== '..' && !local.startsWith('..' + sep)); }))
392
+ const roleProjection = ['directory', 'roles', 'previews'].includes(descriptor.resource);
393
+ const inRoleRoot = roots.some(root => { const local = relative(resolve(root), path); return local === '' || (local !== '..' && !local.startsWith('..' + sep)); });
394
+ if (roleProjection && !inRoleRoot && (/^(?:history\.jsonl|models_cache\.json|(?:thread_history|state|logs|queue|goals)_\d+\.sqlite(?:-wal|-shm|-journal)?)$/.test(relative(resolve(homedir(), '.codex'), path))
395
+ || /^(?:workspace-devices\.db(?:-wal|-shm|-journal)?|audit[/\\]audit\.jsonl)$/.test(relative(resolve(stateRoot(), 'web'), path))))
394
396
  return false;
395
397
  // Preview selection reads role config/state and conversation ledgers, never
396
398
  // task layout run/operation/brief receipts. A worker may commit several of
@@ -27,7 +27,7 @@ import { FleetEventBus } from './events.js';
27
27
  import { ROLE_NAME_RE } from '../config.js';
28
28
  import { TaskListError } from '../rooms-tasks/task-lists.js';
29
29
  import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, TaskStateError } from '../rooms-tasks/task-state.js';
30
- import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
30
+ import { appendTaskProgress, getTaskReview, taskProgressSnapshot, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
31
31
  import { isProvider } from '../subscriptions/store.js';
32
32
  const ONBOARDING_WORK = ['coordination', 'development', 'review'];
33
33
  const statusFor = (code) => ({
@@ -608,7 +608,7 @@ export async function buildWebServer(services, boundary, options = {}) {
608
608
  });
609
609
  app.get('/api/v1/tasks/:id/progress', async (request) => {
610
610
  auth.authenticate(request);
611
- return { progress: (await reviewApi(() => listTaskProgress(request.params.id))).map(publicProgressEntry) };
611
+ return { progress: await reviewApi(() => taskProgressSnapshot(request.params.id)) };
612
612
  });
613
613
  app.post('/api/v1/tasks/:id/progress', async (request, reply) => {
614
614
  auth.authenticate(request, true);
@@ -4,7 +4,10 @@ Every task can carry two optional sections next to its description. Tasks create
4
4
  before this feature, or that never use it, have neither and behave as before.
5
5
 
6
6
  - **Progress** is an append-only, chronological milestone log: free-form text
7
- entries, each with a stable ID, the time it was recorded and its author. It is
7
+ entries, each with a stable ID, the time it was recorded and its author. The
8
+ browser response also includes `unread` per entry from the same task snapshot,
9
+ based on persisted entry-ID acknowledgments. Legacy records without read state
10
+ report all existing entries as read; retry keys remain private. It is
8
11
  for significant verified milestones (root cause found, PR opened with its link,
9
12
  a measured result with its honest scope, final deliverable ready), not for room
10
13
  chat, routine status, handoffs or ordinary review rounds. Entries cannot be
@@ -99,7 +102,7 @@ workspace-device bearer. `GET /api/v1/meta` reports API minor version 1.
99
102
  | Route | Result |
100
103
  | --- | --- |
101
104
  | `GET /tasks`, `GET /tasks/:id` and other task payloads | `progress_count` and `review: {has_markdown, artifact_count, revision, updated_at?}`; `revision` grows with every package change (0 without a package). Bodies are not inlined. |
102
- | `GET /tasks/:id/progress` | `{progress: [{id, at, author, text}]}`, oldest first. |
105
+ | `GET /tasks/:id/progress` | `{progress: [{id, at, author, text, unread}]}`, oldest first. |
103
106
  | `POST /tasks/:id/progress` | `{text, requestKey?}` (or an `Idempotency-Key` header): 201 new, 200 replay, 409 conflicting replay. |
104
107
  | `GET /tasks/:id/review` | `{review: {markdown, updated_at, updated_by, artifacts: [{id, name, mime, size, sha256, added_at, added_by}]}}` or `{review: null}`. |
105
108
  | `PUT /tasks/:id/review` | `{markdown, expectedMarkdown}`; 409 when the Markdown changed meanwhile. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
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",