@ours.network/fleet 0.18.0-nightly.6 → 0.18.0
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 +111 -43
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +9 -2
- package/dist/build-info.json +7 -6
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +83 -7
- package/dist/config.d.ts +11 -3
- package/dist/config.js +40 -15
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +117 -35
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +204 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +74 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/harness-plugins.d.ts +48 -0
- package/dist/harness-plugins.js +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +30 -29
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -3
- package/dist/owner-channel/message-recovery.d.ts +0 -25
- package/dist/owner-channel/message-recovery.js +0 -114
- package/dist/owner-channel/ours-client.d.ts +0 -148
- package/dist/owner-channel/ours-client.js +0 -231
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
4
6
|
import { join } from 'node:path';
|
|
5
7
|
import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
|
|
6
8
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
9
|
+
import { resolveEndpoint } from '../monitor.js';
|
|
7
10
|
import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
|
|
8
11
|
import { VERSION } from '../version.js';
|
|
9
12
|
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
10
|
-
import {
|
|
13
|
+
import { OursMcpClient } from './mcp.js';
|
|
11
14
|
import { ownerNotices, } from './notices.js';
|
|
12
15
|
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
13
16
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
14
|
-
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection,
|
|
17
|
+
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
|
|
15
18
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
16
|
-
import { MessageRecoveryState } from './message-recovery.js';
|
|
17
19
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
18
20
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
19
21
|
const OWNER_UPDATE_MAX_CHARS = 280;
|
|
@@ -26,8 +28,15 @@ const COMMENTARY_MAX_CHARS = 1_600;
|
|
|
26
28
|
const COMMENTARY_MAX_BYTES = 6_400;
|
|
27
29
|
const COMMENTARY_MAX_UPDATES = 32;
|
|
28
30
|
const COMMENTARY_DEDUPE_LIMIT = 512;
|
|
31
|
+
const OWNER_WATCH_STALL_MS = 120_000;
|
|
29
32
|
const OWNER_WATCH_BACKOFF_MAX_MS = 30_000;
|
|
30
|
-
|
|
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;
|
|
31
40
|
/** A relay attempt that failed only because no owner route exists yet. */
|
|
32
41
|
class RelayUnroutableError extends Error {
|
|
33
42
|
}
|
|
@@ -42,7 +51,6 @@ export class OwnerChannel {
|
|
|
42
51
|
authorizations;
|
|
43
52
|
conversations;
|
|
44
53
|
tasks;
|
|
45
|
-
messageRecovery;
|
|
46
54
|
attachmentRecovery;
|
|
47
55
|
attachmentConfig;
|
|
48
56
|
attachmentRoot;
|
|
@@ -51,7 +59,7 @@ export class OwnerChannel {
|
|
|
51
59
|
* (a crash must replay them) but must not be queued twice while live.
|
|
52
60
|
*/
|
|
53
61
|
inFlight = new Set();
|
|
54
|
-
/** Wires already NACKed to the managed agent, so a
|
|
62
|
+
/** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
|
|
55
63
|
relayNacks = new Set();
|
|
56
64
|
/**
|
|
57
65
|
* fleet.yaml declares the restart baseline; `/comments on|off` changes only
|
|
@@ -62,6 +70,7 @@ export class OwnerChannel {
|
|
|
62
70
|
commentsBaseline;
|
|
63
71
|
commentsEnabled;
|
|
64
72
|
stopping = false;
|
|
73
|
+
watchProcess;
|
|
65
74
|
watchTask;
|
|
66
75
|
watchAbort;
|
|
67
76
|
drainTask;
|
|
@@ -75,13 +84,12 @@ export class OwnerChannel {
|
|
|
75
84
|
fleetOps;
|
|
76
85
|
constructor(options) {
|
|
77
86
|
this.options = options;
|
|
78
|
-
this.client = options.client ?? new
|
|
87
|
+
this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
79
88
|
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
80
89
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
81
90
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
82
91
|
this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
|
|
83
92
|
this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
|
|
84
|
-
this.messageRecovery = new MessageRecoveryState(join(options.stateDir, '.owner-channel-message-recovery.json'));
|
|
85
93
|
this.attachmentRecovery = new AttachmentRecoveryState(join(options.stateDir, '.owner-channel-attachment-recovery.json'));
|
|
86
94
|
this.attachmentRoot = join(options.stateDir, '.owner-channel-inbox');
|
|
87
95
|
// An absent key is a pre-`comments` configuration, which relayed live
|
|
@@ -100,8 +108,6 @@ export class OwnerChannel {
|
|
|
100
108
|
options.log(`[${options.role}] owner conversation state corrupt; proactive messages disabled`);
|
|
101
109
|
if (!this.tasks.integrity().ok)
|
|
102
110
|
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`);
|
|
105
111
|
if (!this.attachmentRecovery.integrity())
|
|
106
112
|
options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
|
|
107
113
|
}
|
|
@@ -117,16 +123,12 @@ export class OwnerChannel {
|
|
|
117
123
|
const bindStartedAt = now();
|
|
118
124
|
for (;;) {
|
|
119
125
|
try {
|
|
120
|
-
await this.client.
|
|
126
|
+
await this.client.callTool('choose_identity', { name: this.options.config.identity });
|
|
121
127
|
break;
|
|
122
128
|
}
|
|
123
129
|
catch (error) {
|
|
124
|
-
|
|
125
|
-
|
|
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;
|
|
130
|
+
const message = error?.message ?? String(error);
|
|
131
|
+
const liveConflict = /currently bound to another live session/i.test(message);
|
|
130
132
|
if (!this.binder.inherited || !liveConflict
|
|
131
133
|
|| now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
|
|
132
134
|
throw error;
|
|
@@ -148,10 +150,9 @@ export class OwnerChannel {
|
|
|
148
150
|
void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
|
|
149
151
|
}
|
|
150
152
|
this.ready = true;
|
|
153
|
+
this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
|
|
151
154
|
// Do not make role startup wait for an old owner request to finish a turn.
|
|
152
|
-
|
|
153
|
-
// one, so there is no drain-to-tip race.
|
|
154
|
-
this.watchTask = this.watchLoop();
|
|
155
|
+
void this.drain().catch(error => this.logError('initial drain failed', error));
|
|
155
156
|
}
|
|
156
157
|
drain() {
|
|
157
158
|
this.drainRequested = true;
|
|
@@ -168,8 +169,13 @@ export class OwnerChannel {
|
|
|
168
169
|
async close() {
|
|
169
170
|
this.stopping = true;
|
|
170
171
|
this.ready = false;
|
|
172
|
+
const watch = this.watchProcess;
|
|
173
|
+
this.watchProcess = undefined;
|
|
174
|
+
if (watch && watch.exitCode === null)
|
|
175
|
+
watch.kill('SIGTERM');
|
|
171
176
|
this.watchAbort?.abort();
|
|
172
|
-
|
|
177
|
+
if (!this.options.watch)
|
|
178
|
+
await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
|
|
173
179
|
this.watchTask = undefined;
|
|
174
180
|
await this.managementTail;
|
|
175
181
|
try {
|
|
@@ -195,10 +201,13 @@ export class OwnerChannel {
|
|
|
195
201
|
? ' with interruption'
|
|
196
202
|
: event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
|
|
197
203
|
const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
|
|
204
|
+
const permission = event.permissionMode
|
|
205
|
+
? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
|
|
206
|
+
: '';
|
|
198
207
|
const inherited = event.inherited.length
|
|
199
208
|
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
200
209
|
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
201
|
-
+ `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
210
|
+
+ `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
202
211
|
});
|
|
203
212
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
204
213
|
return run;
|
|
@@ -211,14 +220,11 @@ export class OwnerChannel {
|
|
|
211
220
|
return { action: request.action, contacts: await this.contacts() };
|
|
212
221
|
case 'contact_invite': {
|
|
213
222
|
this.assertLabel(request.name);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (typeof blob !== 'string' || !blob)
|
|
220
|
-
throw new Error('the ours daemon returned no invite blob');
|
|
221
|
-
return { action: request.action, invite: blob };
|
|
223
|
+
const raw = await this.client.callTool('generate_invite', request.name ? { name: request.name } : {});
|
|
224
|
+
const invite = typeof raw === 'string' ? raw : String(raw?.invite ?? '');
|
|
225
|
+
if (!invite)
|
|
226
|
+
throw new Error('ours-mcp returned no invite');
|
|
227
|
+
return { action: request.action, invite };
|
|
222
228
|
}
|
|
223
229
|
case 'contact_add': {
|
|
224
230
|
if (typeof request.invite !== 'string' || !request.invite)
|
|
@@ -226,19 +232,18 @@ export class OwnerChannel {
|
|
|
226
232
|
if (Buffer.byteLength(request.invite) > 48 * 1024)
|
|
227
233
|
throw new Error('invite exceeds 49152 bytes');
|
|
228
234
|
this.assertLabel(request.name);
|
|
229
|
-
let
|
|
235
|
+
let raw;
|
|
230
236
|
try {
|
|
231
|
-
|
|
237
|
+
raw = await this.client.callTool('add_contact', {
|
|
232
238
|
invite: request.invite, ...(request.name ? { name: request.name } : {}),
|
|
233
239
|
});
|
|
234
240
|
}
|
|
235
241
|
catch {
|
|
236
242
|
// Daemon errors are not allowed to reflect invite material through
|
|
237
243
|
// the control response, CLI stderr, or supervisor logs.
|
|
238
|
-
throw new Error('
|
|
244
|
+
throw new Error('ours-mcp could not accept the contact invite');
|
|
239
245
|
}
|
|
240
|
-
|
|
241
|
-
return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
|
|
246
|
+
return { action: request.action, status: 'pending', contact: this.contact(raw) };
|
|
242
247
|
}
|
|
243
248
|
case 'owner_list':
|
|
244
249
|
return {
|
|
@@ -312,36 +317,33 @@ export class OwnerChannel {
|
|
|
312
317
|
throw new Error('unknown owner-channel management action');
|
|
313
318
|
}
|
|
314
319
|
}
|
|
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
|
-
*/
|
|
321
320
|
async contacts() {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
.map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
|
|
328
|
-
];
|
|
329
|
-
return rows.filter((row) => Boolean(row))
|
|
321
|
+
const raw = await this.client.callTool('list_contacts');
|
|
322
|
+
const values = Array.isArray(raw) ? raw : raw?.contacts;
|
|
323
|
+
if (!Array.isArray(values))
|
|
324
|
+
return [];
|
|
325
|
+
return values.map(value => this.contact(value)).filter((v) => Boolean(v))
|
|
330
326
|
.sort((a, b) => a.cid.localeCompare(b.cid));
|
|
331
327
|
}
|
|
332
|
-
contact(
|
|
333
|
-
|
|
328
|
+
contact(raw) {
|
|
329
|
+
if (!raw || typeof raw !== 'object')
|
|
330
|
+
return undefined;
|
|
331
|
+
const value = raw;
|
|
332
|
+
const cid = String(value.cid ?? value.id ?? value.container_id ?? value.containerId ?? '');
|
|
334
333
|
if (!/^[A-Fa-f0-9]{64}$/.test(cid))
|
|
335
334
|
return undefined;
|
|
336
|
-
const
|
|
337
|
-
const
|
|
335
|
+
const humanRaw = value.human ?? value.root;
|
|
336
|
+
const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
|
|
338
337
|
return {
|
|
339
338
|
cid,
|
|
340
|
-
name: this.safeMetadata(
|
|
341
|
-
status,
|
|
342
|
-
...(
|
|
343
|
-
|
|
344
|
-
...(
|
|
339
|
+
name: this.safeMetadata(value.name ?? value.display_name ?? cid),
|
|
340
|
+
status: this.safeMetadata(value.status ?? 'established'),
|
|
341
|
+
...(typeof value.kind === 'string' ? { kind: this.safeMetadata(value.kind) } : {}),
|
|
342
|
+
...(human ? { human: {
|
|
343
|
+
...(typeof (human.cid ?? human.id) === 'string'
|
|
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) } : {}),
|
|
345
347
|
} } : {}),
|
|
346
348
|
};
|
|
347
349
|
}
|
|
@@ -523,145 +525,60 @@ export class OwnerChannel {
|
|
|
523
525
|
return message;
|
|
524
526
|
}
|
|
525
527
|
async drainAll() {
|
|
526
|
-
// A finite cap protects the supervisor if a broken daemon
|
|
527
|
-
//
|
|
528
|
+
// A finite cap protects the supervisor if a broken daemon repeats unread
|
|
529
|
+
// messages forever. A watch notification will resume draining later.
|
|
528
530
|
for (let pass = 0; pass < 100 && !this.stopping; pass++) {
|
|
529
|
-
const [
|
|
530
|
-
this.
|
|
531
|
-
this.client.
|
|
531
|
+
const [raw, fileResult] = await Promise.all([
|
|
532
|
+
this.client.callTool('get_messages'),
|
|
533
|
+
this.client.callTool('list_incoming_files')
|
|
532
534
|
.catch(error => {
|
|
533
535
|
this.logError('attachment metadata inspection unavailable', error);
|
|
534
536
|
return undefined;
|
|
535
537
|
}),
|
|
536
538
|
]);
|
|
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
|
+
}
|
|
537
549
|
const pending = this.attachmentRecovery.integrity() ? this.attachmentRecovery.list() : [];
|
|
538
|
-
let files = await this.attachmentMetadata(fileResult, pending);
|
|
539
550
|
const pendingWires = new Set(pending.flatMap(item => item.fileWireIds));
|
|
540
551
|
files = files.filter(file => (file.status === 'unread' || pendingWires.has(file.wireId))
|
|
541
552
|
&& !this.state.has(file.wireId));
|
|
542
|
-
if (!
|
|
553
|
+
if (!messages.length && !files.length)
|
|
543
554
|
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 });
|
|
544
565
|
let advanced = false;
|
|
545
566
|
const consumedMessages = new Set();
|
|
546
|
-
const groups = this.attachmentGroups(files,
|
|
567
|
+
const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
|
|
547
568
|
for (const group of groups)
|
|
548
569
|
advanced = await this.handleAttachmentGroup(group) || advanced;
|
|
549
|
-
for (const message of
|
|
570
|
+
for (const message of messages) {
|
|
550
571
|
if (!consumedMessages.has(message))
|
|
551
572
|
advanced = await this.handle(message) || advanced;
|
|
552
573
|
}
|
|
553
|
-
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
if (!advanced && claimed.remaining === 0)
|
|
574
|
+
// Deferred in-flight messages are intentionally visible again until
|
|
575
|
+
// their correlated response is delivered. Do not spin on those replay
|
|
576
|
+
// copies; a new watch event or completion-triggered drain will resume.
|
|
577
|
+
if (!advanced)
|
|
558
578
|
return;
|
|
559
579
|
}
|
|
560
580
|
this.options.log(`[${this.options.role}] owner channel drain capped at 100 batches`);
|
|
561
581
|
}
|
|
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
|
-
}
|
|
665
582
|
attachmentGroups(files, messages, pending, consumed) {
|
|
666
583
|
const groups = [];
|
|
667
584
|
const used = new Set();
|
|
@@ -686,9 +603,9 @@ export class OwnerChannel {
|
|
|
686
603
|
? undefined : messageByWire.get(recovery.originWireId);
|
|
687
604
|
if (caption && this.sender(caption).id !== recovery.contact)
|
|
688
605
|
continue;
|
|
689
|
-
// A managed-agent caption is
|
|
690
|
-
//
|
|
691
|
-
//
|
|
606
|
+
// A managed-agent caption is deferred before retrieval. If recovery sees
|
|
607
|
+
// the processed file before that body is replayed, keep the file reserved
|
|
608
|
+
// by the journal until both halves are present again.
|
|
692
609
|
if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
|
|
693
610
|
continue;
|
|
694
611
|
if (caption)
|
|
@@ -773,14 +690,17 @@ export class OwnerChannel {
|
|
|
773
690
|
this.attachmentRecovery.add(recovery);
|
|
774
691
|
requestDir = await prepareAttachmentDirectory(this.attachmentRoot, requestId);
|
|
775
692
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
776
|
-
const
|
|
693
|
+
const processed = group.files.filter(file => file.status !== 'unread');
|
|
777
694
|
const retrieved = unread.length
|
|
778
|
-
? parseRetrievedAttachments(await this.client.
|
|
695
|
+
? parseRetrievedAttachments(await this.client.callTool('get_files', {
|
|
696
|
+
wire_ids: unread.map(file => file.wireId),
|
|
697
|
+
}), unread)
|
|
779
698
|
: [];
|
|
780
|
-
for (const file of
|
|
699
|
+
for (const file of processed) {
|
|
781
700
|
if (!group.recovery)
|
|
782
|
-
throw new Error('unexpected
|
|
783
|
-
const recoveryPath =
|
|
701
|
+
throw new Error('unexpected processed attachment without recovery route');
|
|
702
|
+
const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
|
|
703
|
+
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
784
704
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
785
705
|
}
|
|
786
706
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -789,17 +709,13 @@ export class OwnerChannel {
|
|
|
789
709
|
outbox = this.outboxDir(originWireId);
|
|
790
710
|
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
|
791
711
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
792
|
-
const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId,
|
|
712
|
+
const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
|
|
793
713
|
interrupt: this.options.config.interrupt,
|
|
794
714
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
795
715
|
origin: { kind: 'owner', requestId,
|
|
796
716
|
...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
|
|
797
717
|
});
|
|
798
|
-
const accepted = this.
|
|
799
|
-
? ownerNotices.receivedInterrupting()
|
|
800
|
-
: queued.queuedBehind > 0
|
|
801
|
-
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
802
|
-
: ownerNotices.receivedStarted();
|
|
718
|
+
const accepted = this.acceptanceNotice(queued);
|
|
803
719
|
handledWireIds.forEach(wire => this.inFlight.add(wire));
|
|
804
720
|
const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
|
|
805
721
|
this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
|
|
@@ -872,17 +788,17 @@ export class OwnerChannel {
|
|
|
872
788
|
this.options.log(`[${this.options.role}] managed-agent relay replay of a delivered wire consumed`);
|
|
873
789
|
}
|
|
874
790
|
else if (error instanceof RelayUnroutableError) {
|
|
875
|
-
// No owner route exists yet. Leave the wire
|
|
876
|
-
// so
|
|
877
|
-
//
|
|
791
|
+
// No owner route exists yet. Leave the wire deferred and unconsumed
|
|
792
|
+
// so the daemon replays it after the first owner contact, and tell
|
|
793
|
+
// the authenticated agent once so the wait is never silent.
|
|
878
794
|
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
879
795
|
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
880
|
-
await this.nackManagedAgent(sender.id, message
|
|
796
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
881
797
|
return false;
|
|
882
798
|
}
|
|
883
799
|
else {
|
|
884
800
|
this.logError('managed-agent message relay refused', error);
|
|
885
|
-
await this.nackManagedAgent(sender.id, message
|
|
801
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
886
802
|
}
|
|
887
803
|
}
|
|
888
804
|
this.state.remember(wireId);
|
|
@@ -918,7 +834,7 @@ export class OwnerChannel {
|
|
|
918
834
|
let queued;
|
|
919
835
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
920
836
|
try {
|
|
921
|
-
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId
|
|
837
|
+
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
|
|
922
838
|
interrupt: this.options.config.interrupt,
|
|
923
839
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
924
840
|
origin: { kind: 'owner', requestId, displayText: text },
|
|
@@ -928,9 +844,9 @@ export class OwnerChannel {
|
|
|
928
844
|
await rm(outbox, { recursive: true, force: true });
|
|
929
845
|
if (error instanceof SessionControlError
|
|
930
846
|
&& error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED) {
|
|
931
|
-
// drainAll
|
|
847
|
+
// drainAll deferred this authenticated message before delivery. The
|
|
932
848
|
// adapter generation is terminating, so leave the wire unhandled and
|
|
933
|
-
// body-free: the resumed owner channel
|
|
849
|
+
// body-free: the resumed owner channel will replay it exactly once.
|
|
934
850
|
this.options.log(`[${this.options.role}] owner request ${requestId.slice(0, 12)} `
|
|
935
851
|
+ `held for adapter resume reason=${ACP_CANCEL_DEADLINE_EXCEEDED}`);
|
|
936
852
|
return false;
|
|
@@ -940,14 +856,7 @@ export class OwnerChannel {
|
|
|
940
856
|
this.state.remember(wireId);
|
|
941
857
|
return true;
|
|
942
858
|
}
|
|
943
|
-
|
|
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();
|
|
859
|
+
const accepted = this.acceptanceNotice(queued);
|
|
951
860
|
this.inFlight.add(wireId);
|
|
952
861
|
const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
|
|
953
862
|
this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
|
|
@@ -1127,9 +1036,7 @@ export class OwnerChannel {
|
|
|
1127
1036
|
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
1128
1037
|
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
1129
1038
|
const nackWire = captionWire ?? group.files[0].wireId;
|
|
1130
|
-
|
|
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;
|
|
1039
|
+
const nackMessage = group.caption ?? { wire_id: nackWire };
|
|
1133
1040
|
let requestDir;
|
|
1134
1041
|
let recovery;
|
|
1135
1042
|
try {
|
|
@@ -1163,14 +1070,17 @@ export class OwnerChannel {
|
|
|
1163
1070
|
this.attachmentRecovery.add(recovery);
|
|
1164
1071
|
requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
|
|
1165
1072
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
1166
|
-
const
|
|
1073
|
+
const processed = group.files.filter(file => file.status !== 'unread');
|
|
1167
1074
|
const retrieved = unread.length
|
|
1168
|
-
? parseRetrievedAttachments(await this.client.
|
|
1075
|
+
? parseRetrievedAttachments(await this.client.callTool('get_files', {
|
|
1076
|
+
wire_ids: unread.map(file => file.wireId),
|
|
1077
|
+
}), unread)
|
|
1169
1078
|
: [];
|
|
1170
|
-
for (const file of
|
|
1079
|
+
for (const file of processed) {
|
|
1171
1080
|
if (!group.recovery)
|
|
1172
|
-
throw new Error('unexpected
|
|
1173
|
-
const recoveryPath =
|
|
1081
|
+
throw new Error('unexpected processed attachment without recovery route');
|
|
1082
|
+
const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
|
|
1083
|
+
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1174
1084
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1175
1085
|
}
|
|
1176
1086
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -1182,9 +1092,9 @@ export class OwnerChannel {
|
|
|
1182
1092
|
if (caption)
|
|
1183
1093
|
await this.send(contact, caption, replyTo);
|
|
1184
1094
|
for (const file of admitted) {
|
|
1185
|
-
await this.client.
|
|
1095
|
+
await this.client.callTool('send_file', {
|
|
1186
1096
|
contact, path: file.path, filename: file.filename,
|
|
1187
|
-
...(replyTo ? {
|
|
1097
|
+
...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
1188
1098
|
});
|
|
1189
1099
|
}
|
|
1190
1100
|
}
|
|
@@ -1221,11 +1131,11 @@ export class OwnerChannel {
|
|
|
1221
1131
|
if (error instanceof RelayUnroutableError) {
|
|
1222
1132
|
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1223
1133
|
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1224
|
-
await this.nackManagedAgent(agent,
|
|
1134
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
|
|
1225
1135
|
return false;
|
|
1226
1136
|
}
|
|
1227
1137
|
this.logError('managed-agent caption/file relay refused', error);
|
|
1228
|
-
await this.nackManagedAgent(agent,
|
|
1138
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1229
1139
|
// Rejection/admission failure and uncertain transport are terminal and
|
|
1230
1140
|
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1231
1141
|
for (const wire of handledWireIds)
|
|
@@ -1261,17 +1171,17 @@ export class OwnerChannel {
|
|
|
1261
1171
|
}
|
|
1262
1172
|
/**
|
|
1263
1173
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
1264
|
-
* to the authenticated agent, while its
|
|
1174
|
+
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
1265
1175
|
* delivery is best-effort — it must never make the failure worse.
|
|
1266
1176
|
*/
|
|
1267
|
-
async nackManagedAgent(contact,
|
|
1177
|
+
async nackManagedAgent(contact, message, wireId, notice) {
|
|
1268
1178
|
if (this.relayNacks.has(wireId))
|
|
1269
1179
|
return;
|
|
1270
1180
|
this.relayNacks.add(wireId);
|
|
1271
1181
|
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
1272
1182
|
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
1273
1183
|
try {
|
|
1274
|
-
await this.send(contact, notice,
|
|
1184
|
+
await this.send(contact, notice, message.wire_id ? wireId : undefined);
|
|
1275
1185
|
}
|
|
1276
1186
|
catch (error) {
|
|
1277
1187
|
this.logError('managed-agent relay NACK delivery failed', error);
|
|
@@ -1332,6 +1242,32 @@ export class OwnerChannel {
|
|
|
1332
1242
|
authorizationIntegrity() {
|
|
1333
1243
|
return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
|
|
1334
1244
|
}
|
|
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
|
+
}
|
|
1335
1271
|
async complete(active, outbox, queued, activityCursor) {
|
|
1336
1272
|
const progressMs = this.options.config.progress_interval_ms;
|
|
1337
1273
|
let lastSeq = activityCursor;
|
|
@@ -1532,7 +1468,7 @@ export class OwnerChannel {
|
|
|
1532
1468
|
throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
|
|
1533
1469
|
return message;
|
|
1534
1470
|
}
|
|
1535
|
-
ownerAttachmentPrompt(sender, wireId, requestId,
|
|
1471
|
+
ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
|
|
1536
1472
|
const lines = [
|
|
1537
1473
|
'[fleet-owner]',
|
|
1538
1474
|
`Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
|
|
@@ -1556,10 +1492,13 @@ export class OwnerChannel {
|
|
|
1556
1492
|
lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
|
|
1557
1493
|
}
|
|
1558
1494
|
}
|
|
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.',
|
|
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.');
|
|
1560
1499
|
return lines.join('\n');
|
|
1561
1500
|
}
|
|
1562
|
-
ownerPrompt(sender, text, wireId
|
|
1501
|
+
ownerPrompt(sender, text, wireId) {
|
|
1563
1502
|
return [
|
|
1564
1503
|
'[fleet-owner]',
|
|
1565
1504
|
`Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
|
|
@@ -1573,10 +1512,13 @@ export class OwnerChannel {
|
|
|
1573
1512
|
] : [
|
|
1574
1513
|
'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
|
|
1575
1514
|
]),
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
'
|
|
1579
|
-
|
|
1515
|
+
// send_file is the only delivery route an agent is given: a tool call either
|
|
1516
|
+
// delivers or reports an error, where a file written to disk does neither.
|
|
1517
|
+
'To send the owner a file — now or later in this turn — call ours `send_file`:',
|
|
1518
|
+
`contact: ${this.options.config.identity}`,
|
|
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.',
|
|
1580
1522
|
'',
|
|
1581
1523
|
text || '(empty message)',
|
|
1582
1524
|
].join('\n');
|
|
@@ -1589,8 +1531,8 @@ export class OwnerChannel {
|
|
|
1589
1531
|
return createHash('sha256').update(wireId).digest('hex');
|
|
1590
1532
|
}
|
|
1591
1533
|
send(contact, text, replyTo) {
|
|
1592
|
-
return this.client.
|
|
1593
|
-
contact, text, ...(replyTo ? {
|
|
1534
|
+
return this.client.callTool('send_message', {
|
|
1535
|
+
contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
1594
1536
|
});
|
|
1595
1537
|
}
|
|
1596
1538
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -1598,11 +1540,11 @@ export class OwnerChannel {
|
|
|
1598
1540
|
.filter(entry => entry.isFile())
|
|
1599
1541
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1600
1542
|
for (const entry of entries) {
|
|
1601
|
-
await this.client.
|
|
1543
|
+
await this.client.callTool('send_file', {
|
|
1602
1544
|
contact,
|
|
1603
1545
|
path: join(outbox, entry.name),
|
|
1604
1546
|
filename: entry.name,
|
|
1605
|
-
|
|
1547
|
+
reply_to_wire_id: replyTo,
|
|
1606
1548
|
});
|
|
1607
1549
|
}
|
|
1608
1550
|
await rm(outbox, { recursive: true, force: true });
|
|
@@ -1625,11 +1567,11 @@ export class OwnerChannel {
|
|
|
1625
1567
|
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
1626
1568
|
}
|
|
1627
1569
|
sender(message) {
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
const id = String(source?.id ?? '');
|
|
1632
|
-
return { id, name: String(source?.name ?? id) };
|
|
1570
|
+
const source = message.from ?? message.sender;
|
|
1571
|
+
if (typeof source === 'string')
|
|
1572
|
+
return { id: source, name: source };
|
|
1573
|
+
const id = String(source?.id ?? message.sender_id ?? '');
|
|
1574
|
+
return { id, name: String(source?.name ?? message.sender_name ?? id) };
|
|
1633
1575
|
}
|
|
1634
1576
|
latestEventSeq(events) {
|
|
1635
1577
|
return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
|
|
@@ -1658,65 +1600,90 @@ export class OwnerChannel {
|
|
|
1658
1600
|
return undefined;
|
|
1659
1601
|
}
|
|
1660
1602
|
async watchLoop() {
|
|
1603
|
+
const endpoint = resolveEndpoint({ ...process.env, ...(this.options.env ?? {}) });
|
|
1604
|
+
const fetch = this.options.watchFetch
|
|
1605
|
+
?? ((url, init) => globalThis.fetch(url, init));
|
|
1661
1606
|
const sleep = this.options.binderDeps?.sleep
|
|
1662
1607
|
?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
1663
1608
|
const restored = this.readWatchState();
|
|
1664
1609
|
let state = restored.state;
|
|
1665
|
-
|
|
1666
|
-
|
|
1610
|
+
// An unreadable cursor is the ONLY reason to restart at the tip. Say so on
|
|
1611
|
+
// the next successful connect, and drain first: everything the lost cursor
|
|
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';
|
|
1667
1615
|
let delayMs = 1_000;
|
|
1668
|
-
let
|
|
1616
|
+
let authFailures = 0;
|
|
1617
|
+
if (recovering)
|
|
1618
|
+
await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
|
|
1669
1619
|
while (!this.stopping) {
|
|
1670
1620
|
const ctrl = new AbortController();
|
|
1671
1621
|
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?.();
|
|
1672
1626
|
try {
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
|
|
1684
|
-
if (this.stopping)
|
|
1627
|
+
const response = await fetch(`${endpoint.url(this.options.config.identity)}?since=${cursor}`, { headers: endpoint.headers, signal: ctrl.signal });
|
|
1628
|
+
if (response.status === 401) {
|
|
1629
|
+
authRejected = true;
|
|
1630
|
+
authFailures++;
|
|
1631
|
+
const at = cursor === 'tip' ? state?.cursor ?? 0 : cursor;
|
|
1632
|
+
if (authFailures >= OWNER_WATCH_AUTH_FATAL_ATTEMPTS) {
|
|
1633
|
+
state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FATAL', true);
|
|
1634
|
+
this.options.log(`[${this.options.role}] owner watch stopped `
|
|
1635
|
+
+ `reason=OWNER_WATCH_AUTH_FATAL after ${authFailures} consecutive HTTP 401 responses; `
|
|
1636
|
+
+ 'owner notifications require re-authorization and will not be retried');
|
|
1685
1637
|
return;
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
// Draining is idempotent at the turn boundary because wire IDs are
|
|
1690
|
-
// recorded before a managed request is dispatched.
|
|
1691
|
-
await this.drain();
|
|
1638
|
+
}
|
|
1639
|
+
state = this.writeWatchState(state, at, 'OWNER_WATCH_AUTH_FAILED', true);
|
|
1640
|
+
throw new Error('OWNER_WATCH_AUTH_FAILED: daemon rejected notification credentials');
|
|
1692
1641
|
}
|
|
1693
|
-
if (!
|
|
1694
|
-
throw new Error(
|
|
1642
|
+
if (!response.ok)
|
|
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)
|
|
1655
|
+
await this.drain();
|
|
1695
1656
|
}
|
|
1696
1657
|
catch (error) {
|
|
1697
1658
|
if (this.stopping)
|
|
1698
1659
|
return;
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1660
|
+
const reason = stalled
|
|
1661
|
+
? 'OWNER_WATCH_STALLED' : 'OWNER_WATCH_STREAM_ERROR';
|
|
1662
|
+
const current = cursor === 'tip' ? state?.cursor ?? 0 : cursor;
|
|
1663
|
+
cursor = current;
|
|
1664
|
+
// Only THIS iteration's auth rejection is already written. A stale
|
|
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);
|
|
1704
1669
|
this.options.log(`[${this.options.role}] owner watch reconnect `
|
|
1705
|
-
+ `reason
|
|
1706
|
-
+ `
|
|
1670
|
+
+ `reason=${authRejected ? 'OWNER_WATCH_AUTH_FAILED' : reason} `
|
|
1671
|
+
+ `delay_ms=${delayMs} cursor=${current}`);
|
|
1707
1672
|
await sleep(delayMs);
|
|
1708
1673
|
delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
|
|
1709
1674
|
}
|
|
1710
1675
|
finally {
|
|
1676
|
+
clearTimeout(timer);
|
|
1711
1677
|
if (this.watchAbort === ctrl)
|
|
1712
1678
|
this.watchAbort = undefined;
|
|
1713
1679
|
}
|
|
1714
1680
|
}
|
|
1715
1681
|
}
|
|
1716
1682
|
/**
|
|
1717
|
-
* `recovered` distinguishes a first-ever start from
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1683
|
+
* `recovered` distinguishes a first-ever start (no state, nothing lost) from a
|
|
1684
|
+
* cursor we HAD and can no longer read. Only the latter is a recovery, and the
|
|
1685
|
+
* caller needs to know because the reason it reports is the only evidence a
|
|
1686
|
+
* durable cursor was ever lost.
|
|
1720
1687
|
*/
|
|
1721
1688
|
readWatchState() {
|
|
1722
1689
|
const path = join(this.options.stateDir, '.owner-channel-watch.json');
|
|
@@ -1724,43 +1691,76 @@ export class OwnerChannel {
|
|
|
1724
1691
|
return { recovered: false };
|
|
1725
1692
|
try {
|
|
1726
1693
|
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
1727
|
-
if (
|
|
1694
|
+
if (value.version !== 1 || !Number.isSafeInteger(value.cursor) || value.cursor < 0
|
|
1728
1695
|
|| !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
|
|
1729
1696
|
|| !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
|
|
1730
1697
|
throw new Error('invalid owner watch state');
|
|
1731
|
-
|
|
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 };
|
|
1698
|
+
return { state: value, recovered: false };
|
|
1744
1699
|
}
|
|
1745
1700
|
catch {
|
|
1746
1701
|
this.options.log(`[${this.options.role}] owner watch `
|
|
1747
|
-
+ 'reason=
|
|
1702
|
+
+ 'reason=OWNER_WATCH_CURSOR_RECOVERED invalid cursor state; draining inbox and starting at tip');
|
|
1748
1703
|
return { recovered: true };
|
|
1749
1704
|
}
|
|
1750
1705
|
}
|
|
1751
|
-
writeWatchState(previous, reason,
|
|
1706
|
+
writeWatchState(previous, cursor, reason, failed, reconnected = false) {
|
|
1752
1707
|
const state = {
|
|
1753
|
-
version:
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
: options.resetFailures ? 0 : previous?.consecutiveFailures ?? 0,
|
|
1708
|
+
version: 1,
|
|
1709
|
+
cursor,
|
|
1710
|
+
reconnects: (previous?.reconnects ?? 0) + (reconnected ? 1 : 0),
|
|
1711
|
+
consecutiveFailures: failed ? (previous?.consecutiveFailures ?? 0) + 1 : 0,
|
|
1758
1712
|
reason,
|
|
1759
1713
|
updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
|
|
1760
1714
|
};
|
|
1761
1715
|
replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
|
|
1762
1716
|
return state;
|
|
1763
1717
|
}
|
|
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
|
}
|