@ours.network/fleet 0.15.0 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ export const OWNER_BIND_HANDOFF_TIMEOUT_MS = 5_000;
6
+ const OWNER_BIND_POLL_MS = 50;
7
+ const LOCK_DIR = '.owner-channel-binder.lock';
8
+ const RECLAIM_DIR = '.owner-channel-binder.reclaim.lock';
9
+ const LAST_OWNER_FILE = '.owner-channel-binder.json';
10
+ export class OwnerBinderConflictError extends Error {
11
+ }
12
+ export class OwnerBinderHandoffTimeoutError extends Error {
13
+ }
14
+ const defaultAlive = (pid) => {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ };
23
+ /** Linux PID-reuse fence. Other platforms return undefined and stay conservative. */
24
+ const defaultProcessMarker = (pid) => {
25
+ try {
26
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
27
+ const end = stat.lastIndexOf(')');
28
+ return stat.slice(end + 2).split(/\s+/)[19];
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ };
34
+ function parseOwner(path) {
35
+ let value;
36
+ try {
37
+ value = JSON.parse(readFileSync(path, 'utf8'));
38
+ }
39
+ catch {
40
+ throw new OwnerBinderConflictError('owner-channel binder ownership cannot be verified safely');
41
+ }
42
+ const owner = value;
43
+ if (owner.version !== 1 || typeof owner.role !== 'string' || typeof owner.identity !== 'string'
44
+ || !Number.isSafeInteger(owner.pid) || Number(owner.pid) < 2
45
+ || typeof owner.instance !== 'string' || !owner.instance
46
+ || !Number.isFinite(owner.acquiredAt))
47
+ throw new OwnerBinderConflictError('owner-channel binder ownership cannot be verified safely');
48
+ return owner;
49
+ }
50
+ function sameOwner(a, b) {
51
+ return a.instance === b.instance && a.pid === b.pid && a.role === b.role
52
+ && a.identity === b.identity;
53
+ }
54
+ /**
55
+ * Serialize the one supervisor-owned binder for a role. A live matching holder
56
+ * is an overlapping predecessor and gets a bounded handoff window. A holder
57
+ * for any other role/identity, or unverifiable metadata, remains fail-closed.
58
+ */
59
+ export async function acquireOwnerBinderLease(stateDir, role, identity, deps = {}, timeoutMs = OWNER_BIND_HANDOFF_TIMEOUT_MS) {
60
+ const now = deps.now ?? (() => Date.now());
61
+ const sleep = deps.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
62
+ const alive = deps.alive ?? defaultAlive;
63
+ const processMarker = deps.processMarker ?? defaultProcessMarker;
64
+ const lockDir = join(stateDir, LOCK_DIR);
65
+ const reclaimDir = join(stateDir, RECLAIM_DIR);
66
+ const ownerPath = join(lockDir, 'owner.json');
67
+ const releasedPath = join(stateDir, LAST_OWNER_FILE);
68
+ const startedAt = now();
69
+ let inherited = false;
70
+ const ours = {
71
+ version: 1, role, identity, pid: process.pid,
72
+ ...(processMarker(process.pid) ? { marker: processMarker(process.pid) } : {}),
73
+ instance: randomUUID(), acquiredAt: startedAt,
74
+ };
75
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
76
+ for (;;) {
77
+ try {
78
+ mkdirSync(lockDir, { mode: 0o700 });
79
+ writeFileSync(ownerPath, JSON.stringify(ours) + '\n', { mode: 0o600 });
80
+ // A reclaimer may have installed its gate after our mkdir. It will
81
+ // re-check the canonical owner before claiming, while we withdraw our
82
+ // own raced acquisition before returning.
83
+ if (existsSync(reclaimDir)) {
84
+ const check = parseOwner(ownerPath);
85
+ if (!sameOwner(check, ours))
86
+ throw new OwnerBinderConflictError('owner-channel binder ownership changed during acquisition');
87
+ rmSync(lockDir, { recursive: true, force: true });
88
+ if (now() - startedAt >= timeoutMs)
89
+ throw new OwnerBinderHandoffTimeoutError(`owner-channel binder reclaim did not complete within ${timeoutMs}ms`);
90
+ await sleep(Math.min(OWNER_BIND_POLL_MS, Math.max(1, timeoutMs - (now() - startedAt))));
91
+ continue;
92
+ }
93
+ break;
94
+ }
95
+ catch (error) {
96
+ if (error.code !== 'EEXIST')
97
+ throw error;
98
+ let current;
99
+ try {
100
+ current = parseOwner(ownerPath);
101
+ }
102
+ catch (readError) {
103
+ // The winning process may be between atomic mkdir and its tiny metadata write.
104
+ if (now() - startedAt < OWNER_BIND_POLL_MS * 2) {
105
+ await sleep(OWNER_BIND_POLL_MS);
106
+ continue;
107
+ }
108
+ throw readError;
109
+ }
110
+ if (current.role !== role || current.identity !== identity)
111
+ throw new OwnerBinderConflictError(`owner-channel identity '${identity}' is reserved by foreign binder `
112
+ + `'${current.role}' for '${current.identity}'`);
113
+ inherited = true;
114
+ const currentMarker = processMarker(current.pid);
115
+ const stale = !alive(current.pid)
116
+ || Boolean(current.marker && currentMarker && current.marker !== currentMarker);
117
+ if (stale) {
118
+ await deps.beforeReclaim?.();
119
+ try {
120
+ mkdirSync(reclaimDir, { mode: 0o700 });
121
+ }
122
+ catch (gateError) {
123
+ if (gateError.code === 'EEXIST') {
124
+ await sleep(OWNER_BIND_POLL_MS);
125
+ continue;
126
+ }
127
+ throw gateError;
128
+ }
129
+ const tombstone = `${lockDir}.reclaim-${ours.instance}`;
130
+ let acquiredFromClaim = false;
131
+ try {
132
+ const check = parseOwner(ownerPath);
133
+ if (!sameOwner(current, check))
134
+ continue;
135
+ try {
136
+ renameSync(lockDir, tombstone);
137
+ }
138
+ catch (claimError) {
139
+ if (claimError.code === 'ENOENT')
140
+ continue;
141
+ throw claimError;
142
+ }
143
+ let claimed;
144
+ try {
145
+ claimed = parseOwner(join(tombstone, 'owner.json'));
146
+ }
147
+ catch (claimError) {
148
+ // Preserve an unverifiable claim. Restore it only if nobody has
149
+ // already acquired the canonical path; otherwise fail closed.
150
+ try {
151
+ renameSync(tombstone, lockDir);
152
+ }
153
+ catch { /* keep the claim quarantined */ }
154
+ throw claimError;
155
+ }
156
+ if (!sameOwner(current, claimed)) {
157
+ // Never delete a replacement: put it back when possible, otherwise
158
+ // retain the unique tombstone as the fail-closed claimed record.
159
+ try {
160
+ renameSync(tombstone, lockDir);
161
+ }
162
+ catch { /* keep the claim quarantined */ }
163
+ throw new OwnerBinderConflictError('owner-channel binder changed while stale ownership was being claimed');
164
+ }
165
+ rmSync(tombstone, { recursive: true, force: true });
166
+ // Keep the gate until our replacement is durable. Any acquisition
167
+ // that raced the rename observes the gate and withdraws itself.
168
+ for (;;) {
169
+ try {
170
+ mkdirSync(lockDir, { mode: 0o700 });
171
+ writeFileSync(ownerPath, JSON.stringify(ours) + '\n', { mode: 0o600 });
172
+ acquiredFromClaim = true;
173
+ break;
174
+ }
175
+ catch (replacementError) {
176
+ if (replacementError.code !== 'EEXIST')
177
+ throw replacementError;
178
+ if (now() - startedAt >= timeoutMs)
179
+ throw new OwnerBinderHandoffTimeoutError(`could not complete stale owner-channel binder claim within ${timeoutMs}ms`);
180
+ await sleep(1);
181
+ }
182
+ }
183
+ }
184
+ finally {
185
+ rmSync(reclaimDir, { recursive: true, force: true });
186
+ }
187
+ if (acquiredFromClaim)
188
+ break;
189
+ continue;
190
+ }
191
+ if (now() - startedAt >= timeoutMs)
192
+ throw new OwnerBinderHandoffTimeoutError(`previous '${role}' supervisor still owns owner-channel identity '${identity}' `
193
+ + `after ${timeoutMs}ms bounded handoff`);
194
+ await sleep(Math.min(OWNER_BIND_POLL_MS, Math.max(1, timeoutMs - (now() - startedAt))));
195
+ }
196
+ }
197
+ try {
198
+ const released = JSON.parse(readFileSync(releasedPath, 'utf8'));
199
+ if (released.version === 1 && released.role === role && released.identity === identity
200
+ && Number.isFinite(released.releasedAt)
201
+ && now() - Number(released.releasedAt) <= timeoutMs * 2)
202
+ inherited = true;
203
+ }
204
+ catch { /* absence/corruption cannot grant recovery authority */ }
205
+ let released = false;
206
+ return {
207
+ inherited,
208
+ release() {
209
+ if (released)
210
+ return;
211
+ released = true;
212
+ try {
213
+ const current = parseOwner(ownerPath);
214
+ if (!sameOwner(current, ours))
215
+ return;
216
+ replaceFileAtomically(releasedPath, JSON.stringify({
217
+ ...ours, releasedAt: now(),
218
+ }) + '\n', 0o600);
219
+ rmSync(lockDir, { recursive: true, force: true });
220
+ }
221
+ catch { /* never remove ownership which cannot be proven to be ours */ }
222
+ },
223
+ };
224
+ }
@@ -2,10 +2,12 @@ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { type OwnerChannelConfig } from '../config.js';
3
3
  import type { SessionHandle } from '../session/types.js';
4
4
  import { type OwnerFleetOps } from './commands.js';
5
+ import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
6
  import { type OursToolClient } from './mcp.js';
6
7
  import { type OwnerUpdatePhase } from './notices.js';
7
8
  import { type OwnerEntry } from './state.js';
8
9
  import { type OwnerTaskPhase } from './tasks.js';
10
+ import { type OwnerBinderDeps, type OwnerBinderLease } from './binder.js';
9
11
  export interface OwnerChannelOptions {
10
12
  role: string;
11
13
  /** Harness id of the role (e.g. 'claude-code', 'codex'); gates which slash commands may be forwarded. */
@@ -23,12 +25,18 @@ export interface OwnerChannelOptions {
23
25
  fleet?: OwnerFleetOps;
24
26
  /** Forwarded to fleet CLI invocations spawned for owner commands. */
25
27
  configPath?: string;
28
+ /** Deterministic clock/process seams for binder handoff tests. */
29
+ binderDeps?: OwnerBinderDeps;
30
+ /** Pre-acquired by the runner so the predecessor control socket remains reachable while waiting. */
31
+ binderLease?: OwnerBinderLease;
26
32
  }
27
33
  export interface OwnerChannelHandle {
28
34
  start(): Promise<void>;
29
35
  drain(): Promise<void>;
30
36
  close(): Promise<void>;
31
37
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
38
+ /** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
39
+ notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
32
40
  }
33
41
  export type OwnerChannelManagementRequest = {
34
42
  action: 'contact_list';
@@ -60,6 +68,8 @@ export type OwnerChannelManagementRequest = {
60
68
  taskId: string;
61
69
  phase: OwnerTaskPhase;
62
70
  message: string;
71
+ } | {
72
+ action: 'startup_failure';
63
73
  };
64
74
  export type OwnerChannelManagementResult = {
65
75
  action: 'contact_list';
@@ -95,6 +105,9 @@ export type OwnerChannelManagementResult = {
95
105
  phase: OwnerTaskPhase;
96
106
  sequence: number;
97
107
  state: 'open' | 'closed';
108
+ } | {
109
+ action: 'startup_failure';
110
+ status: 'delivered' | 'duplicate';
98
111
  };
99
112
  export type { OwnerUpdatePhase } from './notices.js';
100
113
  export interface OwnerContact {
@@ -137,12 +150,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
137
150
  private readonly activeRequests;
138
151
  private managementTail;
139
152
  private ready;
153
+ private binder?;
154
+ private binderOwnedInternally;
140
155
  private readonly fleetOps;
141
156
  constructor(options: OwnerChannelOptions);
142
157
  start(): Promise<void>;
143
158
  drain(): Promise<void>;
144
159
  close(): Promise<void>;
145
160
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
161
+ notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
146
162
  private manageNow;
147
163
  private contacts;
148
164
  private contact;
@@ -183,6 +199,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
183
199
  private isAgentSender;
184
200
  private isEffectiveOwner;
185
201
  private relayManagedAgentMessage;
202
+ /**
203
+ * Caption and files are admitted as one relay transaction. The authenticated
204
+ * route and optional source wire are fixed before bytes are retrieved, and no
205
+ * outbound part is emitted until every file passes admission. A transport
206
+ * failure after emission starts is durably uncertain and never blind-retried;
207
+ * the managed agent receives one bounded NACK for the whole transaction.
208
+ */
209
+ private handleManagedAgentAttachmentGroup;
210
+ private managedAttachmentReplyWire;
186
211
  /**
187
212
  * One bounded NACK per wire: an unroutable or refused relay must be visible
188
213
  * to the authenticated agent, while its deferred replays stay quiet. NACK
@@ -11,6 +11,7 @@ import { ownerNotices } from './notices.js';
11
11
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
12
12
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
13
13
  import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
14
+ import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
14
15
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
15
16
  const OWNER_UPDATE_MAX_COUNT = 20;
16
17
  const OWNER_UPDATE_MAX_CHARS = 280;
@@ -51,6 +52,8 @@ export class OwnerChannel {
51
52
  activeRequests = new Map();
52
53
  managementTail = Promise.resolve();
53
54
  ready = false;
55
+ binder;
56
+ binderOwnedInternally = false;
54
57
  fleetOps;
55
58
  constructor(options) {
56
59
  this.options = options;
@@ -79,8 +82,36 @@ export class OwnerChannel {
79
82
  }
80
83
  async start() {
81
84
  this.stopping = false;
82
- await this.client.start();
83
- await this.client.callTool('choose_identity', { name: this.options.config.identity });
85
+ this.binderOwnedInternally = !this.options.binderLease;
86
+ this.binder = this.options.binderLease ?? await acquireOwnerBinderLease(this.options.stateDir, this.options.role, this.options.config.identity, this.options.binderDeps);
87
+ try {
88
+ await this.client.start();
89
+ const now = this.options.binderDeps?.now ?? (() => Date.now());
90
+ const sleep = this.options.binderDeps?.sleep
91
+ ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
92
+ const bindStartedAt = now();
93
+ for (;;) {
94
+ try {
95
+ await this.client.callTool('choose_identity', { name: this.options.config.identity });
96
+ break;
97
+ }
98
+ catch (error) {
99
+ const message = error?.message ?? String(error);
100
+ const liveConflict = /currently bound to another live session/i.test(message);
101
+ if (!this.binder.inherited || !liveConflict
102
+ || now() - bindStartedAt >= OWNER_BIND_HANDOFF_TIMEOUT_MS)
103
+ throw error;
104
+ await sleep(Math.min(50, Math.max(1, OWNER_BIND_HANDOFF_TIMEOUT_MS - (now() - bindStartedAt))));
105
+ }
106
+ }
107
+ }
108
+ catch (error) {
109
+ await this.client.close().catch(closeError => this.logError('startup client close failed', closeError));
110
+ if (this.binderOwnedInternally)
111
+ this.binder.release();
112
+ this.binder = undefined;
113
+ throw error;
114
+ }
84
115
  if (this.authorizationIntegrity().ok && this.tasks.integrity().ok)
85
116
  this.tasks.cleanup(Date.now(), this.effectiveOwners());
86
117
  if (this.attachmentRecovery.integrity()) {
@@ -112,13 +143,34 @@ export class OwnerChannel {
112
143
  if (watch && watch.exitCode === null)
113
144
  watch.kill('SIGTERM');
114
145
  await this.managementTail;
115
- await this.client.close();
146
+ try {
147
+ await this.client.close();
148
+ }
149
+ finally {
150
+ if (this.binderOwnedInternally)
151
+ this.binder?.release();
152
+ this.binder = undefined;
153
+ }
116
154
  }
117
155
  manage(request) {
118
156
  const run = this.managementTail.then(() => this.manageNow(request));
119
157
  this.managementTail = run.then(() => undefined, () => undefined);
120
158
  return run;
121
159
  }
160
+ notifyFleetSpawn(event) {
161
+ const run = this.managementTail.then(async () => {
162
+ if (!this.ready || this.stopping)
163
+ throw new Error('owner-channel MCP client is unavailable');
164
+ const model = event.model ? `, model ${event.model}` : '';
165
+ const monitor = `${event.monitor.mode} monitor${event.monitor.interrupt ? ' with interruption' : ''}`;
166
+ const inherited = event.inherited.length
167
+ ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
168
+ await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
169
+ + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
170
+ });
171
+ this.managementTail = run.then(() => undefined, () => undefined);
172
+ return run;
173
+ }
122
174
  async manageNow(request) {
123
175
  if (!this.ready || this.stopping)
124
176
  throw new Error('owner-channel MCP client is unavailable');
@@ -208,6 +260,18 @@ export class OwnerChannel {
208
260
  if (this.options.config.agent)
209
261
  throw new Error('direct task reports are disabled; the managed agent must message its owner-channel identity');
210
262
  return this.sendOwnerTaskReport(request);
263
+ case 'startup_failure': {
264
+ const message = ownerNotices.startupHandoffFailed(this.options.role, this.options.config.identity);
265
+ try {
266
+ await this.sendProactiveMessage(message);
267
+ }
268
+ catch (error) {
269
+ if (error instanceof DuplicateSendError)
270
+ return { action: request.action, status: 'duplicate' };
271
+ throw error;
272
+ }
273
+ return { action: request.action, status: 'delivered' };
274
+ }
211
275
  default:
212
276
  throw new Error('unknown owner-channel management action');
213
277
  }
@@ -287,13 +351,15 @@ export class OwnerChannel {
287
351
  await send;
288
352
  return { action: request.action, requestId: request.requestId, sequence };
289
353
  }
290
- async sendProactiveMessage(messageValue) {
354
+ async sendProactiveMessage(messageValue, digestValue = messageValue, minIntervalMs) {
291
355
  if (!this.authorizationIntegrity().ok)
292
356
  throw new Error('owner authorization state is corrupt; proactive messages are disabled');
293
357
  const message = this.safeProactiveMessage(messageValue);
294
358
  const route = this.conversations.route(this.effectiveOwners());
295
- const digest = createHash('sha256').update(message).digest('hex');
296
- const sending = this.conversations.beginSend(route.contact, digest);
359
+ const digest = createHash('sha256').update(digestValue).digest('hex');
360
+ const sending = minIntervalMs === undefined
361
+ ? this.conversations.beginSend(route.contact, digest)
362
+ : this.conversations.beginSend(route.contact, digest, Date.now(), minIntervalMs);
297
363
  try {
298
364
  if (!this.isEffectiveOwner(route.contact))
299
365
  throw new Error('selected proactive owner is no longer authorized');
@@ -492,7 +558,18 @@ export class OwnerChannel {
492
558
  if (exact.some(file => file.senderId !== recovery.contact))
493
559
  continue;
494
560
  exact.forEach(file => used.add(file.wireId));
495
- groups.push({ files: exact, recovery });
561
+ const caption = recovery.originWireId === recovery.fileWireIds[0]
562
+ ? undefined : messageByWire.get(recovery.originWireId);
563
+ if (caption && this.sender(caption).id !== recovery.contact)
564
+ continue;
565
+ // A managed-agent caption is deferred before retrieval. If recovery sees
566
+ // the processed file before that body is replayed, keep the file reserved
567
+ // by the journal until both halves are present again.
568
+ if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
569
+ continue;
570
+ if (caption)
571
+ consumed.add(caption);
572
+ groups.push({ files: exact, recovery, ...(caption ? { caption } : {}) });
496
573
  }
497
574
  for (const file of files) {
498
575
  if (used.has(file.wireId))
@@ -528,6 +605,9 @@ export class OwnerChannel {
528
605
  if (handledWireIds.some(wire => this.inFlight.has(wire)))
529
606
  return false;
530
607
  const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
608
+ if (group.files.every(file => this.isAgentSender(file.senderId))
609
+ && (!group.caption || this.isAgentSender(this.sender(group.caption).id)))
610
+ return this.handleManagedAgentAttachmentGroup(group, handledWireIds, sender.id);
531
611
  if (group.files.some(file => file.senderId !== sender.id)
532
612
  || !this.isEffectiveOwner(sender.id)) {
533
613
  this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
@@ -540,6 +620,14 @@ export class OwnerChannel {
540
620
  catch { }
541
621
  return true;
542
622
  }
623
+ // Owner files are requests too: retain their authenticated source wire so
624
+ // a later managed-agent attachment reply cannot drift to a newer owner.
625
+ try {
626
+ this.conversations.recordInbound(sender.id, originWireId);
627
+ }
628
+ catch (error) {
629
+ this.logError('owner attachment route update failed', error);
630
+ }
543
631
  const rejection = !this.attachmentRecovery.integrity()
544
632
  ? 'attachment recovery state is unavailable'
545
633
  : validateAttachmentSelection(group.files, this.attachmentConfig);
@@ -886,6 +974,149 @@ export class OwnerChannel {
886
974
  + `wire=${createHash('sha256').update(wireId).digest('hex').slice(0, 12)} `
887
975
  + `basis=${route.basis} chars=${Array.from(text).length} bytes=${Buffer.byteLength(text)}`);
888
976
  }
977
+ /**
978
+ * Caption and files are admitted as one relay transaction. The authenticated
979
+ * route and optional source wire are fixed before bytes are retrieved, and no
980
+ * outbound part is emitted until every file passes admission. A transport
981
+ * failure after emission starts is durably uncertain and never blind-retried;
982
+ * the managed agent receives one bounded NACK for the whole transaction.
983
+ */
984
+ async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
985
+ const captionWire = group.caption ? this.wireId(group.caption) : undefined;
986
+ const nackWire = captionWire ?? group.files[0].wireId;
987
+ const nackMessage = group.caption ?? { wire_id: nackWire };
988
+ let requestDir;
989
+ let recovery;
990
+ try {
991
+ if (!this.authorizationIntegrity().ok)
992
+ throw new Error('owner authorization state is corrupt; managed-agent relay is disabled');
993
+ const caption = group.caption ? this.safeRelayMessage(group.caption.text) : undefined;
994
+ const replyTo = this.managedAttachmentReplyWire(group, captionWire);
995
+ let route;
996
+ try {
997
+ route = replyTo
998
+ ? this.conversations.routeForWire(replyTo, this.effectiveOwners())
999
+ : this.conversations.route(this.effectiveOwners());
1000
+ }
1001
+ catch (error) {
1002
+ throw new RelayUnroutableError(this.errorText(error));
1003
+ }
1004
+ if (!this.isEffectiveOwner(route.contact))
1005
+ throw new Error('selected attachment relay owner is no longer authorized');
1006
+ const contact = await this.routableContact(route);
1007
+ const rejection = !this.attachmentRecovery.integrity()
1008
+ ? 'attachment recovery state is unavailable'
1009
+ : validateAttachmentSelection(group.files, this.attachmentConfig);
1010
+ if (rejection)
1011
+ throw new Error(rejection);
1012
+ const transactionId = this.requestId(`managed-agent-attachment:${handledWireIds.slice().sort().join(':')}`);
1013
+ recovery = group.recovery ?? {
1014
+ id: transactionId, contact: agent, originWireId: captionWire ?? group.files[0].wireId,
1015
+ fileWireIds: group.files.map(file => file.wireId), createdAt: Date.now(),
1016
+ };
1017
+ if (!group.recovery)
1018
+ this.attachmentRecovery.add(recovery);
1019
+ requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
1020
+ const unread = group.files.filter(file => file.status === 'unread');
1021
+ const processed = group.files.filter(file => file.status !== 'unread');
1022
+ const retrieved = unread.length
1023
+ ? parseRetrievedAttachments(await this.client.callTool('get_files', {
1024
+ wire_ids: unread.map(file => file.wireId),
1025
+ }), unread)
1026
+ : [];
1027
+ for (const file of processed) {
1028
+ if (!group.recovery)
1029
+ throw new Error('unexpected processed attachment without recovery route');
1030
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
1031
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
1032
+ retrieved.push(await recoveredAttachment(file, recoveryPath));
1033
+ }
1034
+ const order = new Map(group.files.map((file, index) => [file.wireId, index]));
1035
+ retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
1036
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
1037
+ const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
1038
+ const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
1039
+ try {
1040
+ if (caption)
1041
+ await this.send(contact, caption, replyTo);
1042
+ for (const file of admitted) {
1043
+ await this.client.callTool('send_file', {
1044
+ contact, path: file.path, filename: file.filename,
1045
+ ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1046
+ });
1047
+ }
1048
+ }
1049
+ catch {
1050
+ try {
1051
+ this.conversations.finishSend(sending.id, 'uncertain');
1052
+ }
1053
+ catch (error) {
1054
+ this.logError('managed-agent attachment uncertainty persist failed', error);
1055
+ }
1056
+ throw new Error('caption/file relay delivery outcome is uncertain; it was not retried');
1057
+ }
1058
+ this.conversations.finishSend(sending.id, 'delivered');
1059
+ for (const wire of handledWireIds)
1060
+ this.state.remember(wire);
1061
+ this.attachmentRecovery.remove(recovery.id);
1062
+ this.options.log(`[${this.options.role}] managed-agent attachment transaction relayed `
1063
+ + `wires=${handledWireIds.length} basis=${route.basis} files=${admitted.length} `
1064
+ + `bytes=${admitted.reduce((total, file) => total + file.size, 0)}`);
1065
+ return true;
1066
+ }
1067
+ catch (error) {
1068
+ if (error instanceof DuplicateSendError) {
1069
+ this.options.log(`[${this.options.role}] managed-agent attachment replay consumed`);
1070
+ for (const wire of handledWireIds)
1071
+ this.state.remember(wire);
1072
+ if (recovery)
1073
+ try {
1074
+ this.attachmentRecovery.remove(recovery.id);
1075
+ }
1076
+ catch { }
1077
+ return true;
1078
+ }
1079
+ if (error instanceof RelayUnroutableError) {
1080
+ this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
1081
+ + `transaction stays queued: ${this.errorText(error)}`);
1082
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
1083
+ return false;
1084
+ }
1085
+ this.logError('managed-agent caption/file relay refused', error);
1086
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1087
+ // Rejection/admission failure and uncertain transport are terminal and
1088
+ // visible. Consuming every correlated wire prevents a later partial replay.
1089
+ for (const wire of handledWireIds)
1090
+ this.state.remember(wire);
1091
+ if (recovery)
1092
+ try {
1093
+ this.attachmentRecovery.remove(recovery.id);
1094
+ }
1095
+ catch { }
1096
+ return true;
1097
+ }
1098
+ finally {
1099
+ if (requestDir)
1100
+ await removeRequestDirectory(requestDir).catch(error => {
1101
+ this.logError('managed-agent attachment cleanup failed', error);
1102
+ });
1103
+ }
1104
+ }
1105
+ managedAttachmentReplyWire(group, captionWire) {
1106
+ const captionReply = group.caption?.reply_to?.wire_id;
1107
+ const candidates = new Set();
1108
+ if (captionReply)
1109
+ candidates.add(captionReply);
1110
+ for (const file of group.files) {
1111
+ const wire = file.replyTo?.wire_id;
1112
+ if (!wire || wire === captionWire)
1113
+ continue;
1114
+ candidates.add(wire);
1115
+ }
1116
+ if (candidates.size > 1)
1117
+ throw new Error('managed-agent caption/file group has conflicting owner reply wires');
1118
+ return candidates.values().next().value;
1119
+ }
889
1120
  /**
890
1121
  * One bounded NACK per wire: an unroutable or refused relay must be visible
891
1122
  * to the authenticated agent, while its deferred replays stay quiet. NACK
@@ -70,6 +70,16 @@ export class OursMcpClient {
70
70
  this.child = undefined;
71
71
  if (!child || child.exitCode !== null)
72
72
  return;
73
+ // EOF lets the proxy close its HTTP MCP transport and release the daemon
74
+ // lease explicitly. SIGTERM used to leave that release racing the next
75
+ // supervised start, which is the owner-channel collision this path guards.
76
+ child.stdin.end();
77
+ const exited = await new Promise(resolve => {
78
+ const timer = setTimeout(() => resolve(false), 1_000);
79
+ child.once('exit', () => { clearTimeout(timer); resolve(true); });
80
+ });
81
+ if (exited || child.exitCode !== null)
82
+ return;
73
83
  child.kill('SIGTERM');
74
84
  await new Promise(resolve => {
75
85
  const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 5_000);
@@ -14,6 +14,7 @@ export declare const ownerNotices: {
14
14
  commandFailed: (command: string) => string;
15
15
  commandUnsupported: (command: string, harness: string) => string;
16
16
  restarting: (role: string, command: string, mode: "keep" | "fresh") => string;
17
+ startupHandoffFailed: (role: string, identity: string) => string;
17
18
  attachmentRejected: (reason: string) => string;
18
19
  attachmentFailed: () => string;
19
20
  deliveryFailed: (role: string) => string;
@@ -39,6 +39,9 @@ export const ownerNotices = {
39
39
  restarting: (role, command, mode) => `ℹ️ ${command} accepted — restarting ${role} ${mode === 'fresh'
40
40
  ? 'FRESH (context wiped)' : '(context resumes)'}. `
41
41
  + 'The channel goes quiet during the restart and resumes when the agent is back.',
42
+ startupHandoffFailed: (role, identity) => `⚠️ ${role} owner channel could not take over '${identity}' from its previous supervisor. `
43
+ + `Recovery: send /restart to retry the supervised handoff; if this repeats, inspect the web `
44
+ + `console or run ours-fleet logs ${role}.`,
42
45
  attachmentRejected: (reason) => `⚠️ Attachment rejected: ${reason}.`,
43
46
  attachmentFailed: () => '⚠️ Could not securely retrieve or admit this attachment request.',
44
47
  deliveryFailed: (role) => `⚠️ Could not deliver this request to ${role}.`,