@ours.network/fleet 0.18.1 → 0.19.0-nightly.2
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/README.md +57 -91
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +10 -7
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +6 -15
- package/dist/build-info.json +5 -5
- package/dist/cli.js +12 -37
- package/dist/config.d.ts +8 -6
- package/dist/config.js +46 -30
- package/dist/creation.d.ts +38 -22
- package/dist/creation.js +111 -24
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +44 -95
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +18 -11
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/claude-code.js +11 -200
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +12 -70
- package/dist/harness/types.d.ts +4 -54
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -69
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/monitor.js +1 -1
- package/dist/ops.d.ts +3 -0
- package/dist/ops.js +8 -3
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +29 -30
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/commands.js +89 -0
- package/dist/owner-channel/message-recovery.d.ts +25 -0
- package/dist/owner-channel/message-recovery.js +114 -0
- package/dist/owner-channel/notices.d.ts +0 -7
- package/dist/owner-channel/notices.js +0 -9
- package/dist/owner-channel/ours-client.d.ts +148 -0
- package/dist/owner-channel/ours-client.js +231 -0
- package/dist/rooms-tasks/cli.d.ts +4 -0
- package/dist/rooms-tasks/cli.js +565 -0
- package/dist/rooms-tasks/config.d.ts +15 -0
- package/dist/rooms-tasks/config.js +171 -0
- package/dist/rooms-tasks/cowork-adapter.d.ts +80 -0
- package/dist/rooms-tasks/cowork-adapter.js +48 -0
- package/dist/rooms-tasks/index.d.ts +7 -0
- package/dist/rooms-tasks/index.js +7 -0
- package/dist/rooms-tasks/room-state.d.ts +22 -0
- package/dist/rooms-tasks/room-state.js +117 -0
- package/dist/rooms-tasks/task-state.d.ts +31 -0
- package/dist/rooms-tasks/task-state.js +173 -0
- package/dist/rooms-tasks/templates.d.ts +6 -0
- package/dist/rooms-tasks/templates.js +80 -0
- package/dist/rooms-tasks/types.d.ts +153 -0
- package/dist/rooms-tasks/types.js +20 -0
- package/dist/runner.d.ts +0 -48
- package/dist/runner.js +94 -236
- package/dist/session/acp.d.ts +0 -104
- package/dist/session/acp.js +10 -213
- package/dist/session/conversation-normalizer.d.ts +0 -6
- package/dist/session/conversation-normalizer.js +10 -153
- package/dist/session/conversation-types.d.ts +4 -23
- package/dist/session/types.d.ts +0 -35
- package/dist/spawn.js +26 -33
- package/dist/supervisor/systemd.js +29 -2
- package/dist/watchdog/briefing.js +0 -7
- package/dist/watchdog/run.js +3 -3
- package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +1 -7
- package/dist/worklog.js +39 -191
- package/package.json +3 -1
- package/dist/model-env.d.ts +0 -71
- package/dist/model-env.js +0 -106
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
3
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
3
|
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
5
|
-
import { createInterface } from 'node:readline';
|
|
6
4
|
import { join } from 'node:path';
|
|
7
5
|
import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
|
|
8
6
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
9
|
-
import { resolveEndpoint } from '../monitor.js';
|
|
10
7
|
import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
|
|
11
8
|
import { VERSION } from '../version.js';
|
|
12
9
|
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
13
|
-
import {
|
|
10
|
+
import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
|
|
14
11
|
import { ownerNotices, } from './notices.js';
|
|
15
12
|
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
16
13
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
17
|
-
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
|
|
14
|
+
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
|
|
18
15
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
16
|
+
import { MessageRecoveryState } from './message-recovery.js';
|
|
19
17
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
20
18
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
21
19
|
const OWNER_UPDATE_MAX_CHARS = 280;
|
|
@@ -28,15 +26,8 @@ const COMMENTARY_MAX_CHARS = 1_600;
|
|
|
28
26
|
const COMMENTARY_MAX_BYTES = 6_400;
|
|
29
27
|
const COMMENTARY_MAX_UPDATES = 32;
|
|
30
28
|
const COMMENTARY_DEDUPE_LIMIT = 512;
|
|
31
|
-
const OWNER_WATCH_STALL_MS = 120_000;
|
|
32
29
|
const OWNER_WATCH_BACKOFF_MAX_MS = 30_000;
|
|
33
|
-
|
|
34
|
-
* Credentials that are wrong now are wrong on the next attempt too. Retrying a
|
|
35
|
-
* permanent 401 forever burns the daemon and hides the real fault behind an
|
|
36
|
-
* endless reconnect log, so the watch stops after this many consecutive auth
|
|
37
|
-
* rejections and records a terminal reason instead.
|
|
38
|
-
*/
|
|
39
|
-
const OWNER_WATCH_AUTH_FATAL_ATTEMPTS = 5;
|
|
30
|
+
const OWNER_MESSAGE_BATCH_LIMIT = 200;
|
|
40
31
|
/** A relay attempt that failed only because no owner route exists yet. */
|
|
41
32
|
class RelayUnroutableError extends Error {
|
|
42
33
|
}
|
|
@@ -51,6 +42,7 @@ export class OwnerChannel {
|
|
|
51
42
|
authorizations;
|
|
52
43
|
conversations;
|
|
53
44
|
tasks;
|
|
45
|
+
messageRecovery;
|
|
54
46
|
attachmentRecovery;
|
|
55
47
|
attachmentConfig;
|
|
56
48
|
attachmentRoot;
|
|
@@ -59,7 +51,7 @@ export class OwnerChannel {
|
|
|
59
51
|
* (a crash must replay them) but must not be queued twice while live.
|
|
60
52
|
*/
|
|
61
53
|
inFlight = new Set();
|
|
62
|
-
/** Wires already NACKed to the managed agent, so a
|
|
54
|
+
/** Wires already NACKed to the managed agent, so a history replay stays quiet. */
|
|
63
55
|
relayNacks = new Set();
|
|
64
56
|
/**
|
|
65
57
|
* fleet.yaml declares the restart baseline; `/comments on|off` changes only
|
|
@@ -70,7 +62,6 @@ export class OwnerChannel {
|
|
|
70
62
|
commentsBaseline;
|
|
71
63
|
commentsEnabled;
|
|
72
64
|
stopping = false;
|
|
73
|
-
watchProcess;
|
|
74
65
|
watchTask;
|
|
75
66
|
watchAbort;
|
|
76
67
|
drainTask;
|
|
@@ -84,12 +75,13 @@ export class OwnerChannel {
|
|
|
84
75
|
fleetOps;
|
|
85
76
|
constructor(options) {
|
|
86
77
|
this.options = options;
|
|
87
|
-
this.client = options.client ?? new
|
|
78
|
+
this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
88
79
|
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
89
80
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
90
81
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
91
82
|
this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
|
|
92
83
|
this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
|
|
84
|
+
this.messageRecovery = new MessageRecoveryState(join(options.stateDir, '.owner-channel-message-recovery.json'));
|
|
93
85
|
this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
|
|
94
86
|
this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
|
|
95
87
|
// An absent key is a pre-`comments` configuration, which relayed live
|
|
@@ -108,6 +100,8 @@ export class OwnerChannel {
|
|
|
108
100
|
options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
|
|
109
101
|
if (!this.tasks.integrity().ok)
|
|
110
102
|
options.log(`[${options.role}] owner task state corrupt; proactive reports disabled`);
|
|
103
|
+
if (!this.messageRecovery.integrity())
|
|
104
|
+
options.log(`[${options.role}] owner message recovery state corrupt; message intake disabled`);
|
|
111
105
|
if (!this.attachmentRecovery.integrity())
|
|
112
106
|
options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
|
|
113
107
|
}
|
|
@@ -123,12 +117,16 @@ export class OwnerChannel {
|
|
|
123
117
|
const bindStartedAt = now();
|
|
124
118
|
for (;;) {
|
|
125
119
|
try {
|
|
126
|
-
await this.client.
|
|
120
|
+
await this.client.bindIdentity(this.options.config.identity);
|
|
127
121
|
break;
|
|
128
122
|
}
|
|
129
123
|
catch (error) {
|
|
130
|
-
|
|
131
|
-
|
|
124
|
+
// The predecessor's lease may still be in flight. Only the daemon's
|
|
125
|
+
// own typed verdict may extend the handoff window: matching the
|
|
126
|
+
// wording of an error message would let any other failure whose text
|
|
127
|
+
// happens to say "bound to another live session" — including one
|
|
128
|
+
// relayed from a peer — spin here for the whole timeout.
|
|
129
|
+
const liveConflict = oursErrorCode(error) === OURS_BOUND_ELSEWHERE;
|
|
132
130
|
if (!this.binder.inherited || !liveConflict
|
|
133
131
|
|| now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
|
|
134
132
|
throw error;
|
|
@@ -150,9 +148,10 @@ export class OwnerChannel {
|
|
|
150
148
|
void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
|
|
151
149
|
}
|
|
152
150
|
this.ready = true;
|
|
153
|
-
this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
|
|
154
151
|
// Do not make role startup wait for an old owner request to finish a turn.
|
|
155
|
-
|
|
152
|
+
// watchLoop itself drains before every establishment, including this first
|
|
153
|
+
// one, so there is no drain-to-tip race.
|
|
154
|
+
this.watchTask = this.watchLoop();
|
|
156
155
|
}
|
|
157
156
|
drain() {
|
|
158
157
|
this.drainRequested = true;
|
|
@@ -169,13 +168,8 @@ export class OwnerChannel {
|
|
|
169
168
|
async close() {
|
|
170
169
|
this.stopping = true;
|
|
171
170
|
this.ready = false;
|
|
172
|
-
const watch = this.watchProcess;
|
|
173
|
-
this.watchProcess = undefined;
|
|
174
|
-
if (watch && watch.exitCode === null)
|
|
175
|
-
watch.kill('SIGTERM');
|
|
176
171
|
this.watchAbort?.abort();
|
|
177
|
-
|
|
178
|
-
await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
|
|
172
|
+
await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
|
|
179
173
|
this.watchTask = undefined;
|
|
180
174
|
await this.managementTail;
|
|
181
175
|
try {
|
|
@@ -201,13 +195,10 @@ export class OwnerChannel {
|
|
|
201
195
|
? ' with interruption'
|
|
202
196
|
: event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
|
|
203
197
|
const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
|
|
204
|
-
const permission = event.permissionMode
|
|
205
|
-
? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
|
|
206
|
-
: '';
|
|
207
198
|
const inherited = event.inherited.length
|
|
208
199
|
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
209
200
|
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
210
|
-
+ `(${event.harness}/${event.session}${model}; ${monitor}
|
|
201
|
+
+ `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
211
202
|
});
|
|
212
203
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
213
204
|
return run;
|
|
@@ -220,11 +211,14 @@ export class OwnerChannel {
|
|
|
220
211
|
return { action: request.action, contacts: await this.contacts() };
|
|
221
212
|
case 'contact_invite': {
|
|
222
213
|
this.assertLabel(request.name);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
214
|
+
// The invite is the blob field, not a sentence containing it. The MCP
|
|
215
|
+
// surface answered with "One-time invite for X created (invite_id …).
|
|
216
|
+
// Share this blob out-of-band …:\n<blob>" and the whole sentence was
|
|
217
|
+
// handed out as the invite, so any rewording changed the payload.
|
|
218
|
+
const { blob } = await this.client.generateInvite(request.name);
|
|
219
|
+
if (typeof blob !== 'string' || !blob)
|
|
220
|
+
throw new Error('the ours daemon returned no invite blob');
|
|
221
|
+
return { action: request.action, invite: blob };
|
|
228
222
|
}
|
|
229
223
|
case 'contact_add': {
|
|
230
224
|
if (typeof request.invite !== 'string' || !request.invite)
|
|
@@ -232,18 +226,19 @@ export class OwnerChannel {
|
|
|
232
226
|
if (Buffer.byteLength(request.invite) > 48 * 1024)
|
|
233
227
|
throw new Error('invite exceeds 49152 bytes');
|
|
234
228
|
this.assertLabel(request.name);
|
|
235
|
-
let
|
|
229
|
+
let added;
|
|
236
230
|
try {
|
|
237
|
-
|
|
231
|
+
added = await this.client.addContact({
|
|
238
232
|
invite: request.invite, ...(request.name ? { name: request.name } : {}),
|
|
239
233
|
});
|
|
240
234
|
}
|
|
241
235
|
catch {
|
|
242
236
|
// Daemon errors are not allowed to reflect invite material through
|
|
243
237
|
// the control response, CLI stderr, or supervisor logs.
|
|
244
|
-
throw new Error('ours
|
|
238
|
+
throw new Error('the ours daemon could not accept the contact invite');
|
|
245
239
|
}
|
|
246
|
-
|
|
240
|
+
const contact = this.contact(added.cid, added.display, 'pending');
|
|
241
|
+
return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
|
|
247
242
|
}
|
|
248
243
|
case 'owner_list':
|
|
249
244
|
return {
|
|
@@ -317,33 +312,36 @@ export class OwnerChannel {
|
|
|
317
312
|
throw new Error('unknown owner-channel management action');
|
|
318
313
|
}
|
|
319
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* The daemon reports established contacts and pending introductions as two
|
|
317
|
+
* separate collections, so the status is structural rather than a word parsed
|
|
318
|
+
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
319
|
+
* display name.
|
|
320
|
+
*/
|
|
320
321
|
async contacts() {
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
322
|
+
const view = await this.client.listContacts();
|
|
323
|
+
const rows = [
|
|
324
|
+
...(Array.isArray(view?.contacts) ? view.contacts : [])
|
|
325
|
+
.map(row => this.contact(row?.container_id, row?.name, 'established', view)),
|
|
326
|
+
...(Array.isArray(view?.pending) ? view.pending : [])
|
|
327
|
+
.map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
|
|
328
|
+
];
|
|
329
|
+
return rows.filter((row) => Boolean(row))
|
|
326
330
|
.sort((a, b) => a.cid.localeCompare(b.cid));
|
|
327
331
|
}
|
|
328
|
-
contact(
|
|
329
|
-
|
|
330
|
-
return undefined;
|
|
331
|
-
const value = raw;
|
|
332
|
-
const cid = String(value.cid ?? value.id ?? value.container_id ?? value.containerId ?? '');
|
|
332
|
+
contact(cidValue, nameValue, status, view) {
|
|
333
|
+
const cid = String(cidValue ?? '');
|
|
333
334
|
if (!/^[A-Fa-f0-9]{64}$/.test(cid))
|
|
334
335
|
return undefined;
|
|
335
|
-
const
|
|
336
|
-
const
|
|
336
|
+
const root = view?.roots?.[cid];
|
|
337
|
+
const rootCid = String(root?.root_cid ?? '');
|
|
337
338
|
return {
|
|
338
339
|
cid,
|
|
339
|
-
name: this.safeMetadata(
|
|
340
|
-
status
|
|
341
|
-
...(
|
|
342
|
-
|
|
343
|
-
...(
|
|
344
|
-
&& /^[A-Fa-f0-9]{64}$/.test(String(human.cid ?? human.id))
|
|
345
|
-
? { cid: String(human.cid ?? human.id) } : {}),
|
|
346
|
-
...(human.name ? { name: this.safeMetadata(human.name) } : {}),
|
|
340
|
+
name: this.safeMetadata(nameValue ?? cid),
|
|
341
|
+
status,
|
|
342
|
+
...(root ? { human: {
|
|
343
|
+
...(/^[A-Fa-f0-9]{64}$/.test(rootCid) ? { cid: rootCid } : {}),
|
|
344
|
+
...(root.root_name ? { name: this.safeMetadata(root.root_name) } : {}),
|
|
347
345
|
} } : {}),
|
|
348
346
|
};
|
|
349
347
|
}
|
|
@@ -525,60 +523,145 @@ export class OwnerChannel {
|
|
|
525
523
|
return message;
|
|
526
524
|
}
|
|
527
525
|
async drainAll() {
|
|
528
|
-
// A finite cap protects the supervisor if a broken daemon
|
|
529
|
-
//
|
|
526
|
+
// A finite cap protects the supervisor if a broken daemon never advances
|
|
527
|
+
// its oldest-first unread batch. A watch hint will resume draining later.
|
|
530
528
|
for (let pass = 0; pass < 100 && !this.stopping; pass++) {
|
|
531
|
-
const [
|
|
532
|
-
this.
|
|
533
|
-
this.client.
|
|
529
|
+
const [claimed, fileResult] = await Promise.all([
|
|
530
|
+
this.claimMessages(),
|
|
531
|
+
this.client.listIncomingFiles()
|
|
534
532
|
.catch(error => {
|
|
535
533
|
this.logError('attachment metadata inspection unavailable', error);
|
|
536
534
|
return undefined;
|
|
537
535
|
}),
|
|
538
536
|
]);
|
|
539
|
-
const messages = Array.isArray(raw?.messages)
|
|
540
|
-
? raw.messages.filter(message => message && typeof message === 'object')
|
|
541
|
-
: [];
|
|
542
|
-
let files = [];
|
|
543
|
-
try {
|
|
544
|
-
files = parseIncomingAttachments(fileResult);
|
|
545
|
-
}
|
|
546
|
-
catch (error) {
|
|
547
|
-
this.logError('attachment metadata inspection unavailable', error);
|
|
548
|
-
}
|
|
549
537
|
const pending = this.attachmentRecovery.integrity() ? this.attachmentRecovery.list() : [];
|
|
538
|
+
let files = await this.attachmentMetadata(fileResult, pending);
|
|
550
539
|
const pendingWires = new Set(pending.flatMap(item => item.fileWireIds));
|
|
551
540
|
files = files.filter(file => (file.status === 'unread' || pendingWires.has(file.wireId))
|
|
552
541
|
&& !this.state.has(file.wireId));
|
|
553
|
-
if (!messages.length && !files.length)
|
|
542
|
+
if (!claimed.messages.length && !files.length && claimed.remaining === 0)
|
|
554
543
|
return;
|
|
555
|
-
// get_messages marks the batch processed. Requeue allowed, unhandled
|
|
556
|
-
// inputs before executing them so a mid-turn process crash can replay.
|
|
557
|
-
const deferred = messages.filter(message => {
|
|
558
|
-
const wireId = this.wireId(message);
|
|
559
|
-
return wireId && !this.state.has(wireId)
|
|
560
|
-
&& this.acceptedSender(this.sender(message).id)
|
|
561
|
-
&& Number.isInteger(message.msg_id);
|
|
562
|
-
}).map(message => message.msg_id);
|
|
563
|
-
if (deferred.length)
|
|
564
|
-
await this.client.callTool('defer_messages', { msg_ids: deferred });
|
|
565
544
|
let advanced = false;
|
|
566
545
|
const consumedMessages = new Set();
|
|
567
|
-
const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
|
|
546
|
+
const groups = this.attachmentGroups(files, claimed.messages, pending, consumedMessages);
|
|
568
547
|
for (const group of groups)
|
|
569
548
|
advanced = await this.handleAttachmentGroup(group) || advanced;
|
|
570
|
-
for (const message of messages) {
|
|
549
|
+
for (const message of claimed.messages) {
|
|
571
550
|
if (!consumedMessages.has(message))
|
|
572
551
|
advanced = await this.handle(message) || advanced;
|
|
573
552
|
}
|
|
574
|
-
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
553
|
+
this.messageRecovery.pruneHandled(wireId => this.state.has(wireId));
|
|
554
|
+
// Journaled in-flight messages remain history-recoverable until their
|
|
555
|
+
// correlated response is delivered. Do not spin on those copies once the
|
|
556
|
+
// unread SQLite queue itself is empty; completion triggers another drain.
|
|
557
|
+
if (!advanced && claimed.remaining === 0)
|
|
578
558
|
return;
|
|
579
559
|
}
|
|
580
560
|
this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
|
|
581
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Claim the exact oldest unread SQLite batch before marking it read.
|
|
564
|
+
* The journal contains only wire IDs and sequence numbers; bodies remain in
|
|
565
|
+
* the daemon's persistent history and are recovered with getHistoryItem.
|
|
566
|
+
*/
|
|
567
|
+
async claimMessages() {
|
|
568
|
+
if (!this.messageRecovery.integrity())
|
|
569
|
+
throw new Error('message recovery state is corrupt; refusing owner message intake');
|
|
570
|
+
this.messageRecovery.pruneHandled(wireId => this.state.has(wireId));
|
|
571
|
+
const recovered = [];
|
|
572
|
+
for (const claim of this.messageRecovery.list()) {
|
|
573
|
+
const item = await this.client.getHistoryItem(claim.wireId);
|
|
574
|
+
if (!item)
|
|
575
|
+
throw new Error(`journaled owner message ${claim.wireId} is missing from persistent history`);
|
|
576
|
+
recovered.push(this.historyMessage(item, claim));
|
|
577
|
+
}
|
|
578
|
+
const listed = await this.client.listIncomingMessages();
|
|
579
|
+
if (!Array.isArray(listed))
|
|
580
|
+
throw new Error('the ours daemon returned invalid unread message metadata');
|
|
581
|
+
const preflight = listed.slice(0, OWNER_MESSAGE_BATCH_LIMIT);
|
|
582
|
+
const now = Date.now();
|
|
583
|
+
const claims = preflight.map(item => this.messageClaim(item, now));
|
|
584
|
+
const claimKeys = new Set(claims.map(item => `${item.seq}\0${item.wireId}`));
|
|
585
|
+
if (claimKeys.size !== claims.length)
|
|
586
|
+
throw new Error('the ours daemon returned duplicate unread message metadata');
|
|
587
|
+
this.messageRecovery.claim(claims);
|
|
588
|
+
let fresh = [];
|
|
589
|
+
let remaining = 0;
|
|
590
|
+
// SDK batchLimit rejects zero. An empty preflight is a read-only drain.
|
|
591
|
+
if (claims.length) {
|
|
592
|
+
const payload = await this.client.getMessages(claims.length);
|
|
593
|
+
if (!payload || !Array.isArray(payload.messages)
|
|
594
|
+
|| !Number.isSafeInteger(payload.remaining) || payload.remaining < 0)
|
|
595
|
+
throw new Error('the ours daemon returned an invalid claimed message batch');
|
|
596
|
+
fresh = payload.messages.map(message => this.historyMessage(message));
|
|
597
|
+
remaining = payload.remaining;
|
|
598
|
+
const expected = claimKeys;
|
|
599
|
+
const actual = new Set(fresh.map(item => `${item.seq}\0${item.wire_id}`));
|
|
600
|
+
if (fresh.length !== claims.length || actual.size !== fresh.length
|
|
601
|
+
|| actual.size !== expected.size
|
|
602
|
+
|| [...expected].some(item => !actual.has(item)))
|
|
603
|
+
throw new Error('the ours daemon claimed a different message batch than fleet journaled');
|
|
604
|
+
}
|
|
605
|
+
const merged = new Map();
|
|
606
|
+
for (const message of [...recovered, ...fresh]) {
|
|
607
|
+
const wireId = this.wireId(message);
|
|
608
|
+
const previous = merged.get(wireId);
|
|
609
|
+
if (previous && previous.seq !== message.seq)
|
|
610
|
+
throw new Error('persistent message history changed sequence during recovery');
|
|
611
|
+
merged.set(wireId, message);
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
messages: [...merged.values()].sort((a, b) => a.seq - b.seq),
|
|
615
|
+
remaining,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
messageClaim(message, claimedAt) {
|
|
619
|
+
const value = message;
|
|
620
|
+
const wireId = String(value.wire_id ?? '').trim();
|
|
621
|
+
const seq = Number(value.seq);
|
|
622
|
+
if (!wireId || !Number.isSafeInteger(seq) || seq < 1
|
|
623
|
+
|| value.status !== 'unread' || value.inbox_state !== 'unread')
|
|
624
|
+
throw new Error('the ours daemon returned malformed unread message metadata');
|
|
625
|
+
return { wireId, seq, claimedAt };
|
|
626
|
+
}
|
|
627
|
+
historyMessage(message, claim) {
|
|
628
|
+
const value = message;
|
|
629
|
+
const wireId = String(value.wire_id ?? '').trim();
|
|
630
|
+
const seq = Number(value.seq);
|
|
631
|
+
const direction = String(value.direction ?? '');
|
|
632
|
+
if (!wireId || !Number.isSafeInteger(seq) || seq < 1 || direction !== 'in'
|
|
633
|
+
|| (claim && (claim.wireId !== wireId || claim.seq !== seq)))
|
|
634
|
+
throw new Error('persistent owner message metadata mismatched its recovery claim');
|
|
635
|
+
return message;
|
|
636
|
+
}
|
|
637
|
+
async attachmentMetadata(unread, pending) {
|
|
638
|
+
const raw = Array.isArray(unread) ? [...unread] : [];
|
|
639
|
+
const present = new Set(raw.map(file => String(file.wire_id ?? '')));
|
|
640
|
+
for (const recovery of pending) {
|
|
641
|
+
for (const wireId of recovery.fileWireIds) {
|
|
642
|
+
if (present.has(wireId))
|
|
643
|
+
continue;
|
|
644
|
+
const item = await this.client.getFileInfo(wireId);
|
|
645
|
+
if (!item)
|
|
646
|
+
throw new Error(`journaled owner file ${wireId} is missing from persistent history`);
|
|
647
|
+
if (item.wire_id !== wireId || item.direction !== 'in'
|
|
648
|
+
|| item.inbox_state !== 'read' || item.status !== 'read')
|
|
649
|
+
throw new Error(`journaled owner file ${wireId} mismatched persistent history`);
|
|
650
|
+
raw.push(item);
|
|
651
|
+
present.add(wireId);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const files = parseIncomingAttachments(raw);
|
|
655
|
+
const byWire = new Map(files.map(file => [file.wireId, file]));
|
|
656
|
+
for (const recovery of pending) {
|
|
657
|
+
for (const wireId of recovery.fileWireIds) {
|
|
658
|
+
const file = byWire.get(wireId);
|
|
659
|
+
if (!file || file.senderId !== recovery.contact)
|
|
660
|
+
throw new Error(`journaled owner file ${wireId} failed recovery provenance validation`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return files;
|
|
664
|
+
}
|
|
582
665
|
attachmentGroups(files, messages, pending, consumed) {
|
|
583
666
|
const groups = [];
|
|
584
667
|
const used = new Set();
|
|
@@ -603,9 +686,9 @@ export class OwnerChannel {
|
|
|
603
686
|
? undefined : messageByWire.get(recovery.originWireId);
|
|
604
687
|
if (caption && this.sender(caption).id !== recovery.contact)
|
|
605
688
|
continue;
|
|
606
|
-
// A managed-agent caption is
|
|
607
|
-
//
|
|
608
|
-
// by the journal until both halves are present
|
|
689
|
+
// A managed-agent caption is claimed before its inbox row becomes read.
|
|
690
|
+
// If recovery sees a read file before that body is recovered from history,
|
|
691
|
+
// keep the file reserved by the journal until both halves are present.
|
|
609
692
|
if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
|
|
610
693
|
continue;
|
|
611
694
|
if (caption)
|
|
@@ -690,17 +773,14 @@ export class OwnerChannel {
|
|
|
690
773
|
this.attachmentRecovery.add(recovery);
|
|
691
774
|
requestDir = await prepareAttachmentDirectory(this.attachmentRoot, requestId);
|
|
692
775
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
693
|
-
const
|
|
776
|
+
const historyRecovered = group.files.filter(file => file.status !== 'unread');
|
|
694
777
|
const retrieved = unread.length
|
|
695
|
-
? parseRetrievedAttachments(await this.client.
|
|
696
|
-
wire_ids: unread.map(file => file.wireId),
|
|
697
|
-
}), unread)
|
|
778
|
+
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
698
779
|
: [];
|
|
699
|
-
for (const file of
|
|
780
|
+
for (const file of historyRecovered) {
|
|
700
781
|
if (!group.recovery)
|
|
701
|
-
throw new Error('unexpected
|
|
702
|
-
const recoveryPath =
|
|
703
|
-
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
782
|
+
throw new Error('unexpected read attachment without recovery route');
|
|
783
|
+
const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
704
784
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
705
785
|
}
|
|
706
786
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -709,13 +789,17 @@ export class OwnerChannel {
|
|
|
709
789
|
outbox = this.outboxDir(originWireId);
|
|
710
790
|
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
|
711
791
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
712
|
-
const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
|
|
792
|
+
const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
|
|
713
793
|
interrupt: this.options.config.interrupt,
|
|
714
794
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
715
795
|
origin: { kind: 'owner', requestId,
|
|
716
796
|
...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
|
|
717
797
|
});
|
|
718
|
-
const accepted = this.
|
|
798
|
+
const accepted = this.options.config.interrupt
|
|
799
|
+
? ownerNotices.receivedInterrupting()
|
|
800
|
+
: queued.queuedBehind > 0
|
|
801
|
+
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
802
|
+
: ownerNotices.receivedStarted();
|
|
719
803
|
handledWireIds.forEach(wire => this.inFlight.add(wire));
|
|
720
804
|
const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
|
|
721
805
|
this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
|
|
@@ -788,17 +872,17 @@ export class OwnerChannel {
|
|
|
788
872
|
this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
|
|
789
873
|
}
|
|
790
874
|
else if (error instanceof RelayUnroutableError) {
|
|
791
|
-
// No owner route exists yet. Leave the wire
|
|
792
|
-
// so
|
|
793
|
-
// the authenticated agent once so the wait is never silent.
|
|
875
|
+
// No owner route exists yet. Leave the wire journaled and unconsumed
|
|
876
|
+
// so persistent history replays it after the first owner contact, and
|
|
877
|
+
// tell the authenticated agent once so the wait is never silent.
|
|
794
878
|
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
795
879
|
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
796
|
-
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
880
|
+
await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayQueued());
|
|
797
881
|
return false;
|
|
798
882
|
}
|
|
799
883
|
else {
|
|
800
884
|
this.logError('managed-agent message relay refused', error);
|
|
801
|
-
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
885
|
+
await this.nackManagedAgent(sender.id, message.wire_id ? wireId : undefined, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
802
886
|
}
|
|
803
887
|
}
|
|
804
888
|
this.state.remember(wireId);
|
|
@@ -834,7 +918,7 @@ export class OwnerChannel {
|
|
|
834
918
|
let queued;
|
|
835
919
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
836
920
|
try {
|
|
837
|
-
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
|
|
921
|
+
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
|
|
838
922
|
interrupt: this.options.config.interrupt,
|
|
839
923
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
840
924
|
origin: { kind: 'owner', requestId, displayText: text },
|
|
@@ -844,9 +928,9 @@ export class OwnerChannel {
|
|
|
844
928
|
await rm(outbox, { recursive: true, force: true });
|
|
845
929
|
if (error instanceof SessionControlError
|
|
846
930
|
&& error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED) {
|
|
847
|
-
// drainAll
|
|
931
|
+
// drainAll journaled this authenticated message before delivery. The
|
|
848
932
|
// adapter generation is terminating, so leave the wire unhandled and
|
|
849
|
-
// body-free: the resumed owner channel
|
|
933
|
+
// body-free: the resumed owner channel recovers it exactly once.
|
|
850
934
|
this.options.log(`[${this.options.role}] owner request ${requestId.slice(0, 12)} `
|
|
851
935
|
+ `held for adapter resume reason=${ACP_CANCEL_DEADLINE_EXCEEDED}`);
|
|
852
936
|
return false;
|
|
@@ -856,7 +940,14 @@ export class OwnerChannel {
|
|
|
856
940
|
this.state.remember(wireId);
|
|
857
941
|
return true;
|
|
858
942
|
}
|
|
859
|
-
|
|
943
|
+
// Interrupting the live turn does not remove prompts which were already
|
|
944
|
+
// accepted into the ACP queue. Never claim this request is running while
|
|
945
|
+
// the session itself says earlier work remains ahead of it.
|
|
946
|
+
const accepted = queued.queuedBehind > 0
|
|
947
|
+
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
948
|
+
: this.options.config.interrupt
|
|
949
|
+
? ownerNotices.receivedInterrupting()
|
|
950
|
+
: ownerNotices.receivedStarted();
|
|
860
951
|
this.inFlight.add(wireId);
|
|
861
952
|
const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
|
|
862
953
|
this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
|
|
@@ -1036,7 +1127,9 @@ export class OwnerChannel {
|
|
|
1036
1127
|
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
1037
1128
|
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
1038
1129
|
const nackWire = captionWire ?? group.files[0].wireId;
|
|
1039
|
-
|
|
1130
|
+
// A caption whose own wire id is synthetic (msg_id only) must not be echoed
|
|
1131
|
+
// back as a reply reference; a file wire always is a real one.
|
|
1132
|
+
const nackReplyTo = (group.caption ? group.caption.wire_id : nackWire) ? nackWire : undefined;
|
|
1040
1133
|
let requestDir;
|
|
1041
1134
|
let recovery;
|
|
1042
1135
|
try {
|
|
@@ -1070,17 +1163,14 @@ export class OwnerChannel {
|
|
|
1070
1163
|
this.attachmentRecovery.add(recovery);
|
|
1071
1164
|
requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
|
|
1072
1165
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
1073
|
-
const
|
|
1166
|
+
const historyRecovered = group.files.filter(file => file.status !== 'unread');
|
|
1074
1167
|
const retrieved = unread.length
|
|
1075
|
-
? parseRetrievedAttachments(await this.client.
|
|
1076
|
-
wire_ids: unread.map(file => file.wireId),
|
|
1077
|
-
}), unread)
|
|
1168
|
+
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1078
1169
|
: [];
|
|
1079
|
-
for (const file of
|
|
1170
|
+
for (const file of historyRecovered) {
|
|
1080
1171
|
if (!group.recovery)
|
|
1081
|
-
throw new Error('unexpected
|
|
1082
|
-
const recoveryPath =
|
|
1083
|
-
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1172
|
+
throw new Error('unexpected read attachment without recovery route');
|
|
1173
|
+
const recoveryPath = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1084
1174
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1085
1175
|
}
|
|
1086
1176
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -1092,9 +1182,9 @@ export class OwnerChannel {
|
|
|
1092
1182
|
if (caption)
|
|
1093
1183
|
await this.send(contact, caption, replyTo);
|
|
1094
1184
|
for (const file of admitted) {
|
|
1095
|
-
await this.client.
|
|
1185
|
+
await this.client.sendFile({
|
|
1096
1186
|
contact, path: file.path, filename: file.filename,
|
|
1097
|
-
...(replyTo ? {
|
|
1187
|
+
...(replyTo ? { replyToWireId: replyTo } : {}),
|
|
1098
1188
|
});
|
|
1099
1189
|
}
|
|
1100
1190
|
}
|
|
@@ -1131,11 +1221,11 @@ export class OwnerChannel {
|
|
|
1131
1221
|
if (error instanceof RelayUnroutableError) {
|
|
1132
1222
|
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1133
1223
|
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1134
|
-
await this.nackManagedAgent(agent,
|
|
1224
|
+
await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayQueued());
|
|
1135
1225
|
return false;
|
|
1136
1226
|
}
|
|
1137
1227
|
this.logError('managed-agent caption/file relay refused', error);
|
|
1138
|
-
await this.nackManagedAgent(agent,
|
|
1228
|
+
await this.nackManagedAgent(agent, nackReplyTo, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1139
1229
|
// Rejection/admission failure and uncertain transport are terminal and
|
|
1140
1230
|
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1141
1231
|
for (const wire of handledWireIds)
|
|
@@ -1171,17 +1261,17 @@ export class OwnerChannel {
|
|
|
1171
1261
|
}
|
|
1172
1262
|
/**
|
|
1173
1263
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
1174
|
-
* to the authenticated agent, while its
|
|
1264
|
+
* to the authenticated agent, while its history replays stay quiet. NACK
|
|
1175
1265
|
* delivery is best-effort — it must never make the failure worse.
|
|
1176
1266
|
*/
|
|
1177
|
-
async nackManagedAgent(contact,
|
|
1267
|
+
async nackManagedAgent(contact, replyTo, wireId, notice) {
|
|
1178
1268
|
if (this.relayNacks.has(wireId))
|
|
1179
1269
|
return;
|
|
1180
1270
|
this.relayNacks.add(wireId);
|
|
1181
1271
|
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
1182
1272
|
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
1183
1273
|
try {
|
|
1184
|
-
await this.send(contact, notice,
|
|
1274
|
+
await this.send(contact, notice, replyTo);
|
|
1185
1275
|
}
|
|
1186
1276
|
catch (error) {
|
|
1187
1277
|
this.logError('managed-agent relay NACK delivery failed', error);
|
|
@@ -1242,32 +1332,6 @@ export class OwnerChannel {
|
|
|
1242
1332
|
authorizationIntegrity() {
|
|
1243
1333
|
return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
|
|
1244
1334
|
}
|
|
1245
|
-
/**
|
|
1246
|
-
* Report what the session actually did with the prompt, not what the config
|
|
1247
|
-
* asked for. `interrupt: true` used to be reported as "your request
|
|
1248
|
-
* interrupted the previous task" unconditionally; the session now answers
|
|
1249
|
-
* whether anything was cancelled, whether the request is queued behind
|
|
1250
|
-
* earlier prompts, or whether it is held until the current task reaches a
|
|
1251
|
-
* safe stopping point. Backends that report no delivery state keep the old
|
|
1252
|
-
* queuedBehind-based wording.
|
|
1253
|
-
*/
|
|
1254
|
-
acceptanceNotice(queued) {
|
|
1255
|
-
switch (queued.delivery) {
|
|
1256
|
-
case 'interrupted': return ownerNotices.receivedInterrupting();
|
|
1257
|
-
case 'deferred': return ownerNotices.receivedDeferred();
|
|
1258
|
-
case 'queued': return ownerNotices.receivedQueued(Math.max(1, queued.queuedBehind));
|
|
1259
|
-
case 'started': return ownerNotices.receivedStarted();
|
|
1260
|
-
default:
|
|
1261
|
-
// Interrupting the live turn does not remove prompts which were already
|
|
1262
|
-
// accepted into the ACP queue. Never claim this request is running while
|
|
1263
|
-
// the session itself says earlier work remains ahead of it.
|
|
1264
|
-
return queued.queuedBehind > 0
|
|
1265
|
-
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
1266
|
-
: this.options.config.interrupt
|
|
1267
|
-
? ownerNotices.receivedInterrupting()
|
|
1268
|
-
: ownerNotices.receivedStarted();
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
1335
|
async complete(active, outbox, queued, activityCursor) {
|
|
1272
1336
|
const progressMs = this.options.config.progress_interval_ms;
|
|
1273
1337
|
let lastSeq = activityCursor;
|
|
@@ -1468,7 +1532,7 @@ export class OwnerChannel {
|
|
|
1468
1532
|
throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
|
|
1469
1533
|
return message;
|
|
1470
1534
|
}
|
|
1471
|
-
ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
|
|
1535
|
+
ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
|
|
1472
1536
|
const lines = [
|
|
1473
1537
|
'[fleet-owner]',
|
|
1474
1538
|
`Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
|
|
@@ -1492,13 +1556,10 @@ export class OwnerChannel {
|
|
|
1492
1556
|
lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
|
|
1493
1557
|
}
|
|
1494
1558
|
}
|
|
1495
|
-
lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.',
|
|
1496
|
-
// send_file is the only delivery route an agent is given: a tool call either
|
|
1497
|
-
// delivers or reports an error, where a file written to disk does neither.
|
|
1498
|
-
'To send the owner a file — now or later in this turn — call ours `send_file`:', `contact: ${this.options.config.identity}`, 'and the path of the finished file. Fleet routes it to the authenticated owner.', 'A file written anywhere else is not delivered and nothing will report that it was not.', 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.');
|
|
1559
|
+
lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.', `To attach response files, write regular files only to: ${outbox}`);
|
|
1499
1560
|
return lines.join('\n');
|
|
1500
1561
|
}
|
|
1501
|
-
ownerPrompt(sender, text, wireId) {
|
|
1562
|
+
ownerPrompt(sender, text, wireId, outbox) {
|
|
1502
1563
|
return [
|
|
1503
1564
|
'[fleet-owner]',
|
|
1504
1565
|
`Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
|
|
@@ -1512,13 +1573,10 @@ export class OwnerChannel {
|
|
|
1512
1573
|
] : [
|
|
1513
1574
|
'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
|
|
1514
1575
|
]),
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
'
|
|
1518
|
-
|
|
1519
|
-
'and the path of the finished file. Fleet routes it to the authenticated owner.',
|
|
1520
|
-
'A file written anywhere else is not delivered and nothing will report that it was not.',
|
|
1521
|
-
'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.',
|
|
1576
|
+
'To attach files to your response, copy each finished file directly into this fleet outbox:',
|
|
1577
|
+
outbox,
|
|
1578
|
+
'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
|
|
1579
|
+
'Use descriptive unique filenames. Put nothing there that the owner did not request or should not receive.',
|
|
1522
1580
|
'',
|
|
1523
1581
|
text || '(empty message)',
|
|
1524
1582
|
].join('\n');
|
|
@@ -1531,8 +1589,8 @@ export class OwnerChannel {
|
|
|
1531
1589
|
return createHash('sha256').update(wireId).digest('hex');
|
|
1532
1590
|
}
|
|
1533
1591
|
send(contact, text, replyTo) {
|
|
1534
|
-
return this.client.
|
|
1535
|
-
contact, text, ...(replyTo ? {
|
|
1592
|
+
return this.client.sendMessage({
|
|
1593
|
+
contact, text, ...(replyTo ? { replyToWireId: replyTo } : {}),
|
|
1536
1594
|
});
|
|
1537
1595
|
}
|
|
1538
1596
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -1540,11 +1598,11 @@ export class OwnerChannel {
|
|
|
1540
1598
|
.filter(entry => entry.isFile())
|
|
1541
1599
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1542
1600
|
for (const entry of entries) {
|
|
1543
|
-
await this.client.
|
|
1601
|
+
await this.client.sendFile({
|
|
1544
1602
|
contact,
|
|
1545
1603
|
path: join(outbox, entry.name),
|
|
1546
1604
|
filename: entry.name,
|
|
1547
|
-
|
|
1605
|
+
replyToWireId: replyTo,
|
|
1548
1606
|
});
|
|
1549
1607
|
}
|
|
1550
1608
|
await rm(outbox, { recursive: true, force: true });
|
|
@@ -1567,11 +1625,11 @@ export class OwnerChannel {
|
|
|
1567
1625
|
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
1568
1626
|
}
|
|
1569
1627
|
sender(message) {
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
const id = String(source?.id ??
|
|
1574
|
-
return { id, name: String(source?.name ??
|
|
1628
|
+
// Authenticated routing data, straight from the daemon's typed envelope.
|
|
1629
|
+
// The id still goes through the CID checks in acceptedSender/isEffectiveOwner.
|
|
1630
|
+
const source = message.from;
|
|
1631
|
+
const id = String(source?.id ?? '');
|
|
1632
|
+
return { id, name: String(source?.name ?? id) };
|
|
1575
1633
|
}
|
|
1576
1634
|
latestEventSeq(events) {
|
|
1577
1635
|
return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
|
|
@@ -1600,90 +1658,65 @@ export class OwnerChannel {
|
|
|
1600
1658
|
return undefined;
|
|
1601
1659
|
}
|
|
1602
1660
|
async watchLoop() {
|
|
1603
|
-
const endpoint = resolveEndpoint({ ...process.env, ...(this.options.env ?? {}) });
|
|
1604
|
-
const fetch = this.options.watchFetch
|
|
1605
|
-
?? ((url, init) => globalThis.fetch(url, init));
|
|
1606
1661
|
const sleep = this.options.binderDeps?.sleep
|
|
1607
1662
|
?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
1608
1663
|
const restored = this.readWatchState();
|
|
1609
1664
|
let state = restored.state;
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
// would have pointed at is still in the inbox, which is the authority.
|
|
1613
|
-
let recovering = restored.recovered;
|
|
1614
|
-
let cursor = state?.cursor ?? 'tip';
|
|
1665
|
+
if (restored.recovered)
|
|
1666
|
+
state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
|
|
1615
1667
|
let delayMs = 1_000;
|
|
1616
|
-
let
|
|
1617
|
-
if (recovering)
|
|
1618
|
-
await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
|
|
1668
|
+
let attempts = 0;
|
|
1619
1669
|
while (!this.stopping) {
|
|
1620
1670
|
const ctrl = new AbortController();
|
|
1621
1671
|
this.watchAbort = ctrl;
|
|
1622
|
-
let stalled = false;
|
|
1623
|
-
let authRejected = false;
|
|
1624
|
-
const timer = setTimeout(() => { stalled = true; ctrl.abort(); }, this.options.watchStallMs ?? OWNER_WATCH_STALL_MS);
|
|
1625
|
-
timer.unref?.();
|
|
1626
1672
|
try {
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1673
|
+
// This drain is unconditional at EVERY establishment. Starting the SDK
|
|
1674
|
+
// stream at 0 then replays notification hints instead of tip-priming,
|
|
1675
|
+
// so mail arriving after the drain but before the first request cannot
|
|
1676
|
+
// fall into a gap. Persistent history plus fleet's body-free claim
|
|
1677
|
+
// journal is authoritative; durable wire-ID dedupe makes hints harmless.
|
|
1678
|
+
await this.drain();
|
|
1679
|
+
if (this.stopping)
|
|
1680
|
+
return;
|
|
1681
|
+
state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
|
|
1682
|
+
attempts++;
|
|
1683
|
+
for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
|
|
1684
|
+
if (this.stopping)
|
|
1637
1685
|
return;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
throw new Error(`daemon returned HTTP ${response.status}`);
|
|
1644
|
-
const body = await response.json();
|
|
1645
|
-
const next = typeof body.cursor === 'number' ? body.cursor : cursor === 'tip' ? 0 : cursor;
|
|
1646
|
-
const reconnect = (state?.consecutiveFailures ?? 0) > 0;
|
|
1647
|
-
state = this.writeWatchState(state, next, recovering ? 'OWNER_WATCH_CURSOR_RECOVERED' : 'OWNER_WATCH_CONNECTED', false, reconnect);
|
|
1648
|
-
recovering = false;
|
|
1649
|
-
authFailures = 0;
|
|
1650
|
-
cursor = next;
|
|
1651
|
-
delayMs = 1_000;
|
|
1652
|
-
// Notification events are content-free hints. The inbox remains the
|
|
1653
|
-
// authority and its wire-level durable dedupe prevents duplicate turns.
|
|
1654
|
-
if ((body.events?.length ?? 0) > 0)
|
|
1686
|
+
state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
|
|
1687
|
+
delayMs = 1_000;
|
|
1688
|
+
// Notification events contain no bodies and are only wake hints.
|
|
1689
|
+
// Draining is idempotent at the turn boundary because wire IDs are
|
|
1690
|
+
// recorded before a managed request is dispatched.
|
|
1655
1691
|
await this.drain();
|
|
1692
|
+
}
|
|
1693
|
+
if (!this.stopping)
|
|
1694
|
+
throw new Error('ours SDK notification stream ended');
|
|
1656
1695
|
}
|
|
1657
1696
|
catch (error) {
|
|
1658
1697
|
if (this.stopping)
|
|
1659
1698
|
return;
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
// AUTH_FAILED from an earlier attempt must never suppress the cursor,
|
|
1666
|
-
// the failure counter, or a later STALLED transition.
|
|
1667
|
-
if (!authRejected)
|
|
1668
|
-
state = this.writeWatchState(state, current, reason, true);
|
|
1699
|
+
// SDK 2 deliberately hides transport status behind its typed stream.
|
|
1700
|
+
// Do not parse error prose to rediscover it: every failure follows the
|
|
1701
|
+
// same capped retry path forever, and the pre-establishment drain makes
|
|
1702
|
+
// that retry correctness-preserving.
|
|
1703
|
+
state = this.writeWatchState(state, 'OWNER_WATCH_STREAM_ERROR', { failed: true });
|
|
1669
1704
|
this.options.log(`[${this.options.role}] owner watch reconnect `
|
|
1670
|
-
+ `reason=${
|
|
1671
|
-
+ `
|
|
1705
|
+
+ `reason=OWNER_WATCH_STREAM_ERROR delay_ms=${delayMs} `
|
|
1706
|
+
+ `failures=${state.consecutiveFailures}: ${this.errorText(error)}`);
|
|
1672
1707
|
await sleep(delayMs);
|
|
1673
1708
|
delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
|
|
1674
1709
|
}
|
|
1675
1710
|
finally {
|
|
1676
|
-
clearTimeout(timer);
|
|
1677
1711
|
if (this.watchAbort === ctrl)
|
|
1678
1712
|
this.watchAbort = undefined;
|
|
1679
1713
|
}
|
|
1680
1714
|
}
|
|
1681
1715
|
}
|
|
1682
1716
|
/**
|
|
1683
|
-
* `recovered` distinguishes a first-ever start
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
* durable cursor was ever lost.
|
|
1717
|
+
* `recovered` distinguishes a first-ever start from unreadable persisted
|
|
1718
|
+
* diagnostics. Notification correctness does not depend on this state: every
|
|
1719
|
+
* establishment drains and then replays SDK hints from offset zero.
|
|
1687
1720
|
*/
|
|
1688
1721
|
readWatchState() {
|
|
1689
1722
|
const path = join(this.options.stateDir, '.owner-channel-watch.json');
|
|
@@ -1691,76 +1724,43 @@ export class OwnerChannel {
|
|
|
1691
1724
|
return { recovered: false };
|
|
1692
1725
|
try {
|
|
1693
1726
|
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
1694
|
-
if (value.version !== 1
|
|
1727
|
+
if ((value.version !== 1 && value.version !== 2)
|
|
1695
1728
|
|| !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
|
|
1696
1729
|
|| !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
|
|
1697
1730
|
throw new Error('invalid owner watch state');
|
|
1698
|
-
|
|
1731
|
+
const reasons = new Set([
|
|
1732
|
+
'OWNER_WATCH_CONNECTING', 'OWNER_WATCH_CONNECTED',
|
|
1733
|
+
'OWNER_WATCH_STREAM_ERROR', 'OWNER_WATCH_STATE_RECOVERED',
|
|
1734
|
+
]);
|
|
1735
|
+
return { state: {
|
|
1736
|
+
version: 2,
|
|
1737
|
+
reconnects: value.reconnects,
|
|
1738
|
+
consecutiveFailures: value.consecutiveFailures,
|
|
1739
|
+
reason: reasons.has(value.reason)
|
|
1740
|
+
? value.reason : 'OWNER_WATCH_CONNECTING',
|
|
1741
|
+
updatedAt: typeof value.updatedAt === 'string'
|
|
1742
|
+
? value.updatedAt : new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
|
|
1743
|
+
}, recovered: false };
|
|
1699
1744
|
}
|
|
1700
1745
|
catch {
|
|
1701
1746
|
this.options.log(`[${this.options.role}] owner watch `
|
|
1702
|
-
+ 'reason=
|
|
1747
|
+
+ 'reason=OWNER_WATCH_STATE_RECOVERED invalid persisted counters; restarting safely');
|
|
1703
1748
|
return { recovered: true };
|
|
1704
1749
|
}
|
|
1705
1750
|
}
|
|
1706
|
-
writeWatchState(previous,
|
|
1751
|
+
writeWatchState(previous, reason, options = {}) {
|
|
1707
1752
|
const state = {
|
|
1708
|
-
version:
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1753
|
+
version: 2,
|
|
1754
|
+
reconnects: (previous?.reconnects ?? 0) + (options.reconnected ? 1 : 0),
|
|
1755
|
+
consecutiveFailures: options.failed
|
|
1756
|
+
? (previous?.consecutiveFailures ?? 0) + 1
|
|
1757
|
+
: options.resetFailures ? 0 : previous?.consecutiveFailures ?? 0,
|
|
1712
1758
|
reason,
|
|
1713
1759
|
updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
|
|
1714
1760
|
};
|
|
1715
1761
|
replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
|
|
1716
1762
|
return state;
|
|
1717
1763
|
}
|
|
1718
|
-
/** Compatibility path for injected child-process tests; production is direct. */
|
|
1719
|
-
async legacyWatchLoop() {
|
|
1720
|
-
let delayMs = 1_000;
|
|
1721
|
-
while (!this.stopping) {
|
|
1722
|
-
try {
|
|
1723
|
-
const child = this.options.watch?.(this.options.config.identity) ?? spawn(this.options.command ?? 'ours-mcp', ['watch', this.options.config.identity], {
|
|
1724
|
-
env: { ...process.env, ...(this.options.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'],
|
|
1725
|
-
});
|
|
1726
|
-
this.watchProcess = child;
|
|
1727
|
-
await new Promise((resolve, reject) => {
|
|
1728
|
-
if (child.pid) {
|
|
1729
|
-
resolve();
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
child.once('spawn', resolve);
|
|
1733
|
-
child.once('error', reject);
|
|
1734
|
-
});
|
|
1735
|
-
createInterface({ input: child.stderr }).on('line', line => this.options.log(`[${this.options.role}] owner watch: ${line}`));
|
|
1736
|
-
delayMs = 1_000;
|
|
1737
|
-
// Drain at every (re)attachment, not only after a future notification:
|
|
1738
|
-
// a failed send/turn leaves the input deferred and may not emit another
|
|
1739
|
-
// watch line by itself.
|
|
1740
|
-
await this.drain();
|
|
1741
|
-
for await (const _line of createInterface({ input: child.stdout })) {
|
|
1742
|
-
if (this.stopping)
|
|
1743
|
-
break;
|
|
1744
|
-
await this.drain();
|
|
1745
|
-
}
|
|
1746
|
-
if (!this.stopping)
|
|
1747
|
-
throw new Error('watch exited');
|
|
1748
|
-
}
|
|
1749
|
-
catch (error) {
|
|
1750
|
-
if (!this.stopping) {
|
|
1751
|
-
this.logError('watch failed; retrying', error);
|
|
1752
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
1753
|
-
delayMs = Math.min(delayMs * 2, 30_000);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
finally {
|
|
1757
|
-
const child = this.watchProcess;
|
|
1758
|
-
this.watchProcess = undefined;
|
|
1759
|
-
if (child && child.exitCode === null)
|
|
1760
|
-
child.kill('SIGTERM');
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
1764
|
errorText(error) {
|
|
1765
1765
|
return error?.message ?? String(error);
|
|
1766
1766
|
}
|