@ours.network/fleet 2.2.2 → 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.2",
3
- "buildId": "b0c5de2e0887",
4
- "commit": "ebe447505d63119b299e3619d12a6399f1e2e49f",
2
+ "version": "2.2.3",
3
+ "buildId": "3ac91b025945",
4
+ "commit": "576470bf1cd528fb2d517d081358b3e5c667eea3",
5
5
  "dirty": false,
6
- "builtAt": "2026-10-29T07:51:29.222Z",
6
+ "builtAt": "2026-10-29T08:13:26.500Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.2.2",
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",