@ours.network/fleet 0.18.0-nightly.5 → 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 +101 -25
- 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 +113 -27
- 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 +28 -17
- package/dist/owner-channel/channel.js +249 -158
- 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/ours-client.d.ts +0 -141
- package/dist/owner-channel/ours-client.js +0 -225
|
@@ -1,17 +1,20 @@
|
|
|
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
19
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
17
20
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
@@ -25,7 +28,15 @@ const COMMENTARY_MAX_CHARS = 1_600;
|
|
|
25
28
|
const COMMENTARY_MAX_BYTES = 6_400;
|
|
26
29
|
const COMMENTARY_MAX_UPDATES = 32;
|
|
27
30
|
const COMMENTARY_DEDUPE_LIMIT = 512;
|
|
31
|
+
const OWNER_WATCH_STALL_MS = 120_000;
|
|
28
32
|
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;
|
|
29
40
|
/** A relay attempt that failed only because no owner route exists yet. */
|
|
30
41
|
class RelayUnroutableError extends Error {
|
|
31
42
|
}
|
|
@@ -59,6 +70,7 @@ export class OwnerChannel {
|
|
|
59
70
|
commentsBaseline;
|
|
60
71
|
commentsEnabled;
|
|
61
72
|
stopping = false;
|
|
73
|
+
watchProcess;
|
|
62
74
|
watchTask;
|
|
63
75
|
watchAbort;
|
|
64
76
|
drainTask;
|
|
@@ -72,7 +84,7 @@ export class OwnerChannel {
|
|
|
72
84
|
fleetOps;
|
|
73
85
|
constructor(options) {
|
|
74
86
|
this.options = options;
|
|
75
|
-
this.client = options.client ?? new
|
|
87
|
+
this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
76
88
|
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
77
89
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
78
90
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
@@ -111,16 +123,12 @@ export class OwnerChannel {
|
|
|
111
123
|
const bindStartedAt = now();
|
|
112
124
|
for (;;) {
|
|
113
125
|
try {
|
|
114
|
-
await this.client.
|
|
126
|
+
await this.client.callTool('choose_identity', { name: this.options.config.identity });
|
|
115
127
|
break;
|
|
116
128
|
}
|
|
117
129
|
catch (error) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// wording of an error message would let any other failure whose text
|
|
121
|
-
// happens to say "bound to another live session" — including one
|
|
122
|
-
// relayed from a peer — spin here for the whole timeout.
|
|
123
|
-
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);
|
|
124
132
|
if (!this.binder.inherited || !liveConflict
|
|
125
133
|
|| now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
|
|
126
134
|
throw error;
|
|
@@ -142,10 +150,9 @@ export class OwnerChannel {
|
|
|
142
150
|
void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
|
|
143
151
|
}
|
|
144
152
|
this.ready = true;
|
|
153
|
+
this.watchTask = this.options.watch ? this.legacyWatchLoop() : this.watchLoop();
|
|
145
154
|
// Do not make role startup wait for an old owner request to finish a turn.
|
|
146
|
-
|
|
147
|
-
// one, so there is no drain-to-tip race.
|
|
148
|
-
this.watchTask = this.watchLoop();
|
|
155
|
+
void this.drain().catch(error => this.logError('initial drain failed', error));
|
|
149
156
|
}
|
|
150
157
|
drain() {
|
|
151
158
|
this.drainRequested = true;
|
|
@@ -162,8 +169,13 @@ export class OwnerChannel {
|
|
|
162
169
|
async close() {
|
|
163
170
|
this.stopping = true;
|
|
164
171
|
this.ready = false;
|
|
172
|
+
const watch = this.watchProcess;
|
|
173
|
+
this.watchProcess = undefined;
|
|
174
|
+
if (watch && watch.exitCode === null)
|
|
175
|
+
watch.kill('SIGTERM');
|
|
165
176
|
this.watchAbort?.abort();
|
|
166
|
-
|
|
177
|
+
if (!this.options.watch)
|
|
178
|
+
await this.watchTask?.catch(error => this.logError('watch shutdown failed', error));
|
|
167
179
|
this.watchTask = undefined;
|
|
168
180
|
await this.managementTail;
|
|
169
181
|
try {
|
|
@@ -189,10 +201,13 @@ export class OwnerChannel {
|
|
|
189
201
|
? ' with interruption'
|
|
190
202
|
: event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
|
|
191
203
|
const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
|
|
204
|
+
const permission = event.permissionMode
|
|
205
|
+
? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
|
|
206
|
+
: '';
|
|
192
207
|
const inherited = event.inherited.length
|
|
193
208
|
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
194
209
|
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
195
|
-
+ `(${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);
|
|
196
211
|
});
|
|
197
212
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
198
213
|
return run;
|
|
@@ -205,14 +220,11 @@ export class OwnerChannel {
|
|
|
205
220
|
return { action: request.action, contacts: await this.contacts() };
|
|
206
221
|
case 'contact_invite': {
|
|
207
222
|
this.assertLabel(request.name);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (typeof blob !== 'string' || !blob)
|
|
214
|
-
throw new Error('the ours daemon returned no invite blob');
|
|
215
|
-
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 };
|
|
216
228
|
}
|
|
217
229
|
case 'contact_add': {
|
|
218
230
|
if (typeof request.invite !== 'string' || !request.invite)
|
|
@@ -220,19 +232,18 @@ export class OwnerChannel {
|
|
|
220
232
|
if (Buffer.byteLength(request.invite) > 48 * 1024)
|
|
221
233
|
throw new Error('invite exceeds 49152 bytes');
|
|
222
234
|
this.assertLabel(request.name);
|
|
223
|
-
let
|
|
235
|
+
let raw;
|
|
224
236
|
try {
|
|
225
|
-
|
|
237
|
+
raw = await this.client.callTool('add_contact', {
|
|
226
238
|
invite: request.invite, ...(request.name ? { name: request.name } : {}),
|
|
227
239
|
});
|
|
228
240
|
}
|
|
229
241
|
catch {
|
|
230
242
|
// Daemon errors are not allowed to reflect invite material through
|
|
231
243
|
// the control response, CLI stderr, or supervisor logs.
|
|
232
|
-
throw new Error('
|
|
244
|
+
throw new Error('ours-mcp could not accept the contact invite');
|
|
233
245
|
}
|
|
234
|
-
|
|
235
|
-
return { action: request.action, status: 'pending', ...(contact ? { contact } : {}) };
|
|
246
|
+
return { action: request.action, status: 'pending', contact: this.contact(raw) };
|
|
236
247
|
}
|
|
237
248
|
case 'owner_list':
|
|
238
249
|
return {
|
|
@@ -306,36 +317,33 @@ export class OwnerChannel {
|
|
|
306
317
|
throw new Error('unknown owner-channel management action');
|
|
307
318
|
}
|
|
308
319
|
}
|
|
309
|
-
/**
|
|
310
|
-
* The daemon reports established contacts and pending introductions as two
|
|
311
|
-
* separate collections, so the status is structural rather than a word parsed
|
|
312
|
-
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
313
|
-
* display name.
|
|
314
|
-
*/
|
|
315
320
|
async contacts() {
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
.map(row => this.contact(row?.container_id, row?.name, 'pending', view)),
|
|
322
|
-
];
|
|
323
|
-
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))
|
|
324
326
|
.sort((a, b) => a.cid.localeCompare(b.cid));
|
|
325
327
|
}
|
|
326
|
-
contact(
|
|
327
|
-
|
|
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 ?? '');
|
|
328
333
|
if (!/^[A-Fa-f0-9]{64}$/.test(cid))
|
|
329
334
|
return undefined;
|
|
330
|
-
const
|
|
331
|
-
const
|
|
335
|
+
const humanRaw = value.human ?? value.root;
|
|
336
|
+
const human = humanRaw && typeof humanRaw === 'object' ? humanRaw : undefined;
|
|
332
337
|
return {
|
|
333
338
|
cid,
|
|
334
|
-
name: this.safeMetadata(
|
|
335
|
-
status,
|
|
336
|
-
...(
|
|
337
|
-
|
|
338
|
-
...(
|
|
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) } : {}),
|
|
339
347
|
} } : {}),
|
|
340
348
|
};
|
|
341
349
|
}
|
|
@@ -520,16 +528,16 @@ export class OwnerChannel {
|
|
|
520
528
|
// A finite cap protects the supervisor if a broken daemon repeats unread
|
|
521
529
|
// messages forever. A watch notification will resume draining later.
|
|
522
530
|
for (let pass = 0; pass < 100 && !this.stopping; pass++) {
|
|
523
|
-
const [
|
|
524
|
-
this.client.
|
|
525
|
-
this.client.
|
|
531
|
+
const [raw, fileResult] = await Promise.all([
|
|
532
|
+
this.client.callTool('get_messages'),
|
|
533
|
+
this.client.callTool('list_incoming_files')
|
|
526
534
|
.catch(error => {
|
|
527
535
|
this.logError('attachment metadata inspection unavailable', error);
|
|
528
536
|
return undefined;
|
|
529
537
|
}),
|
|
530
538
|
]);
|
|
531
|
-
const messages = Array.isArray(
|
|
532
|
-
?
|
|
539
|
+
const messages = Array.isArray(raw?.messages)
|
|
540
|
+
? raw.messages.filter(message => message && typeof message === 'object')
|
|
533
541
|
: [];
|
|
534
542
|
let files = [];
|
|
535
543
|
try {
|
|
@@ -553,7 +561,7 @@ export class OwnerChannel {
|
|
|
553
561
|
&& Number.isInteger(message.msg_id);
|
|
554
562
|
}).map(message => message.msg_id);
|
|
555
563
|
if (deferred.length)
|
|
556
|
-
await this.client.
|
|
564
|
+
await this.client.callTool('defer_messages', { msg_ids: deferred });
|
|
557
565
|
let advanced = false;
|
|
558
566
|
const consumedMessages = new Set();
|
|
559
567
|
const groups = this.attachmentGroups(files, messages, pending, consumedMessages);
|
|
@@ -684,12 +692,15 @@ export class OwnerChannel {
|
|
|
684
692
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
685
693
|
const processed = group.files.filter(file => file.status !== 'unread');
|
|
686
694
|
const retrieved = unread.length
|
|
687
|
-
? parseRetrievedAttachments(await this.client.
|
|
695
|
+
? parseRetrievedAttachments(await this.client.callTool('get_files', {
|
|
696
|
+
wire_ids: unread.map(file => file.wireId),
|
|
697
|
+
}), unread)
|
|
688
698
|
: [];
|
|
689
699
|
for (const file of processed) {
|
|
690
700
|
if (!group.recovery)
|
|
691
701
|
throw new Error('unexpected processed attachment without recovery route');
|
|
692
|
-
const recoveryPath =
|
|
702
|
+
const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
|
|
703
|
+
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
693
704
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
694
705
|
}
|
|
695
706
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -698,17 +709,13 @@ export class OwnerChannel {
|
|
|
698
709
|
outbox = this.outboxDir(originWireId);
|
|
699
710
|
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
|
700
711
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
701
|
-
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), {
|
|
702
713
|
interrupt: this.options.config.interrupt,
|
|
703
714
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
704
715
|
origin: { kind: 'owner', requestId,
|
|
705
716
|
...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
|
|
706
717
|
});
|
|
707
|
-
const accepted = this.
|
|
708
|
-
? ownerNotices.receivedInterrupting()
|
|
709
|
-
: queued.queuedBehind > 0
|
|
710
|
-
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
711
|
-
: ownerNotices.receivedStarted();
|
|
718
|
+
const accepted = this.acceptanceNotice(queued);
|
|
712
719
|
handledWireIds.forEach(wire => this.inFlight.add(wire));
|
|
713
720
|
const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
|
|
714
721
|
this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
|
|
@@ -786,12 +793,12 @@ export class OwnerChannel {
|
|
|
786
793
|
// the authenticated agent once so the wait is never silent.
|
|
787
794
|
this.options.log(`[${this.options.role}] owner channel managed-agent relay has no owner `
|
|
788
795
|
+ `route yet; message stays queued: ${this.errorText(error)}`);
|
|
789
|
-
await this.nackManagedAgent(sender.id, message
|
|
796
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayQueued());
|
|
790
797
|
return false;
|
|
791
798
|
}
|
|
792
799
|
else {
|
|
793
800
|
this.logError('managed-agent message relay refused', error);
|
|
794
|
-
await this.nackManagedAgent(sender.id, message
|
|
801
|
+
await this.nackManagedAgent(sender.id, message, wireId, ownerNotices.relayRefused(this.errorText(error)));
|
|
795
802
|
}
|
|
796
803
|
}
|
|
797
804
|
this.state.remember(wireId);
|
|
@@ -827,7 +834,7 @@ export class OwnerChannel {
|
|
|
827
834
|
let queued;
|
|
828
835
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
829
836
|
try {
|
|
830
|
-
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId
|
|
837
|
+
queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
|
|
831
838
|
interrupt: this.options.config.interrupt,
|
|
832
839
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
833
840
|
origin: { kind: 'owner', requestId, displayText: text },
|
|
@@ -849,14 +856,7 @@ export class OwnerChannel {
|
|
|
849
856
|
this.state.remember(wireId);
|
|
850
857
|
return true;
|
|
851
858
|
}
|
|
852
|
-
|
|
853
|
-
// accepted into the ACP queue. Never claim this request is running while
|
|
854
|
-
// the session itself says earlier work remains ahead of it.
|
|
855
|
-
const accepted = queued.queuedBehind > 0
|
|
856
|
-
? ownerNotices.receivedQueued(queued.queuedBehind)
|
|
857
|
-
: this.options.config.interrupt
|
|
858
|
-
? ownerNotices.receivedInterrupting()
|
|
859
|
-
: ownerNotices.receivedStarted();
|
|
859
|
+
const accepted = this.acceptanceNotice(queued);
|
|
860
860
|
this.inFlight.add(wireId);
|
|
861
861
|
const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
|
|
862
862
|
this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
|
|
@@ -1036,9 +1036,7 @@ export class OwnerChannel {
|
|
|
1036
1036
|
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
1037
1037
|
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
1038
1038
|
const nackWire = captionWire ?? group.files[0].wireId;
|
|
1039
|
-
|
|
1040
|
-
// back as a reply reference; a file wire always is a real one.
|
|
1041
|
-
const nackReplyTo = (group.caption ? group.caption.wire_id : nackWire) ? nackWire : undefined;
|
|
1039
|
+
const nackMessage = group.caption ?? { wire_id: nackWire };
|
|
1042
1040
|
let requestDir;
|
|
1043
1041
|
let recovery;
|
|
1044
1042
|
try {
|
|
@@ -1074,12 +1072,15 @@ export class OwnerChannel {
|
|
|
1074
1072
|
const unread = group.files.filter(file => file.status === 'unread');
|
|
1075
1073
|
const processed = group.files.filter(file => file.status !== 'unread');
|
|
1076
1074
|
const retrieved = unread.length
|
|
1077
|
-
? parseRetrievedAttachments(await this.client.
|
|
1075
|
+
? parseRetrievedAttachments(await this.client.callTool('get_files', {
|
|
1076
|
+
wire_ids: unread.map(file => file.wireId),
|
|
1077
|
+
}), unread)
|
|
1078
1078
|
: [];
|
|
1079
1079
|
for (const file of processed) {
|
|
1080
1080
|
if (!group.recovery)
|
|
1081
1081
|
throw new Error('unexpected processed attachment without recovery route');
|
|
1082
|
-
const recoveryPath =
|
|
1082
|
+
const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
|
|
1083
|
+
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1083
1084
|
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1084
1085
|
}
|
|
1085
1086
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
@@ -1091,9 +1092,9 @@ export class OwnerChannel {
|
|
|
1091
1092
|
if (caption)
|
|
1092
1093
|
await this.send(contact, caption, replyTo);
|
|
1093
1094
|
for (const file of admitted) {
|
|
1094
|
-
await this.client.
|
|
1095
|
+
await this.client.callTool('send_file', {
|
|
1095
1096
|
contact, path: file.path, filename: file.filename,
|
|
1096
|
-
...(replyTo ? {
|
|
1097
|
+
...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
1097
1098
|
});
|
|
1098
1099
|
}
|
|
1099
1100
|
}
|
|
@@ -1130,11 +1131,11 @@ export class OwnerChannel {
|
|
|
1130
1131
|
if (error instanceof RelayUnroutableError) {
|
|
1131
1132
|
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1132
1133
|
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1133
|
-
await this.nackManagedAgent(agent,
|
|
1134
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
|
|
1134
1135
|
return false;
|
|
1135
1136
|
}
|
|
1136
1137
|
this.logError('managed-agent caption/file relay refused', error);
|
|
1137
|
-
await this.nackManagedAgent(agent,
|
|
1138
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1138
1139
|
// Rejection/admission failure and uncertain transport are terminal and
|
|
1139
1140
|
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1140
1141
|
for (const wire of handledWireIds)
|
|
@@ -1173,14 +1174,14 @@ export class OwnerChannel {
|
|
|
1173
1174
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
1174
1175
|
* delivery is best-effort — it must never make the failure worse.
|
|
1175
1176
|
*/
|
|
1176
|
-
async nackManagedAgent(contact,
|
|
1177
|
+
async nackManagedAgent(contact, message, wireId, notice) {
|
|
1177
1178
|
if (this.relayNacks.has(wireId))
|
|
1178
1179
|
return;
|
|
1179
1180
|
this.relayNacks.add(wireId);
|
|
1180
1181
|
if (this.relayNacks.size > RELAY_NACK_MEMORY)
|
|
1181
1182
|
this.relayNacks.delete(this.relayNacks.values().next().value);
|
|
1182
1183
|
try {
|
|
1183
|
-
await this.send(contact, notice,
|
|
1184
|
+
await this.send(contact, notice, message.wire_id ? wireId : undefined);
|
|
1184
1185
|
}
|
|
1185
1186
|
catch (error) {
|
|
1186
1187
|
this.logError('managed-agent relay NACK delivery failed', error);
|
|
@@ -1241,6 +1242,32 @@ export class OwnerChannel {
|
|
|
1241
1242
|
authorizationIntegrity() {
|
|
1242
1243
|
return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
|
|
1243
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
|
+
}
|
|
1244
1271
|
async complete(active, outbox, queued, activityCursor) {
|
|
1245
1272
|
const progressMs = this.options.config.progress_interval_ms;
|
|
1246
1273
|
let lastSeq = activityCursor;
|
|
@@ -1441,7 +1468,7 @@ export class OwnerChannel {
|
|
|
1441
1468
|
throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
|
|
1442
1469
|
return message;
|
|
1443
1470
|
}
|
|
1444
|
-
ownerAttachmentPrompt(sender, wireId, requestId,
|
|
1471
|
+
ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
|
|
1445
1472
|
const lines = [
|
|
1446
1473
|
'[fleet-owner]',
|
|
1447
1474
|
`Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
|
|
@@ -1465,10 +1492,13 @@ export class OwnerChannel {
|
|
|
1465
1492
|
lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
|
|
1466
1493
|
}
|
|
1467
1494
|
}
|
|
1468
|
-
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.');
|
|
1469
1499
|
return lines.join('\n');
|
|
1470
1500
|
}
|
|
1471
|
-
ownerPrompt(sender, text, wireId
|
|
1501
|
+
ownerPrompt(sender, text, wireId) {
|
|
1472
1502
|
return [
|
|
1473
1503
|
'[fleet-owner]',
|
|
1474
1504
|
`Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
|
|
@@ -1482,10 +1512,13 @@ export class OwnerChannel {
|
|
|
1482
1512
|
] : [
|
|
1483
1513
|
'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
|
|
1484
1514
|
]),
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
'
|
|
1488
|
-
|
|
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.',
|
|
1489
1522
|
'',
|
|
1490
1523
|
text || '(empty message)',
|
|
1491
1524
|
].join('\n');
|
|
@@ -1498,8 +1531,8 @@ export class OwnerChannel {
|
|
|
1498
1531
|
return createHash('sha256').update(wireId).digest('hex');
|
|
1499
1532
|
}
|
|
1500
1533
|
send(contact, text, replyTo) {
|
|
1501
|
-
return this.client.
|
|
1502
|
-
contact, text, ...(replyTo ? {
|
|
1534
|
+
return this.client.callTool('send_message', {
|
|
1535
|
+
contact, text, ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
1503
1536
|
});
|
|
1504
1537
|
}
|
|
1505
1538
|
async sendAttachments(contact, outbox, replyTo) {
|
|
@@ -1507,11 +1540,11 @@ export class OwnerChannel {
|
|
|
1507
1540
|
.filter(entry => entry.isFile())
|
|
1508
1541
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1509
1542
|
for (const entry of entries) {
|
|
1510
|
-
await this.client.
|
|
1543
|
+
await this.client.callTool('send_file', {
|
|
1511
1544
|
contact,
|
|
1512
1545
|
path: join(outbox, entry.name),
|
|
1513
1546
|
filename: entry.name,
|
|
1514
|
-
|
|
1547
|
+
reply_to_wire_id: replyTo,
|
|
1515
1548
|
});
|
|
1516
1549
|
}
|
|
1517
1550
|
await rm(outbox, { recursive: true, force: true });
|
|
@@ -1534,11 +1567,11 @@ export class OwnerChannel {
|
|
|
1534
1567
|
return Number.isInteger(message.msg_id) ? `msg:${message.msg_id}` : '';
|
|
1535
1568
|
}
|
|
1536
1569
|
sender(message) {
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
const id = String(source?.id ?? '');
|
|
1541
|
-
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) };
|
|
1542
1575
|
}
|
|
1543
1576
|
latestEventSeq(events) {
|
|
1544
1577
|
return events.reduce((latest, event) => Math.max(latest, event.seq), 0);
|
|
@@ -1567,65 +1600,90 @@ export class OwnerChannel {
|
|
|
1567
1600
|
return undefined;
|
|
1568
1601
|
}
|
|
1569
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));
|
|
1570
1606
|
const sleep = this.options.binderDeps?.sleep
|
|
1571
1607
|
?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
1572
1608
|
const restored = this.readWatchState();
|
|
1573
1609
|
let state = restored.state;
|
|
1574
|
-
|
|
1575
|
-
|
|
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';
|
|
1576
1615
|
let delayMs = 1_000;
|
|
1577
|
-
let
|
|
1616
|
+
let authFailures = 0;
|
|
1617
|
+
if (recovering)
|
|
1618
|
+
await this.drain().catch(error => this.logError('cursor recovery drain failed', error));
|
|
1578
1619
|
while (!this.stopping) {
|
|
1579
1620
|
const ctrl = new AbortController();
|
|
1580
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?.();
|
|
1581
1626
|
try {
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
|
|
1593
|
-
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');
|
|
1594
1637
|
return;
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
// Draining is idempotent at the turn boundary because wire IDs are
|
|
1599
|
-
// recorded before a managed request is dispatched.
|
|
1600
|
-
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');
|
|
1601
1641
|
}
|
|
1602
|
-
if (!
|
|
1603
|
-
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();
|
|
1604
1656
|
}
|
|
1605
1657
|
catch (error) {
|
|
1606
1658
|
if (this.stopping)
|
|
1607
1659
|
return;
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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);
|
|
1613
1669
|
this.options.log(`[${this.options.role}] owner watch reconnect `
|
|
1614
|
-
+ `reason
|
|
1615
|
-
+ `
|
|
1670
|
+
+ `reason=${authRejected ? 'OWNER_WATCH_AUTH_FAILED' : reason} `
|
|
1671
|
+
+ `delay_ms=${delayMs} cursor=${current}`);
|
|
1616
1672
|
await sleep(delayMs);
|
|
1617
1673
|
delayMs = Math.min(delayMs * 2, OWNER_WATCH_BACKOFF_MAX_MS);
|
|
1618
1674
|
}
|
|
1619
1675
|
finally {
|
|
1676
|
+
clearTimeout(timer);
|
|
1620
1677
|
if (this.watchAbort === ctrl)
|
|
1621
1678
|
this.watchAbort = undefined;
|
|
1622
1679
|
}
|
|
1623
1680
|
}
|
|
1624
1681
|
}
|
|
1625
1682
|
/**
|
|
1626
|
-
* `recovered` distinguishes a first-ever start from
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
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.
|
|
1629
1687
|
*/
|
|
1630
1688
|
readWatchState() {
|
|
1631
1689
|
const path = join(this.options.stateDir, '.owner-channel-watch.json');
|
|
@@ -1633,43 +1691,76 @@ export class OwnerChannel {
|
|
|
1633
1691
|
return { recovered: false };
|
|
1634
1692
|
try {
|
|
1635
1693
|
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
1636
|
-
if (
|
|
1694
|
+
if (value.version !== 1 || !Number.isSafeInteger(value.cursor) || value.cursor < 0
|
|
1637
1695
|
|| !Number.isSafeInteger(value.reconnects) || value.reconnects < 0
|
|
1638
1696
|
|| !Number.isSafeInteger(value.consecutiveFailures) || value.consecutiveFailures < 0)
|
|
1639
1697
|
throw new Error('invalid owner watch state');
|
|
1640
|
-
|
|
1641
|
-
'OWNER_WATCH_CONNECTING', 'OWNER_WATCH_CONNECTED',
|
|
1642
|
-
'OWNER_WATCH_STREAM_ERROR', 'OWNER_WATCH_STATE_RECOVERED',
|
|
1643
|
-
]);
|
|
1644
|
-
return { state: {
|
|
1645
|
-
version: 2,
|
|
1646
|
-
reconnects: value.reconnects,
|
|
1647
|
-
consecutiveFailures: value.consecutiveFailures,
|
|
1648
|
-
reason: reasons.has(value.reason)
|
|
1649
|
-
? value.reason : 'OWNER_WATCH_CONNECTING',
|
|
1650
|
-
updatedAt: typeof value.updatedAt === 'string'
|
|
1651
|
-
? value.updatedAt : new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
|
|
1652
|
-
}, recovered: false };
|
|
1698
|
+
return { state: value, recovered: false };
|
|
1653
1699
|
}
|
|
1654
1700
|
catch {
|
|
1655
1701
|
this.options.log(`[${this.options.role}] owner watch `
|
|
1656
|
-
+ 'reason=
|
|
1702
|
+
+ 'reason=OWNER_WATCH_CURSOR_RECOVERED invalid cursor state; draining inbox and starting at tip');
|
|
1657
1703
|
return { recovered: true };
|
|
1658
1704
|
}
|
|
1659
1705
|
}
|
|
1660
|
-
writeWatchState(previous, reason,
|
|
1706
|
+
writeWatchState(previous, cursor, reason, failed, reconnected = false) {
|
|
1661
1707
|
const state = {
|
|
1662
|
-
version:
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
: 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,
|
|
1667
1712
|
reason,
|
|
1668
1713
|
updatedAt: new Date(this.options.binderDeps?.now?.() ?? Date.now()).toISOString(),
|
|
1669
1714
|
};
|
|
1670
1715
|
replaceFileAtomically(join(this.options.stateDir, '.owner-channel-watch.json'), `${JSON.stringify(state)}\n`, 0o600);
|
|
1671
1716
|
return state;
|
|
1672
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
|
+
}
|
|
1673
1764
|
errorText(error) {
|
|
1674
1765
|
return error?.message ?? String(error);
|
|
1675
1766
|
}
|