@ours.network/fleet 1.2.0-nightly.78 → 1.2.0-nightly.79
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.
- package/dist/application/conversation-history.d.ts +1 -1
- package/dist/application/conversation-history.js +20 -6
- package/dist/application/session-control.d.ts +2 -0
- package/dist/application/session-control.js +6 -1
- package/dist/build-info.json +4 -4
- package/dist/session/conversation-store.js +7 -0
- package/dist/web/server.js +16 -3
- package/docs/conversation-stream.md +20 -0
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@ export interface ConversationTailPage extends ConversationPageView {
|
|
|
17
17
|
export declare function conversationTailPage(stateDir: string, request: {
|
|
18
18
|
before?: string;
|
|
19
19
|
limit?: number;
|
|
20
|
-
}, snapshot: ConversationPageView['snapshot'], resumedSessionId?: string): Promise<ConversationTailPage>;
|
|
20
|
+
}, snapshot: ConversationPageView['snapshot'], resumedSessionId?: string, signal?: AbortSignal): Promise<ConversationTailPage>;
|
|
21
21
|
/** Read the existing ACP session ledger without opening its writer/recovery store. */
|
|
22
22
|
export declare function resumedConversationPage(stateDir: string, request: {
|
|
23
23
|
after?: string;
|
|
@@ -8,16 +8,19 @@ export function conversationEventVisible(event, sessionGeneration, resumedSessio
|
|
|
8
8
|
return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
|
|
9
9
|
}
|
|
10
10
|
/** Newest-first lines of a file, read in chunks from its end; `visit` returns false to stop. */
|
|
11
|
-
async function readLinesBackward(path, visit) {
|
|
11
|
+
async function readLinesBackward(path, visit, signal) {
|
|
12
|
+
signal?.throwIfAborted();
|
|
12
13
|
const file = await open(path, 'r');
|
|
13
14
|
try {
|
|
14
15
|
let position = (await file.stat()).size;
|
|
15
16
|
let carry = Buffer.alloc(0);
|
|
16
17
|
while (position > 0) {
|
|
18
|
+
signal?.throwIfAborted();
|
|
17
19
|
const size = Math.min(256 * 1024, position);
|
|
18
20
|
position -= size;
|
|
19
21
|
const chunk = Buffer.alloc(size);
|
|
20
22
|
await file.read(chunk, 0, size, position);
|
|
23
|
+
signal?.throwIfAborted();
|
|
21
24
|
let buffer = Buffer.concat([chunk, carry]);
|
|
22
25
|
let end = buffer.length;
|
|
23
26
|
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
@@ -55,7 +58,8 @@ async function lastSeq(path) {
|
|
|
55
58
|
* read backwards from the ledger so opening a long session costs one page.
|
|
56
59
|
* `nextCursor` is the newest ledger position seen, for forward `after` paging.
|
|
57
60
|
*/
|
|
58
|
-
export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId) {
|
|
61
|
+
export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId, signal) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
59
63
|
const before = request.before === undefined ? Infinity : Number(request.before);
|
|
60
64
|
if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
|
|
61
65
|
throw new Error('before must be a ledger cursor');
|
|
@@ -68,12 +72,22 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
|
|
|
68
72
|
const events = [];
|
|
69
73
|
let newest;
|
|
70
74
|
let hasOlder = false;
|
|
71
|
-
const
|
|
72
|
-
const
|
|
75
|
+
const observation = snapshot;
|
|
76
|
+
const barrier = observation.ledgerBarrier;
|
|
77
|
+
// Current supervisors capture these sanitized inventories together with the
|
|
78
|
+
// ledger barrier. The browser uses them directly, including authoritative
|
|
79
|
+
// empty arrays. Looking for their old ledger events would scan the entire
|
|
80
|
+
// generation on every open. Legacy/incomplete snapshots still need context.
|
|
81
|
+
const complete = !snapshot.observationFailure && Number.isSafeInteger(barrier) && barrier >= 0
|
|
82
|
+
&& Number.isSafeInteger(observation.stateRevision) && observation.stateRevision >= 0;
|
|
83
|
+
const needsCapabilities = !complete || !Array.isArray(observation.commands);
|
|
84
|
+
const pending = new Set(complete && Array.isArray(observation.pendingPermissions)
|
|
85
|
+
&& snapshot.pendingPermissionIds.every(id => observation.pendingPermissions.some(permission => permission.permissionId === id))
|
|
86
|
+
? [] : snapshot.pendingPermissionIds);
|
|
73
87
|
let capabilities;
|
|
74
88
|
const context = [];
|
|
75
89
|
// Context only accompanies the newest page; older pages stop as soon as they are full.
|
|
76
|
-
const wantsContext = () => before === Infinity && (!capabilities || pending.size > 0);
|
|
90
|
+
const wantsContext = () => before === Infinity && ((needsCapabilities && !capabilities) || pending.size > 0);
|
|
77
91
|
const generation = `"sessionGeneration":${JSON.stringify(snapshot.sessionGeneration)}`;
|
|
78
92
|
for (const file of files) {
|
|
79
93
|
const more = await readLinesBackward(join(stateDir, '.conversation', file), line => {
|
|
@@ -115,7 +129,7 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
|
|
|
115
129
|
hasOlder = true;
|
|
116
130
|
}
|
|
117
131
|
return !hasOlder || wantsContext();
|
|
118
|
-
});
|
|
132
|
+
}, signal);
|
|
119
133
|
if (!more)
|
|
120
134
|
break;
|
|
121
135
|
}
|
|
@@ -70,6 +70,7 @@ export interface RoleSessionControl {
|
|
|
70
70
|
conversationTail?(request: {
|
|
71
71
|
before?: string;
|
|
72
72
|
limit?: number;
|
|
73
|
+
signal?: AbortSignal;
|
|
73
74
|
}): Promise<ConversationTailPage>;
|
|
74
75
|
submitPromptV2?(request: {
|
|
75
76
|
commandId: string;
|
|
@@ -128,6 +129,7 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
|
|
|
128
129
|
conversationTail(request?: {
|
|
129
130
|
before?: string;
|
|
130
131
|
limit?: number;
|
|
132
|
+
signal?: AbortSignal;
|
|
131
133
|
}): Promise<ConversationTailPage>;
|
|
132
134
|
submitPromptV2(request: {
|
|
133
135
|
commandId: string;
|
|
@@ -98,7 +98,9 @@ export class RoleSessionControlAdapter {
|
|
|
98
98
|
async conversationTail(request = {}) {
|
|
99
99
|
// A replacement while reading disk invalidates both the tail and its snapshot.
|
|
100
100
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
101
|
+
request.signal?.throwIfAborted();
|
|
101
102
|
const live = await this.call('conversation_page', { limit: 1 });
|
|
103
|
+
request.signal?.throwIfAborted();
|
|
102
104
|
const snapshot = await this.snapshot();
|
|
103
105
|
let resumed;
|
|
104
106
|
if (snapshot.backend === 'acp' && snapshot.sessionId) {
|
|
@@ -110,12 +112,15 @@ export class RoleSessionControlAdapter {
|
|
|
110
112
|
}
|
|
111
113
|
let page;
|
|
112
114
|
try {
|
|
113
|
-
page = await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
|
|
115
|
+
page = await conversationTailPage(this.stateDir, request, live.snapshot, resumed, request.signal);
|
|
114
116
|
}
|
|
115
117
|
catch (error) {
|
|
118
|
+
request.signal?.throwIfAborted();
|
|
116
119
|
throw new FleetError('invalid_request', error.message);
|
|
117
120
|
}
|
|
121
|
+
request.signal?.throwIfAborted();
|
|
118
122
|
const check = await this.call('conversation_page', { limit: 1 });
|
|
123
|
+
request.signal?.throwIfAborted();
|
|
119
124
|
if (check.snapshot.sessionGeneration === live.snapshot.sessionGeneration)
|
|
120
125
|
return page;
|
|
121
126
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.79",
|
|
3
|
+
"buildId": "ed6b63d22064",
|
|
4
|
+
"commit": "62a21f66895ff79cd5e5ea190febcfc59611e946",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-19T21:
|
|
6
|
+
"builtAt": "2026-10-19T21:53:54.074Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"service.refresh-v1",
|
|
9
9
|
"managed-cli.codex-reasoning-effort-v1",
|
|
@@ -504,6 +504,13 @@ export class ConversationEventStore {
|
|
|
504
504
|
return this.tail.filter(event => event.seq > after).slice(0, limit);
|
|
505
505
|
const events = [];
|
|
506
506
|
for (const segment of this.segments) {
|
|
507
|
+
// Recovery/append already indexed these single-writer segments. A page
|
|
508
|
+
// after a resumed generation's floor must not reread every older segment
|
|
509
|
+
// merely because the requested cursor is outside the memory tail.
|
|
510
|
+
// Degraded/unknown ranges retain the conservative recovery read path.
|
|
511
|
+
const range = this.segmentRanges.get(segment);
|
|
512
|
+
if (!this._degraded && range && !range.damaged && range.last !== undefined && range.last <= after)
|
|
513
|
+
continue;
|
|
507
514
|
for (const event of this.readSegment(segment)) {
|
|
508
515
|
if (event.seq <= after)
|
|
509
516
|
continue;
|
package/dist/web/server.js
CHANGED
|
@@ -925,7 +925,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
925
925
|
limit: request.query.limit ? Number(request.query.limit) : undefined,
|
|
926
926
|
});
|
|
927
927
|
});
|
|
928
|
-
app.get('/api/v1/roles/:id/conversation', async (request) => {
|
|
928
|
+
app.get('/api/v1/roles/:id/conversation', async (request, reply) => {
|
|
929
929
|
auth.authenticate(request);
|
|
930
930
|
const control = await services.session(request.params.id);
|
|
931
931
|
if (!control.conversationPage)
|
|
@@ -941,8 +941,21 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
941
941
|
const stateDir = role ? services.repository.stateDir(role) : undefined;
|
|
942
942
|
const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
|
|
943
943
|
if (backward) {
|
|
944
|
-
const
|
|
945
|
-
|
|
944
|
+
const owner = new AbortController();
|
|
945
|
+
const cancel = () => owner.abort();
|
|
946
|
+
request.raw.once('aborted', cancel);
|
|
947
|
+
reply.raw.once('close', cancel);
|
|
948
|
+
if (request.raw.aborted || reply.raw.destroyed)
|
|
949
|
+
cancel();
|
|
950
|
+
try {
|
|
951
|
+
const page = await control.conversationTail({ before: request.query.before, limit, signal: owner.signal });
|
|
952
|
+
owner.signal.throwIfAborted();
|
|
953
|
+
return { ...page, events: page.events.map(present), context: page.context.map(present) };
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
request.raw.off('aborted', cancel);
|
|
957
|
+
reply.raw.off('close', cancel);
|
|
958
|
+
}
|
|
946
959
|
}
|
|
947
960
|
const page = await control.conversationPage({ after: request.query.after, limit });
|
|
948
961
|
return { ...page, events: page.events.map(present) };
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Conversation event stream (wire version 1)
|
|
2
2
|
|
|
3
|
+
Initial `conversation?latest=1&limit=200` history uses the captured command and
|
|
4
|
+
pending-permission inventories when the supervisor supplies a complete observation
|
|
5
|
+
snapshot with its revision and ledger barrier. Empty inventories are authoritative.
|
|
6
|
+
This avoids scanning the current generation for old context events after the
|
|
7
|
+
latest page is full. Legacy, failed or incomplete captures retain ledger context
|
|
8
|
+
recovery. Generation checks, resumed-session filtering and event cursors still apply.
|
|
9
|
+
The private snapshot checks also use the store's recovered/appended segment ranges
|
|
10
|
+
to skip healthy segments ending at or before their cursor. A retained prior
|
|
11
|
+
generation therefore does not require rereading its whole prefix merely because
|
|
12
|
+
the current-generation cursor is outside the in-memory tail. Degraded stores and
|
|
13
|
+
unknown or damaged ranges retain conservative reads.
|
|
14
|
+
Older pages can scan intervening newer ledger segments; the event count alone is
|
|
15
|
+
not a disk-read byte bound.
|
|
16
|
+
|
|
17
|
+
Disconnecting a latest/before HTTP history request cancels its disk tail reader
|
|
18
|
+
at asynchronous read boundaries and prevents further orchestration or publication.
|
|
19
|
+
An already admitted private-control RPC retains its existing timeout; disconnect
|
|
20
|
+
does not interrupt the agent or its work. Forward-history reads and passive SSE
|
|
21
|
+
retain their separate lifetimes.
|
|
22
|
+
|
|
3
23
|
`GET /api/v1/roles/:id/conversation/events?expectedGeneration=GENERATION&after=CURSOR`
|
|
4
24
|
uses the existing Fleet route boundary, including `/fleet/api/v1/...` through the
|
|
5
25
|
prefix gateway. Use authenticated `fetch`, then read the response body. Cookies
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.79",
|
|
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",
|