@ai-workbench/remote-agent 0.8.12 → 0.8.14

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.
@@ -1,3 +1,4 @@
1
+ import { type CloseCause, type CloseDiagnostics, type CloseObserver } from './close-diagnostics.ts';
1
2
  import type { ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  import { NeedError } from './remote.ts';
3
4
  export type EmployeeMessage = Record<string, unknown>;
@@ -5,10 +6,11 @@ export interface EmployeeExecutionTransport {
5
6
  write(message: EmployeeMessage, signal?: AbortSignal): Promise<void>;
6
7
  read(signal?: AbortSignal): Promise<EmployeeMessage | null>;
7
8
  /** Closing a carrier never proves that an employee command completed. */
8
- close(): void | Promise<void>;
9
+ close(cause?: CloseCause): void | Promise<void>;
9
10
  }
10
11
  export interface EmployeeExecutionOptions {
11
12
  bindingId: string;
13
+ closeDiagnostics?: CloseDiagnostics;
12
14
  acpMaxMessageBytes: number;
13
15
  /** Rechecks the current L CAS/directory/account and N/S five-tuple. No cached permit. */
14
16
  validate(signal: AbortSignal): Promise<void>;
@@ -42,12 +44,13 @@ export declare class EmployeeExecution {
42
44
  private gate;
43
45
  private stop;
44
46
  private closeTransport;
45
- invalidate(error?: NeedError): void;
47
+ invalidate(error?: NeedError, cause?: CloseCause): void;
46
48
  private cleanup;
47
- dispose(): Promise<void>;
49
+ dispose(cause?: CloseCause): Promise<void>;
48
50
  handle(params: unknown, signal?: AbortSignal): Promise<EmployeeExecutionResult>;
51
+ private handleRequest;
49
52
  private open;
50
53
  private read;
51
54
  }
52
55
  /** NDJSON carrier with byte bounds and backpressure derived solely from acpMaxMessageBytes. */
53
- export declare function createEmployeeProcessTransport(child: ChildProcessWithoutNullStreams, acpMaxMessageBytes: number, stderrCodes?: readonly string[]): EmployeeExecutionTransport;
56
+ export declare function createEmployeeProcessTransport(child: ChildProcessWithoutNullStreams, acpMaxMessageBytes: number, stderrCodes?: readonly string[], observe?: CloseObserver): EmployeeExecutionTransport;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { NeedError } from "./remote.js";
3
4
  const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
@@ -80,7 +81,8 @@ export class EmployeeExecution {
80
81
  active() { if (this.ended)
81
82
  throw this.error ?? need('EMPLOYEE_EXEC_CLOSED'); }
82
83
  async gate(signal) { this.active(); cancelled(signal); await waiting(this.options.validate(signal), signal); this.active(); cancelled(signal); }
83
- stop(error) {
84
+ stop(error, cause = { layer: 'executor', initiator: 'executor.stop', reason: closeCode(error, 'EMPLOYEE_EXEC_CLOSED'), action: 'close' }, observe = this.options.closeDiagnostics?.capture()) {
85
+ observe?.({ ...cause, changes: cause.changes ?? [{ field: 'executor.ended', before: this.ended, after: true }] });
84
86
  if (this.ended)
85
87
  return;
86
88
  this.ended = true;
@@ -89,12 +91,13 @@ export class EmployeeExecution {
89
91
  const io = this.io;
90
92
  this.io = undefined;
91
93
  if (io)
92
- this.closeTransport(io);
94
+ this.closeTransport(io, undefined, observe);
93
95
  }
94
- closeTransport(io) {
96
+ closeTransport(io, cause = { layer: 'executor', initiator: 'executor.transport-cleanup', reason: 'EXECUTOR_CLEANUP', action: 'close' }, observe = this.options.closeDiagnostics?.capture()) {
97
+ observe?.(cause);
95
98
  let closing;
96
99
  try {
97
- closing = Promise.resolve(io.close());
100
+ closing = Promise.resolve(io.close(cause));
98
101
  }
99
102
  catch {
100
103
  closing = Promise.reject(need('EMPLOYEE_EXEC_UNKNOWN'));
@@ -103,28 +106,32 @@ export class EmployeeExecution {
103
106
  // Revocation starts cleanup without a caller waiting yet; retain rejection for close/dispose.
104
107
  void this.closing.catch(() => { });
105
108
  }
106
- invalidate(error = need('EMPLOYEE_CONTEXT_CHANGED')) { this.stop(error); }
109
+ invalidate(error = need('EMPLOYEE_CONTEXT_CHANGED'), cause) { this.stop(error, cause ?? { layer: 'executor', initiator: 'executor.invalidate', reason: error.code, action: 'invalidate' }); }
107
110
  async cleanup() { await this.factoryReceipt; await this.closing; }
108
- async dispose() { this.stop(); await this.cleanup(); }
111
+ async dispose(cause = { layer: 'executor', initiator: 'executor.dispose', reason: 'EXECUTOR_DISPOSE', action: 'dispose' }) { this.stop(undefined, cause); await this.cleanup(); }
109
112
  async handle(params, signal) {
110
- const request = this.parse(params);
113
+ const request = this.parse(params), observe = this.options.closeDiagnostics?.capture();
114
+ const run = () => this.handleRequest(request, signal, observe);
115
+ return this.options.closeDiagnostics ? this.options.closeDiagnostics.run('employee-exec.' + request.operation, run) : run();
116
+ }
117
+ async handleRequest(request, signal, observe) {
111
118
  cancelled(signal);
112
119
  if (request.operation === 'close') {
113
- this.stop();
120
+ this.stop(undefined, { layer: 'executor', initiator: 'remote.employee-exec.close', reason: 'REMOTE_REASON_UNAVAILABLE', remoteReason: 'unavailable', operation: 'close', action: 'close' }, observe);
114
121
  await this.cleanup();
115
122
  return { version: 1, closed: true, result: 'UNKNOWN' };
116
123
  }
117
124
  this.active();
118
125
  const current = signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal;
119
126
  if (request.operation === 'open')
120
- return this.open(current);
127
+ return this.open(current, observe);
121
128
  if (request.operation === 'read')
122
- return this.read(current);
129
+ return this.read(current, observe);
123
130
  const value = encoded(request.message, this.options.acpMaxMessageBytes);
124
131
  const size = value.line.byteLength;
125
132
  if (this.queuedWriteBytes + size > this.options.acpMaxMessageBytes) {
126
133
  const error = need('ACP_MESSAGE_TOO_LARGE');
127
- this.stop(error);
134
+ this.stop(error, { layer: 'executor', initiator: 'executor.write-capacity', reason: error.code, operation: 'write', action: 'failure' }, observe);
128
135
  throw error;
129
136
  }
130
137
  this.queuedWriteBytes += size;
@@ -140,7 +147,7 @@ export class EmployeeExecution {
140
147
  }
141
148
  catch (error) {
142
149
  const known = this.error ?? failure(error);
143
- this.stop(known);
150
+ this.stop(known, { layer: 'executor', initiator: 'executor.write-failure', reason: known.code, operation: 'write', action: 'failure' }, observe);
144
151
  throw known;
145
152
  }
146
153
  finally {
@@ -150,7 +157,7 @@ export class EmployeeExecution {
150
157
  this.writeTail = operation.catch(() => { });
151
158
  return operation;
152
159
  }
153
- async open(signal) {
160
+ async open(signal, observe) {
154
161
  if (this.opening || this.executionId)
155
162
  throw need('EMPLOYEE_EXEC_ALREADY_OPEN');
156
163
  this.opening = true;
@@ -158,7 +165,7 @@ export class EmployeeExecution {
158
165
  await this.gate(signal);
159
166
  // A factory may finish after revoke/abort. Its late executor belongs to this binding and must close.
160
167
  const created = Promise.resolve().then(() => { cancelled(signal); return this.options.transport(signal); }).then(async (io) => { if (this.ended || signal.aborted) {
161
- this.closeTransport(io);
168
+ this.closeTransport(io, { layer: 'executor', initiator: 'executor.late-factory', reason: this.error?.code ?? 'ABORTED', operation: 'open', action: 'close' }, observe);
162
169
  await this.closing;
163
170
  throw this.error ?? need('ABORTED');
164
171
  } this.io = io; return io; });
@@ -176,14 +183,14 @@ export class EmployeeExecution {
176
183
  }
177
184
  catch (error) {
178
185
  const known = this.error ?? failure(error);
179
- this.stop(known);
186
+ this.stop(known, { layer: 'executor', initiator: 'executor.open-failure', reason: known.code, operation: 'open', action: 'failure' }, observe);
180
187
  throw known;
181
188
  }
182
189
  finally {
183
190
  this.opening = false;
184
191
  }
185
192
  }
186
- async read(signal) {
193
+ async read(signal, observe) {
187
194
  if (this.pendingRead)
188
195
  throw need('EMPLOYEE_EXEC_READ_BUSY');
189
196
  this.pendingRead = true;
@@ -201,7 +208,7 @@ export class EmployeeExecution {
201
208
  }
202
209
  await this.gate(signal);
203
210
  if (message === null) {
204
- this.stop();
211
+ this.stop(undefined, { layer: 'executor', initiator: 'executor.read-eof', reason: 'EXECUTOR_EOF', operation: 'read', action: 'eof' }, observe);
205
212
  return { version: 1, messages: [], closed: true };
206
213
  }
207
214
  const value = encoded(message, this.options.acpMaxMessageBytes);
@@ -215,7 +222,7 @@ export class EmployeeExecution {
215
222
  if (signal.aborted && !this.ended) {
216
223
  if (consumed) {
217
224
  const unknown = need('EMPLOYEE_EXEC_UNKNOWN');
218
- this.stop(unknown);
225
+ this.stop(unknown, { layer: 'executor', initiator: 'executor.read-consumed-cancel', reason: unknown.code, operation: 'read', action: 'abort' }, observe);
219
226
  throw unknown;
220
227
  }
221
228
  throw need('ABORTED');
@@ -223,7 +230,7 @@ export class EmployeeExecution {
223
230
  if (this.ended && !this.error)
224
231
  return { version: 1, messages: [], closed: true };
225
232
  const known = failure(error);
226
- this.stop(known);
233
+ this.stop(known, { layer: 'executor', initiator: 'executor.read-failure', reason: known.code, operation: 'read', action: 'failure' }, observe);
227
234
  throw known;
228
235
  }
229
236
  finally {
@@ -232,7 +239,7 @@ export class EmployeeExecution {
232
239
  }
233
240
  }
234
241
  /** NDJSON carrier with byte bounds and backpressure derived solely from acpMaxMessageBytes. */
235
- export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderrCodes = []) {
242
+ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderrCodes = [], observe) {
236
243
  capacity(acpMaxMessageBytes);
237
244
  let partial = Buffer.alloc(0), queuedBytes = 0, writingBytes = 0, closed = false, reason;
238
245
  let processClosed = false, terminationRequested = false;
@@ -247,6 +254,7 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
247
254
  const settle = () => { if (!pending)
248
255
  return; const reader = pending; pending = undefined; reader.clear(); reason ? reader.reject(reason) : reader.resolve(null); };
249
256
  const terminate = () => {
257
+ observe?.({ layer: 'relay', initiator: 'ssh.terminate', reason: 'TRANSPORT_CLEANUP', action: 'terminate', changes: [{ field: 'transport.termination-requested', before: terminationRequested, after: processClosed || terminationRequested ? terminationRequested : true }] });
250
258
  if (processClosed || terminationRequested)
251
259
  return;
252
260
  terminationRequested = true;
@@ -267,7 +275,8 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
267
275
  receipt.reject(need('EMPLOYEE_EXEC_UNKNOWN'));
268
276
  }
269
277
  };
270
- const finish = (error, kill = false) => {
278
+ const finish = (error, kill = false, cause = { layer: 'relay', initiator: 'ssh.protocol-failure', reason: closeCode(error, 'TRANSPORT_CLOSED'), action: 'failure' }) => {
279
+ observe?.({ ...cause, changes: cause.changes ?? [{ field: 'transport.closed', before: closed, after: true }] });
271
280
  if (!closed) {
272
281
  closed = true;
273
282
  reason = error;
@@ -353,9 +362,10 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
353
362
  }
354
363
  });
355
364
  child.stderr.resume();
356
- child.stdin.on('error', () => finish(need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN'), true));
357
- child.once('error', () => finish(need('EMPLOYEE_EXEC_UNKNOWN'), true));
358
- child.once('close', (code, signal) => { processClosed = true; finish(partial.length ? need('EMPLOYEE_EXEC_PROTOCOL_INVALID') : code === 0 && !signal ? undefined : need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN')); receipt.resolve(); });
365
+ child.stdin.on('error', () => finish(need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.stdin-error', reason: diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN', action: 'failure' }));
366
+ child.once('error', () => finish(need('EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.process-error', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'failure' }));
367
+ child.prependOnceListener('close', (code, signal) => observe?.({ layer: 'relay', initiator: 'ssh.process.close', reason: diagnosticCode ?? (code === 0 && !signal ? 'PROCESS_EXIT' : 'EMPLOYEE_EXEC_UNKNOWN'), action: 'exit', exitCode: code, signal }));
368
+ child.once('close', (code, signal) => { processClosed = true; finish(partial.length ? need('EMPLOYEE_EXEC_PROTOCOL_INVALID') : code === 0 && !signal ? undefined : need(diagnosticCode ?? 'EMPLOYEE_EXEC_UNKNOWN'), false, { layer: 'relay', initiator: 'ssh.process-close-settle', reason: diagnosticCode ?? (code === 0 && !signal ? 'PROCESS_EXIT' : 'EMPLOYEE_EXEC_UNKNOWN'), action: 'exit', exitCode: code, signal }); receipt.resolve(); });
359
369
  return {
360
370
  write: async (message, signal) => {
361
371
  cancelled(signal);
@@ -364,7 +374,7 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
364
374
  const value = encoded(message, acpMaxMessageBytes);
365
375
  if (writingBytes + value.line.byteLength > acpMaxMessageBytes) {
366
376
  const error = need('ACP_MESSAGE_TOO_LARGE');
367
- finish(error, true);
377
+ finish(error, true, { layer: 'relay', initiator: 'ssh.write-capacity', reason: error.code, operation: 'write', action: 'failure' });
368
378
  throw error;
369
379
  }
370
380
  writingBytes += value.line.byteLength;
@@ -374,19 +384,19 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
374
384
  return false; done = true; writingBytes -= value.line.byteLength; writes.delete(fail); signal?.removeEventListener('abort', abort); return true; };
375
385
  const fail = (error) => { if (cleanup())
376
386
  reject(error); };
377
- const abort = () => { const error = need('EMPLOYEE_EXEC_UNKNOWN'); finish(error, true); fail(error); };
387
+ const abort = () => { const error = need('EMPLOYEE_EXEC_UNKNOWN'); finish(error, true, { layer: 'relay', initiator: 'ssh.write-abort', reason: error.code, operation: 'write', action: 'abort' }); fail(error); };
378
388
  writes.add(fail);
379
389
  signal?.addEventListener('abort', abort, { once: true });
380
390
  try {
381
391
  child.stdin.write(value.line, error => { if (error) {
382
- finish(need('EMPLOYEE_EXEC_UNKNOWN'), true);
392
+ finish(need('EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.write-callback', reason: 'EMPLOYEE_EXEC_UNKNOWN', operation: 'write', action: 'failure' });
383
393
  fail(need('EMPLOYEE_EXEC_UNKNOWN'));
384
394
  }
385
395
  else if (cleanup())
386
396
  resolve(); });
387
397
  }
388
398
  catch {
389
- finish(need('EMPLOYEE_EXEC_UNKNOWN'), true);
399
+ finish(need('EMPLOYEE_EXEC_UNKNOWN'), true, { layer: 'relay', initiator: 'ssh.write-call', reason: 'EMPLOYEE_EXEC_UNKNOWN', operation: 'write', action: 'failure' });
390
400
  fail(need('EMPLOYEE_EXEC_UNKNOWN'));
391
401
  }
392
402
  if (signal?.aborted)
@@ -418,6 +428,6 @@ export function createEmployeeProcessTransport(child, acpMaxMessageBytes, stderr
418
428
  abort();
419
429
  });
420
430
  },
421
- close: () => { finish(undefined, true); queue.length = 0; queuedBytes = 0; return receipt.promise; }
431
+ close: (cause = { layer: 'relay', initiator: 'ssh.transport-close', reason: 'TRANSPORT_CLOSE', action: 'close' }) => { finish(undefined, true, cause); queue.length = 0; queuedBytes = 0; return receipt.promise; }
422
432
  };
423
433
  }
@@ -1,3 +1,4 @@
1
+ import { type CloseCause, type CloseDiagnostics, type CloseObserver } from './close-diagnostics.ts';
1
2
  import { type Socket } from 'node:net';
2
3
  import { type JSONRPCMessage, type Tool } from '@modelcontextprotocol/sdk/types.js';
3
4
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
@@ -10,6 +11,7 @@ export declare const EMPLOYEE_MCP_TOOLS: Tool[];
10
11
  export declare class EmployeeSocketTransport implements Transport {
11
12
  private socket;
12
13
  private maxBytes;
14
+ private observeClose?;
13
15
  onclose?: () => void;
14
16
  onerror?: (error: Error) => void;
15
17
  onmessage?: (message: JSONRPCMessage) => void;
@@ -19,13 +21,14 @@ export declare class EmployeeSocketTransport implements Transport {
19
21
  get error(): Error | undefined;
20
22
  private readonly lifetime;
21
23
  readonly signal: AbortSignal;
22
- constructor(socket: Socket, maxBytes: number);
24
+ constructor(socket: Socket, maxBytes: number, observeClose?: CloseObserver | undefined);
23
25
  private failed;
24
26
  start(): Promise<void>;
25
27
  send(message: JSONRPCMessage): Promise<void>;
26
- close(): Promise<void>;
28
+ close(cause?: CloseCause): Promise<void>;
27
29
  }
28
30
  export interface EmployeeMcpOptions {
31
+ closeDiagnostics?: CloseDiagnostics;
29
32
  lease: EmployeeLease;
30
33
  maxBytes: number;
31
34
  killGraceMs: number;
@@ -37,6 +40,6 @@ export interface EmployeeMcpOptions {
37
40
  export interface EmployeeMcp {
38
41
  routing: ReturnType<typeof employeeRouting>;
39
42
  signal: AbortSignal;
40
- close(): Promise<void>;
43
+ close(cause?: CloseCause): Promise<void>;
41
44
  }
42
45
  export declare function openEmployeeMcp(options: EmployeeMcpOptions): Promise<EmployeeMcp>;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { createServer } from 'node:net';
2
3
  import { mkdtemp, lstat, rm, access } from 'node:fs/promises';
3
4
  import { constants } from 'node:fs';
@@ -20,6 +21,7 @@ export const EMPLOYEE_MCP_TOOLS = [
20
21
  export class EmployeeSocketTransport {
21
22
  socket;
22
23
  maxBytes;
24
+ observeClose;
23
25
  onclose;
24
26
  onerror;
25
27
  onmessage;
@@ -29,9 +31,10 @@ export class EmployeeSocketTransport {
29
31
  get error() { return this.failure; }
30
32
  lifetime = new AbortController();
31
33
  signal = this.lifetime.signal;
32
- constructor(socket, maxBytes) {
34
+ constructor(socket, maxBytes, observeClose) {
33
35
  this.socket = socket;
34
36
  this.maxBytes = maxBytes;
37
+ this.observeClose = observeClose;
35
38
  }
36
39
  failed(error) { try {
37
40
  this.onerror?.(error);
@@ -40,11 +43,11 @@ export class EmployeeSocketTransport {
40
43
  this.failure = observation instanceof Error ? observation : new Error('EMPLOYEE_MCP_OBSERVATION_FAILED');
41
44
  }
42
45
  finally {
43
- void this.close();
46
+ void this.close({ layer: 'relay', initiator: 'mcp.socket-failure', reason: closeCode(error, 'EMPLOYEE_MCP_PROTOCOL_OR_SOCKET_ERROR'), action: 'failure' });
44
47
  } }
45
48
  async start() {
46
49
  this.socket.on('error', error => this.failed(error));
47
- this.socket.on('close', () => void this.close());
50
+ this.socket.on('close', () => void this.close({ layer: 'relay', initiator: 'mcp.socket-peer-close', reason: 'MCP_SOCKET_CLOSED', action: 'close' }));
48
51
  this.socket.on('data', (bytes) => {
49
52
  try {
50
53
  let cursor = 0;
@@ -71,12 +74,12 @@ export class EmployeeSocketTransport {
71
74
  throw new Error('EMPLOYEE_MCP_CLOSED');
72
75
  const wire = Buffer.from(JSON.stringify(message) + '\n');
73
76
  if (wire.length - 1 > this.maxBytes) {
74
- await this.close();
77
+ await this.close({ layer: 'relay', initiator: 'mcp.send-capacity', reason: 'EMPLOYEE_MCP_MESSAGE_TOO_LARGE', action: 'close' });
75
78
  throw new Error('EMPLOYEE_MCP_MESSAGE_TOO_LARGE');
76
79
  }
77
80
  await new Promise((yes, no) => this.socket.write(wire, error => error ? no(error) : yes()));
78
81
  }
79
- async close() { if (this.ended)
82
+ async close(cause = { layer: 'relay', initiator: 'mcp.transport-close', reason: 'MCP_TRANSPORT_CLOSED', action: 'close' }) { this.observeClose?.({ ...cause, changes: cause.changes ?? [{ field: 'mcp.ended', before: this.ended, after: true }] }); if (this.ended)
80
83
  return; this.ended = true; this.lifetime.abort(); this.socket.destroy(); this.onclose?.(); }
81
84
  }
82
85
  export async function openEmployeeMcp(options) {
@@ -85,7 +88,7 @@ export async function openEmployeeMcp(options) {
85
88
  if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)
86
89
  throw new NeedError('SETTINGS_REQUIRED', '本机工具消息容量无效。', 'acpMaxMessageBytes');
87
90
  await access('/usr/bin/nc', constants.X_OK);
88
- const tools = createEmployeeTools({ cwd: options.lease.target.local.directory.path, shell: options.lease.target.endpoint.endpointShell, uid: options.lease.target.endpoint.remoteUid, maxBytes: options.maxBytes, killGraceMs: options.killGraceMs, detail: observe, runShell: options.runShell, validate: signal => options.lease.validate(signal).then(() => { }) });
91
+ const tools = createEmployeeTools({ closeDiagnostics: options.closeDiagnostics, cwd: options.lease.target.local.directory.path, shell: options.lease.target.endpoint.endpointShell, uid: options.lease.target.endpoint.remoteUid, maxBytes: options.maxBytes, killGraceMs: options.killGraceMs, detail: observe, runShell: options.runShell, validate: signal => options.lease.validate(signal).then(() => { }) });
89
92
  const root = await mkdtemp('/private/tmp/aw-x87-'), socketPath = root + '/mcp';
90
93
  try {
91
94
  const info = await lstat(root);
@@ -93,19 +96,40 @@ export async function openEmployeeMcp(options) {
93
96
  throw new NeedError('EMPLOYEE_MCP_STORAGE_INVALID', '本机工具私有通道不可用。', '当前员工的700私有Unix目录');
94
97
  }
95
98
  catch (error) {
96
- await tools.close();
97
- await rm(root, { recursive: true });
99
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.storage-failure', reason: closeCode(error), action: 'failure' });
100
+ const failures = [error];
101
+ try {
102
+ await tools.close({ layer: 'executor', initiator: 'mcp.storage-cleanup', reason: 'MCP_STORAGE_FAILED', action: 'close' });
103
+ }
104
+ catch (cleanup) {
105
+ failures.push(cleanup);
106
+ }
107
+ try {
108
+ await rm(root, { recursive: true });
109
+ }
110
+ catch (cleanup) {
111
+ failures.push(cleanup);
112
+ }
113
+ try {
114
+ options.closeDiagnostics?.assertRecorded();
115
+ }
116
+ catch (reporting) {
117
+ failures.push(reporting);
118
+ }
119
+ if (failures.length > 1)
120
+ throw new AggregateError(failures, 'EMPLOYEE_MCP_STORAGE_AND_CLEANUP_FAILED');
98
121
  throw error;
99
122
  }
100
123
  const clients = new Set(), transports = new Set();
101
124
  let closing, diagnosticFailure;
102
125
  const failureLifetime = new AbortController();
103
126
  const poison = (error) => {
127
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.poison', reason: closeCode(error, 'CONVERSATION_DETAILS_FAILED'), action: 'invalidate' });
104
128
  diagnosticFailure ??= error;
105
129
  failureLifetime.abort(error);
106
130
  // Close is memoized synchronously: no fresh connection/call can pass the fence.
107
- void options.lease.close().catch(failure => { diagnosticFailure ??= failure; });
108
- void close().catch(failure => { diagnosticFailure ??= failure; });
131
+ void options.lease.close({ layer: 'endpoint', initiator: 'mcp.poison-lease', reason: 'CONVERSATION_DETAILS_FAILED', action: 'close' }).catch(failure => { diagnosticFailure ??= failure; });
132
+ void close({ layer: 'relay', initiator: 'mcp.poison-close', reason: 'CONVERSATION_DETAILS_FAILED', action: 'close' }).catch(failure => { diagnosticFailure ??= failure; });
109
133
  };
110
134
  function observeUsing(event, sink) { if (diagnosticFailure)
111
135
  throw diagnosticFailure; try {
@@ -118,10 +142,12 @@ export async function openEmployeeMcp(options) {
118
142
  function observe(event) { observeUsing(event, options.detail); }
119
143
  const server = createServer(socket => {
120
144
  if (closing || failureLifetime.signal.aborted || options.lease.signal.aborted) {
145
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.reject-late-socket', reason: 'MCP_CLOSING', action: 'close' });
121
146
  socket.destroy();
122
147
  return;
123
148
  }
124
- const mcp = new Server({ name: 'ai-workbench-employee', version: '0.8.12' }, { capabilities: { tools: {} } }), transport = new EmployeeSocketTransport(socket, options.maxBytes);
149
+ const observeConnection = options.closeDiagnostics?.child().capture();
150
+ const mcp = new Server({ name: 'ai-workbench-employee', version: '0.8.14' }, { capabilities: { tools: {} } }), transport = new EmployeeSocketTransport(socket, options.maxBytes, observeConnection);
125
151
  clients.add(mcp);
126
152
  transports.add(transport);
127
153
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: EMPLOYEE_MCP_TOOLS }));
@@ -133,40 +159,50 @@ export async function openEmployeeMcp(options) {
133
159
  const sink = options.captureDetail?.() ?? options.detail;
134
160
  return tools.call(request.params.name, request.params.arguments ?? {}, signal, event => observeUsing(event, sink));
135
161
  });
136
- mcp.onerror = () => { try {
162
+ mcp.onerror = () => { observeConnection?.({ layer: 'relay', initiator: 'mcp.server-error', reason: 'EMPLOYEE_MCP_PROTOCOL_OR_TRANSPORT_ERROR', action: 'failure' }); try {
137
163
  observe({ kind: 'session', severity: 'warning', code: 'CLAUDE_EMPLOYEE_MCP_UNKNOWN', summary: '本机工具连接出现协议或传输错误;在途结果未知,没有重放' });
138
164
  }
139
165
  catch (error) {
140
166
  poison(error);
141
167
  } };
142
- mcp.onclose = () => { clients.delete(mcp); transports.delete(transport); };
143
- void mcp.connect(transport).catch(() => { socket.destroy(); });
168
+ mcp.onclose = () => { observeConnection?.({ layer: 'relay', initiator: 'mcp.client-closed', reason: 'MCP_CLIENT_CLOSED', action: 'close' }); clients.delete(mcp); transports.delete(transport); };
169
+ void mcp.connect(transport).catch(error => { observeConnection?.({ layer: 'relay', initiator: 'mcp.connect-failure', reason: closeCode(error, 'MCP_CONNECT_FAILED'), action: 'close' }); socket.destroy(); });
144
170
  });
145
- const close = () => closing ??= Promise.resolve().then(async () => {
146
- options.lease.signal.removeEventListener('abort', aborted);
147
- const errors = [], clean = async (operation) => { try {
148
- await operation();
149
- }
150
- catch (error) {
151
- if (!errors.includes(error))
171
+ const close = (cause = { layer: 'relay', initiator: 'mcp.close', reason: 'MCP_CLOSE_REQUESTED', action: 'close' }) => {
172
+ options.closeDiagnostics?.record({ ...cause, changes: cause.changes ?? [{ field: 'mcp.closing', before: !!closing, after: true }] });
173
+ return closing ??= Promise.resolve().then(async () => {
174
+ options.lease.signal.removeEventListener('abort', aborted);
175
+ const errors = [], clean = async (operation) => { try {
176
+ await operation();
177
+ }
178
+ catch (error) {
179
+ if (!errors.includes(error))
180
+ errors.push(error);
181
+ } };
182
+ for (const transport of transports)
183
+ await clean(() => transport.close({ layer: 'relay', initiator: 'mcp.cleanup-transport', reason: 'MCP_CLEANUP', action: 'close' }));
184
+ await Promise.all([...clients].map(client => clean(() => client.close())));
185
+ await clean(() => tools.close({ layer: 'executor', initiator: 'mcp.cleanup-tools', reason: 'MCP_CLEANUP', action: 'close' }));
186
+ await clean(() => options.lease.close({ layer: 'endpoint', initiator: 'mcp.cleanup-lease', reason: 'MCP_CLEANUP', action: 'close' }));
187
+ options.closeDiagnostics?.record({ layer: 'relay', initiator: 'mcp.server-close', reason: 'MCP_CLEANUP', action: 'close' });
188
+ if (server.listening)
189
+ await clean(() => new Promise((yes, no) => server.close(error => error ? no(error) : yes())));
190
+ await clean(() => rm(root, { recursive: true }));
191
+ if (diagnosticFailure && !errors.includes(diagnosticFailure))
192
+ errors.unshift(diagnosticFailure);
193
+ try {
194
+ options.closeDiagnostics?.assertRecorded();
195
+ }
196
+ catch (error) {
152
197
  errors.push(error);
153
- } };
154
- for (const transport of transports)
155
- await clean(() => transport.close());
156
- await Promise.all([...clients].map(client => clean(() => client.close())));
157
- await clean(() => tools.close());
158
- await clean(() => options.lease.close());
159
- if (server.listening)
160
- await clean(() => new Promise((yes, no) => server.close(error => error ? no(error) : yes())));
161
- await clean(() => rm(root, { recursive: true }));
162
- if (diagnosticFailure && !errors.includes(diagnosticFailure))
163
- errors.unshift(diagnosticFailure);
164
- if (errors.length === 1)
165
- throw errors[0];
166
- if (errors.length)
167
- throw new AggregateError(errors, 'EMPLOYEE_MCP_CLEANUP_FAILED');
168
- });
169
- const aborted = () => { void close().catch(error => { diagnosticFailure ??= error; failureLifetime.abort(error); }); };
198
+ }
199
+ if (errors.length === 1)
200
+ throw errors[0];
201
+ if (errors.length)
202
+ throw new AggregateError(errors, 'EMPLOYEE_MCP_CLEANUP_FAILED');
203
+ });
204
+ };
205
+ const aborted = () => { void close({ layer: 'endpoint', initiator: 'mcp.lease-abort', reason: closeCode(options.lease.signal.reason, 'EMPLOYEE_CONTEXT_CHANGED'), action: 'abort' }).catch(error => { diagnosticFailure ??= error; failureLifetime.abort(error); }); };
170
206
  try {
171
207
  await new Promise((yes, no) => { server.once('error', no); server.listen(socketPath, () => { server.removeListener('error', no); yes(); }); });
172
208
  options.lease.signal.addEventListener('abort', aborted, { once: true });
@@ -175,7 +211,7 @@ export async function openEmployeeMcp(options) {
175
211
  return { routing: employeeRouting(options.lease.target, socketPath, options.maxBytes), signal: failureLifetime.signal, close };
176
212
  }
177
213
  catch (error) {
178
- await close();
214
+ await close({ layer: 'relay', initiator: 'mcp.open-failure', reason: closeCode(error, 'MCP_OPEN_FAILED'), action: 'failure' });
179
215
  throw error;
180
216
  }
181
217
  }