@ai-workbench/remote-agent 0.8.13 → 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.
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { CloseDiagnostics, closeCode, closeErrorCodes } from "./close-diagnostics.js";
9
10
  import { createHash } from 'node:crypto';
10
11
  import { admitEmployeeLocation } from "./employee-location.js";
11
12
  import { EmployeeExecution } from "./employee-exec.js";
@@ -104,7 +105,7 @@ export function createEmployeeProvider(get, options, agent) {
104
105
  prior = live.get(sessionId);
105
106
  }
106
107
  if (prior && (prior.current.api !== api || info?.workspaceLocations !== 1 || info?.localWorkspaceContext !== 1)) {
107
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
108
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
108
109
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
109
110
  }
110
111
  if (info?.workspaceLocations !== 1 && info?.localWorkspaceContext !== 1)
@@ -121,12 +122,12 @@ export function createEmployeeProvider(get, options, agent) {
121
122
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
122
123
  if (row.source !== 'local') {
123
124
  if (prior)
124
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
125
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
125
126
  if (request.localAcceptance)
126
127
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
127
128
  return;
128
129
  }
129
- let details;
130
+ let details, closeDiagnostics;
130
131
  try {
131
132
  const acceptance = request.localAcceptance;
132
133
  if (!acceptance || acceptance.instanceId !== connection.instanceId || acceptance.epoch !== connection.epoch || acceptance.sessionId !== sessionId || acceptance.provider !== agent.loginProvider || acceptance.workspaceId !== workspace.id || acceptance.bindingId !== row.bindingId)
@@ -134,6 +135,10 @@ export function createEmployeeProvider(get, options, agent) {
134
135
  details = await options.details?.(request, api, connection, target);
135
136
  if (details)
136
137
  request.timing?.bind(detail => details.record(detail));
138
+ const sink = details;
139
+ closeDiagnostics = prior?.runtime.closeDiagnostics ?? new CloseDiagnostics(event => sink?.record(event));
140
+ closeDiagnostics.bind(event => sink?.record(event));
141
+ request.closeDiagnostics = closeDiagnostics;
137
142
  const unsupported = request.mode !== 'session' || agent.loginProvider !== 'codex' || !!request.recoveryCheckpointId || !!request.resumeSessionId && !prior;
138
143
  if (unsupported)
139
144
  throw fail('EMPLOYEE_OPERATION_UNSUPPORTED');
@@ -145,7 +150,7 @@ export function createEmployeeProvider(get, options, agent) {
145
150
  if (!network || typeof network.prepareEmployeeEndpoint !== 'function' || typeof network.onState !== 'function')
146
151
  throw fail('DEPENDENCY_REQUIRED');
147
152
  if (prior && prior.network !== network) {
148
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
153
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
149
154
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
150
155
  }
151
156
  const admitted = await admitEmployeeLocation({ api: api, network, workspace, connection, expectedLocal: { instanceId: acceptance.instanceId, epoch: acceptance.epoch, workspaceId: acceptance.workspaceId, bindingId: acceptance.bindingId, directory: { ...acceptance.directory } }, settingsPath: target.session.settingsPath, signal: request.signal });
@@ -167,7 +172,7 @@ export function createEmployeeProvider(get, options, agent) {
167
172
  native: { path: native.path, sha256: native.sha256 },
168
173
  });
169
174
  if (prior && (prior.scope !== scope || prior.ended))
170
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'));
175
+ await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
171
176
  let entry = prior && prior.scope === scope && !prior.ended ? prior : undefined;
172
177
  let candidate = entry;
173
178
  const base = async (signal) => {
@@ -177,7 +182,7 @@ export function createEmployeeProvider(get, options, agent) {
177
182
  if (candidate?.ended)
178
183
  throw candidate.reason ?? fail('EMPLOYEE_EXEC_CLOSED');
179
184
  await (candidate?.current ?? input).stable(signal);
180
- await admitted.validate(signal);
185
+ await closeDiagnostics.run('endpoint.prepare', () => admitted.validate(signal));
181
186
  if (get() !== api || employee.getNetwork() !== network)
182
187
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
183
188
  await (candidate?.current ?? input).stable(signal);
@@ -193,25 +198,26 @@ export function createEmployeeProvider(get, options, agent) {
193
198
  }
194
199
  const io = employee.io ?? sshIo(target, argv, request.acpMaxMessageBytes);
195
200
  const scopedIo = { run: (command, signal) => io.run(command, signal), attach: async (command) => {
201
+ const observe = closeDiagnostics.capture();
196
202
  const child = await io.attach(command);
197
- child.once('close', () => { if (candidate && !candidate.ended)
198
- void candidate.dispose(fail('EMPLOYEE_EXEC_UNKNOWN')).catch(() => { }); });
203
+ child.once('close', (code, signal) => { observe({ layer: 'relay', initiator: 'ssh.process.close', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'exit', exitCode: code, signal }); if (candidate && !candidate.ended)
204
+ void candidate.dispose(fail('EMPLOYEE_EXEC_UNKNOWN'), { layer: 'runtime', initiator: 'provider.child-close', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'dispose' }).catch(() => { }); });
199
205
  return child;
200
206
  } };
201
- const ssh = { target, endpoint: admitted.endpoint, employeeBinary: native.path, binarySha256: native.sha256, acpMaxMessageBytes: request.acpMaxMessageBytes, argv: argv ?? (() => []), validate: base, io: scopedIo };
207
+ const ssh = { closeDiagnostics, target, endpoint: admitted.endpoint, employeeBinary: native.path, binarySha256: native.sha256, acpMaxMessageBytes: request.acpMaxMessageBytes, argv: argv ?? (() => []), validate: base, io: scopedIo };
202
208
  const validate = async (signal) => { try {
203
209
  await validateEmployeeSshTarget(ssh, signal);
204
210
  }
205
211
  catch (error) {
206
212
  if (candidate && errorCode(error) !== 'ABORTED')
207
- await candidate.dispose(asNeed(error));
213
+ await candidate.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.validation-failure', reason: closeCode(error), action: 'invalidate' });
208
214
  throw error;
209
215
  } };
210
216
  await validate(request.signal ?? new AbortController().signal);
211
217
  if (!options.open)
212
218
  throw fail('RUNTIME_REQUIRED');
213
219
  // Host cwd is only the protected agent bootstrap/config directory. Native tools use admitted.cwd.
214
- const host = await options.open(target, { name: 'employee', path: target.projectRoot }, { ...request, agent, employeeScope: scope, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, bindingRevision: state.revision, detailChannel: details, validateContext: async (ignore = false) => {
220
+ const host = await options.open(target, { name: 'employee', path: target.projectRoot }, { ...request, agent, employeeScope: scope, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, bindingRevision: state.revision, closeDiagnostics, detailChannel: details, validateContext: async (ignore = false) => {
215
221
  // Disposal addresses only the already-owned host broker instance through its frozen IO/CAS.
216
222
  // A withdrawn local source must prevent dispatch, but cannot forbid that instance's exact kill.
217
223
  if (ignore)
@@ -226,49 +232,65 @@ export function createEmployeeProvider(get, options, agent) {
226
232
  await validate(request.signal ?? new AbortController().signal);
227
233
  }
228
234
  catch (error) {
229
- await entry.dispose(asNeed(error));
235
+ await entry.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.final-validation', reason: closeCode(error), action: 'dispose' });
230
236
  throw error;
231
237
  }
232
238
  entry.runtime.details = details;
233
239
  entry.runtime.connection = { ...connection };
234
240
  entry.runtime.binding = { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision };
241
+ entry.runtime.restartNotice = host.restartNotice;
242
+ entry.runtime.restartInputIds = host.restartInputIds;
243
+ entry.runtime.freshSessionConfirmed = host.freshSessionConfirmed;
235
244
  return entry.runtime;
236
245
  }
237
246
  const disposers = [];
238
- const execution = new EmployeeExecution({ bindingId: admitted.bindingId, acpMaxMessageBytes: request.acpMaxMessageBytes, validate, transport: signal => createEmployeeSshTransport(ssh, signal) });
239
- const runtime = { ...host, cwd: admitted.cwd, details, connection: { ...connection }, binding: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision }, saveCheckpoint: undefined, recoveredSession: undefined, restartNotice: undefined, restartInputIds: undefined, employeeExecutionMeta: { version: 1, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, cwd: admitted.cwd, acpMaxMessageBytes: request.acpMaxMessageBytes }, employeePresentation: '文件与命令在本机选定目录运行,Git 使用本机仓库;模型、登录和全局助手要求仍在远程主机。本机自定义的附加工具、自动动作、插件及断开后的会话恢复暂不支持。子任务被系统回收后能否继续,尚未验证。' };
240
- entry = { scope, runtime, current: input, details, execution, network, ended: false, dispose: reason => {
247
+ const execution = new EmployeeExecution({ closeDiagnostics, bindingId: admitted.bindingId, acpMaxMessageBytes: request.acpMaxMessageBytes, validate, transport: signal => createEmployeeSshTransport(ssh, signal) });
248
+ const runtime = { ...host, closeDiagnostics, cwd: admitted.cwd, details, connection: { ...connection }, binding: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision }, saveCheckpoint: undefined, recoveredSession: undefined, employeeExecutionMeta: { version: 1, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, cwd: admitted.cwd, acpMaxMessageBytes: request.acpMaxMessageBytes }, employeePresentation: '文件与命令在本机选定目录运行,Git 使用本机仓库;模型、登录和全局助手要求仍在远程主机。本机自定义的附加工具、自动动作、插件及断开后的会话恢复暂不支持。子任务被系统回收后能否继续,尚未验证。' };
249
+ entry = { scope, runtime, current: input, details, execution, network, ended: false, dispose: (reason, cause = { layer: 'runtime', initiator: 'provider.dispose', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', action: 'dispose' }) => {
250
+ closeDiagnostics.record({ ...cause, changes: cause.changes ?? [{ field: 'provider.ended', before: entry.ended, after: true }] });
241
251
  if (entry.closing)
242
252
  return entry.closing;
243
253
  entry.ended = true;
244
254
  entry.reason = reason;
245
- execution.invalidate(reason ?? fail('EMPLOYEE_EXEC_CLOSED'));
255
+ execution.invalidate(reason ?? fail('EMPLOYEE_EXEC_CLOSED'), { layer: 'executor', initiator: 'provider.invalidate-executor', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', action: 'invalidate' });
246
256
  for (const off of disposers)
247
257
  off();
248
258
  host.broker?.fail(reason ?? fail('EMPLOYEE_EXEC_CLOSED'));
249
259
  entry.closing = (async () => {
250
- let cleanup;
260
+ const cleanup = [], reporting = [];
251
261
  try {
252
- await execution.dispose();
262
+ await execution.dispose({ layer: 'executor', initiator: 'provider.cleanup-executor', reason: 'RUNTIME_CLEANUP', action: 'dispose' });
253
263
  }
254
264
  catch (error) {
255
- cleanup = error;
265
+ cleanup.push(...closeErrorCodes(error, 'EXECUTOR_CLEANUP_FAILED'));
256
266
  }
257
267
  try {
258
- await host.dispose?.();
268
+ await host.dispose?.({ layer: 'broker', initiator: 'provider.cleanup-host', reason: 'RUNTIME_CLEANUP', action: 'dispose' });
259
269
  }
260
270
  catch (error) {
261
- cleanup = error;
271
+ cleanup.push(...closeErrorCodes(error, 'HOST_CLEANUP_FAILED'));
272
+ }
273
+ const firstReason = String(closeDiagnostics.first?.reason ?? reason?.code ?? 'EMPLOYEE_EXEC_CLOSED');
274
+ try {
275
+ entry.details?.record({ kind: 'need', severity: 'warning', code: cleanup.length ? 'REMOTE_CLEANUP_UNCONFIRMED' : 'EMPLOYEE_EXEC_UNKNOWN', summary: '员工执行通道已关闭,命令结果 UNKNOWN', payload: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, result: 'UNKNOWN', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', firstReason, cleanup: cleanup.length ? 'unconfirmed' : 'closed', cleanupCodes: cleanup } });
276
+ }
277
+ catch (error) {
278
+ reporting.push(closeCode(error, 'CLOSE_RECORD_FAILED'));
262
279
  }
263
280
  try {
264
- entry.details?.record({ kind: 'need', severity: 'warning', code: cleanup ? 'REMOTE_CLEANUP_UNCONFIRMED' : 'EMPLOYEE_EXEC_UNKNOWN', summary: '员工执行通道已关闭,命令结果 UNKNOWN', payload: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, result: 'UNKNOWN', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', cleanup: cleanup ? 'unconfirmed' : 'closed' } });
265
281
  await entry.details?.flush();
266
282
  }
267
- catch {
268
- throw fail('CONVERSATION_DETAILS_FAILED');
283
+ catch (error) {
284
+ reporting.push(...closeErrorCodes(error, 'CLOSE_FLUSH_FAILED'));
285
+ }
286
+ try {
287
+ closeDiagnostics.assertRecorded();
288
+ }
289
+ catch (error) {
290
+ reporting.push(closeDiagnostics.recordingFailureCode ?? closeCode(error, 'CLOSE_RECORD_FAILED'));
269
291
  }
270
- if (cleanup)
271
- throw fail('REMOTE_CLEANUP_UNCONFIRMED');
292
+ if (cleanup.length || reporting.length)
293
+ throw Object.assign(new NeedError(reporting.length ? 'CONVERSATION_DETAILS_FAILED' : 'REMOTE_CLEANUP_UNCONFIRMED', '执行连接已关闭;首次原因 ' + firstReason + ',清理或诊断保存未确认。', '首次关闭 ' + firstReason + ';清理 ' + (cleanup.join(',') || 'closed') + ';保存 ' + (reporting.join(',') || 'recorded')), { cause: new AggregateError([firstReason, ...cleanup, ...reporting].map(code => new NeedError(code, '关闭收尾代码 ' + code, '技术详情中的固定代码')), 'EMPLOYEE_CLOSE_AND_DIAGNOSTIC_FAILURES') });
272
294
  if (live.get(sessionId) === entry)
273
295
  live.delete(sessionId);
274
296
  })();
@@ -279,35 +301,35 @@ export function createEmployeeProvider(get, options, agent) {
279
301
  try {
280
302
  const result = await execution.handle(params, signal);
281
303
  if (result.closed)
282
- await entry.dispose();
304
+ await entry.dispose(undefined, { layer: 'runtime', initiator: 'provider.rpc-closed-result', reason: 'EMPLOYEE_EXEC_CLOSED', operation: typeof params === 'object' && params && 'operation' in params && typeof params.operation === 'string' ? params.operation : 'unknown', action: 'dispose' });
283
305
  return { ...result };
284
306
  }
285
307
  catch (error) {
286
308
  // EmployeeExecution reserves ABORTED for an unconsumed read wait. Lost responses are UNKNOWN.
287
309
  if (!(params && typeof params === 'object' && 'operation' in params && params.operation === 'read' && errorCode(error) === 'ABORTED'))
288
- await entry.dispose(asNeed(error));
310
+ await entry.dispose(asNeed(error), { layer: 'runtime', initiator: 'provider.rpc-failure', reason: closeCode(error), action: 'dispose' });
289
311
  throw error;
290
312
  }
291
313
  };
292
- runtime.dispose = () => entry.dispose();
293
- runtime.detach = runtime.dispose;
294
- const revoke = (reason) => { void entry.dispose(reason).catch(() => { }); };
314
+ runtime.dispose = cause => entry.dispose(undefined, cause ?? { layer: 'runtime', initiator: 'runtime.dispose', reason: 'RUNTIME_DISPOSE', action: 'dispose' });
315
+ runtime.detach = cause => entry.dispose(undefined, cause ?? { layer: 'runtime', initiator: 'runtime.detach', reason: 'RUNTIME_DETACH', action: 'detach' });
316
+ const revoke = (reason, initiator, changes) => { void entry.dispose(reason, { layer: 'endpoint', initiator, reason: reason.code, action: 'invalidate', changes }).catch(() => { }); };
295
317
  disposers.push(api.onWorkspaceLocationState(next => {
296
318
  const matches = next.items.filter(item => item.workspaceId === admitted.workspaceId);
297
319
  if (get() !== api || next.phase !== 'ready' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch || matches.length !== 1 || matches[0].bindingId !== admitted.bindingId || matches[0].source !== 'local' || matches[0].status !== 'selected')
298
- revoke(fail('EMPLOYEE_CONTEXT_CHANGED'));
320
+ revoke(fail('EMPLOYEE_CONTEXT_CHANGED'), 'endpoint.workspace-change', [{ field: 'workspace.binding-id', before: admitted.bindingId, after: matches[0]?.bindingId ?? null }, { field: 'workspace.source', before: 'local', after: matches[0]?.source ?? null }, { field: 'workspace.status', before: 'selected', after: matches[0]?.status ?? null }, { field: 'workspace.match-count', before: 1, after: matches.length }]);
299
321
  }), api.onConnectionState(next => { if (get() !== api || next.phase !== 'connected' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch)
300
- revoke(fail('CONNECTION_LOST')); }), network.onState(event => { if (event.kind === 'lost' || employee.getNetwork() !== network)
301
- revoke(fail('CONNECTION_LOST')); }));
322
+ revoke(fail('CONNECTION_LOST'), 'endpoint.connection-change', [{ field: 'connection.instance-id', before: connection.instanceId, after: next.instanceId }, { field: 'connection.epoch', before: connection.epoch, after: next.epoch }, { field: 'connection.phase', before: 'connected', after: next.phase }]); }), network.onState(event => { if (event.kind === 'lost' || employee.getNetwork() !== network)
323
+ revoke(fail('CONNECTION_LOST'), 'endpoint.network-loss', [{ field: 'network.phase', before: null, after: event.state.phase }]); }));
302
324
  if (host.broker)
303
325
  disposers.push(host.broker.onChange(() => { if (!host.broker.online)
304
- revoke(fail('EMPLOYEE_EXEC_UNKNOWN')); }));
326
+ revoke(fail('EMPLOYEE_EXEC_UNKNOWN'), 'endpoint.broker-offline'); }));
305
327
  live.set(sessionId, entry);
306
328
  try {
307
329
  await validate(request.signal ?? new AbortController().signal);
308
330
  }
309
331
  catch (error) {
310
- await entry.dispose(asNeed(error));
332
+ await entry.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.final-validation', reason: closeCode(error), action: 'dispose' });
311
333
  throw error;
312
334
  }
313
335
  return runtime;
@@ -1,3 +1,4 @@
1
+ import { type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  import type { EmployeeEndpointTarget } from './employee-endpoint.ts';
3
4
  export interface EmployeeShellResult {
@@ -13,6 +14,7 @@ export type EmployeeShell = (request: {
13
14
  signal: AbortSignal;
14
15
  }) => Promise<EmployeeShellResult>;
15
16
  interface Options {
17
+ closeDiagnostics?: CloseDiagnostics;
16
18
  target: EmployeeEndpointTarget;
17
19
  maxBytes: number;
18
20
  killGraceMs: number;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { EMPLOYEE_TARGET_PYTHON, EMPLOYEE_PYTHON_ERRORS } from "./employee-relay.js";
3
4
  import { NeedError } from "./remote.js";
@@ -15,11 +16,13 @@ const unknown = (code) => new NeedError(code, '本机命令结果未知;没有
15
16
  export function createEmployeeShell(options) {
16
17
  return async ({ command, timeout, signal }) => {
17
18
  signal.throwIfAborted();
19
+ const diagnostics = options.closeDiagnostics?.child();
18
20
  const executionId = randomUUID(), target = options.target;
19
21
  const child = await options.attach({ command: '/usr/bin/python3', args: ['-c', SHELL_PYTHON, JSON.stringify({ endpoint: target.endpoint, bridge: target.bridge, directory: target.local.directory, maxBytes: options.maxBytes, executionId })] });
20
22
  return new Promise((yes, no) => {
21
23
  let settled = false, bytes = 0, stdout = [], stderr = [], reason, grace, timer;
22
24
  const finish = (error, code) => {
25
+ diagnostics?.record({ layer: 'executor', initiator: 'shell.finish', reason: closeCode(error, 'SHELL_FINISHED'), action: 'close', exitCode: code ?? null, changes: [{ field: 'shell.settled', before: settled, after: true }] });
23
26
  if (settled)
24
27
  return;
25
28
  settled = true;
@@ -62,11 +65,20 @@ export function createEmployeeShell(options) {
62
65
  no(unknown('EMPLOYEE_EXIT_UNKNOWN'));
63
66
  return;
64
67
  }
68
+ try {
69
+ diagnostics?.assertRecorded();
70
+ }
71
+ catch (failure) {
72
+ no(failure);
73
+ return;
74
+ }
65
75
  yes({ stdout: out, stderr: err.slice(header.length, start) + err.slice(start + started.length), stderrSource: 'combined-ssh-and-command', exitCode: code, signal: null });
66
76
  };
67
- const stop = (code) => { if (settled || reason)
68
- return; reason = unknown(code); child.kill('SIGTERM'); grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
69
- child.kill('SIGKILL'); finish(reason); }, options.killGraceMs); };
77
+ const stop = (code) => { diagnostics?.record({ layer: 'executor', initiator: 'shell.stop', reason: code, action: 'terminate', changes: [{ field: 'shell.stop-code', before: reason?.code ?? null, after: settled || reason ? reason?.code ?? null : code }] }); if (settled || reason)
78
+ return; reason = unknown(code); child.kill('SIGTERM'); grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) {
79
+ diagnostics?.record({ layer: 'executor', initiator: 'shell.kill-grace', reason: reason?.code ?? 'SHELL_CLEANUP', action: 'terminate', signal: 'SIGKILL' });
80
+ child.kill('SIGKILL');
81
+ } finish(reason); }, options.killGraceMs); };
70
82
  const abort = () => stop('EMPLOYEE_CANCELLED_UNKNOWN');
71
83
  const collect = (destination, chunk) => { bytes += chunk.length; if (bytes > options.maxBytes) {
72
84
  stop('EMPLOYEE_OUTPUT_TOO_LARGE_UNKNOWN');
@@ -75,8 +87,8 @@ export function createEmployeeShell(options) {
75
87
  child.stdout.on('data', (b) => collect(stdout, b));
76
88
  child.stderr.on('data', (b) => collect(stderr, b));
77
89
  child.stdin.on('error', () => stop('EMPLOYEE_INPUT_UNKNOWN'));
78
- child.once('error', () => { finish(unknown('EMPLOYEE_TRANSPORT_UNKNOWN')); });
79
- child.once('close', code => finish(reason, code));
90
+ child.once('error', () => { diagnostics?.record({ layer: 'relay', initiator: 'shell.process-error', reason: 'EMPLOYEE_TRANSPORT_UNKNOWN', action: 'failure' }); finish(unknown('EMPLOYEE_TRANSPORT_UNKNOWN')); });
91
+ child.once('close', (code, exitSignal) => { diagnostics?.record({ layer: 'relay', initiator: 'shell.process-close', reason: reason?.code ?? 'PROCESS_EXIT', action: 'exit', exitCode: code, signal: exitSignal }); finish(reason, code); });
80
92
  signal.addEventListener('abort', abort, { once: true });
81
93
  if (signal.aborted)
82
94
  abort();
@@ -1,3 +1,4 @@
1
+ import { type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { EmployeeEndpointTarget } from '@ai-workbench/network/api';
2
3
  import { type BrokerIo, type SshArgv } from './broker.ts';
3
4
  import type { ConnectTarget } from './provider.ts';
@@ -5,6 +6,7 @@ import { type EmployeeExecutionTransport } from './employee-exec.ts';
5
6
  export interface EmployeeSshOptions {
6
7
  target: ConnectTarget;
7
8
  endpoint: EmployeeEndpointTarget;
9
+ closeDiagnostics?: CloseDiagnostics;
8
10
  /** Automatically discovered package binary path on this same employee device. */
9
11
  employeeBinary: string;
10
12
  binarySha256: string;
@@ -1,3 +1,4 @@
1
+ import { closeCode } from "./close-diagnostics.js";
1
2
  import { sshIo } from "./broker.js";
2
3
  import { createEmployeeProcessTransport } from "./employee-exec.js";
3
4
  import { NeedError, shellQuote } from "./remote.js";
@@ -92,7 +93,8 @@ async function currentTarget(options, signal) {
92
93
  await gate();
93
94
  const b = endpoint.bridge, e = endpoint.endpoint;
94
95
  const command = [b.command, 'target', '--settings', b.settingsPath, '--device', e.deviceId, '--if-instance', b.bridgeInstanceId, '--if-generation', String(b.generation), '--if-endpoint', e.endpointInstanceId, '--if-context', e.executionContextId].map(shellQuote).join(' ');
95
- const raw = await awaitSignal(io.run(command, signal), signal);
96
+ const run = () => awaitSignal(io.run(command, signal), signal);
97
+ const raw = await (options.closeDiagnostics ? options.closeDiagnostics.run('endpoint.target', run) : run());
96
98
  const args = readTarget(raw, endpoint, options.acpMaxMessageBytes);
97
99
  await gate();
98
100
  return { io, args, gate };
@@ -107,9 +109,9 @@ export async function createEmployeeSshTransport(options, signal = new AbortCont
107
109
  checkSignal(signal);
108
110
  // EmployeeExecution races the user's open wait; this factory retains any late child cleanup.
109
111
  const child = await io.attach('exec ' + ['/usr/bin/ssh', ...args, remote].map(shellQuote).join(' '));
110
- const transport = createEmployeeProcessTransport(child, options.acpMaxMessageBytes, ['AW_EMPLOYEE_BINARY_UNAVAILABLE', 'AW_EMPLOYEE_BINARY_HASH_FAILED', 'AW_EMPLOYEE_BINARY_HASH_MISMATCH', 'AW_EMPLOYEE_BINARY_VERSION_FAILED', 'AW_EMPLOYEE_BINARY_VERSION_MISMATCH']);
112
+ const transport = createEmployeeProcessTransport(child, options.acpMaxMessageBytes, ['AW_EMPLOYEE_BINARY_UNAVAILABLE', 'AW_EMPLOYEE_BINARY_HASH_FAILED', 'AW_EMPLOYEE_BINARY_HASH_MISMATCH', 'AW_EMPLOYEE_BINARY_VERSION_FAILED', 'AW_EMPLOYEE_BINARY_VERSION_MISMATCH'], options.closeDiagnostics?.capture());
111
113
  if (signal.aborted) {
112
- await transport.close();
114
+ await transport.close({ layer: 'endpoint', initiator: 'ssh.open-abort', reason: 'ABORTED', operation: 'open', action: 'abort' });
113
115
  throw fail('ABORTED');
114
116
  }
115
117
  try {
@@ -117,7 +119,7 @@ export async function createEmployeeSshTransport(options, signal = new AbortCont
117
119
  return transport;
118
120
  }
119
121
  catch (error) {
120
- await transport.close();
122
+ await transport.close({ layer: 'endpoint', initiator: 'ssh.post-open-validation', reason: closeCode(error), operation: 'open', action: 'failure' });
121
123
  throw error;
122
124
  }
123
125
  }
@@ -1,8 +1,10 @@
1
+ import { type CloseCause, type CloseDiagnostics } from './close-diagnostics.ts';
1
2
  import type { DetailObservation } from './conversation-details.ts';
2
3
  import type { EmployeeShell } from './employee-shell.ts';
3
4
  export declare const EMPLOYEE_TOOL_NAMES: readonly ["Read", "Write", "Edit", "Glob", "Grep", "Bash"];
4
5
  export type EmployeeToolName = typeof EMPLOYEE_TOOL_NAMES[number];
5
6
  interface Options {
7
+ closeDiagnostics?: CloseDiagnostics;
6
8
  cwd: string;
7
9
  shell: string;
8
10
  uid: number;
@@ -112,6 +114,6 @@ export declare function createEmployeeTools(options: Options): {
112
114
  } | undefined;
113
115
  isError?: boolean | undefined;
114
116
  }>;
115
- close(): Promise<void>;
117
+ close(cause?: CloseCause): Promise<void>;
116
118
  };
117
119
  export {};
@@ -94,13 +94,14 @@ export function createEmployeeTools(options) {
94
94
  throw failure('EMPLOYEE_OUTPUT_TOO_LARGE'); };
95
95
  async function run(command, args, signal, detail, timeout) {
96
96
  signal.throwIfAborted();
97
+ const diagnostics = options.closeDiagnostics?.child(detail);
97
98
  const operation = new Promise((yes, no) => {
98
99
  const child = spawn(command, args, { cwd: options.cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
99
100
  child.stdin.end();
100
101
  let bytes = 0, stdout = [], stderr = [], failed, killTimer, timeoutTimer;
101
- const kill = (which) => { if (child.exitCode === null && child.signalCode === null)
102
+ const kill = (which) => { diagnostics?.record({ layer: 'executor', initiator: 'tools.process-kill', reason: failed?.code ?? 'PROCESS_CLEANUP', action: 'terminate', signal: which }); if (child.exitCode === null && child.signalCode === null)
102
103
  child.kill(which); };
103
- const stop = (code) => { if (failed)
104
+ const stop = (code) => { diagnostics?.record({ layer: 'executor', initiator: 'tools.process-stop', reason: code, action: 'terminate', changes: [{ field: 'tools.stop-code', before: failed?.code ?? null, after: failed?.code ?? code }] }); if (failed)
104
105
  return; failed = failure(code); kill('SIGTERM'); killTimer = setTimeout(() => kill('SIGKILL'), options.killGraceMs); killTimer.unref(); };
105
106
  const aborted = () => stop('EMPLOYEE_CANCELLED_UNKNOWN');
106
107
  const collect = (dest, chunk) => { bytes += chunk.length; if (bytes > options.maxBytes) {
@@ -116,8 +117,8 @@ export function createEmployeeTools(options) {
116
117
  stop('CONVERSATION_DETAILS_FAILED');
117
118
  } if (signal.aborted)
118
119
  aborted(); });
119
- child.on('error', e => { failed ??= failure(e.code === 'ENOENT' ? 'EMPLOYEE_COMMAND_UNAVAILABLE' : 'EMPLOYEE_PROCESS_FAILED'); });
120
- child.on('close', (exitCode, exitSignal) => { signal.removeEventListener('abort', aborted); if (killTimer)
120
+ child.on('error', e => { diagnostics?.record({ layer: 'executor', initiator: 'tools.process-error', reason: e.code === 'ENOENT' ? 'EMPLOYEE_COMMAND_UNAVAILABLE' : 'EMPLOYEE_PROCESS_FAILED', action: 'failure' }); failed ??= failure(e.code === 'ENOENT' ? 'EMPLOYEE_COMMAND_UNAVAILABLE' : 'EMPLOYEE_PROCESS_FAILED'); });
121
+ child.on('close', (exitCode, exitSignal) => { diagnostics?.record({ layer: 'executor', initiator: 'tools.process-close', reason: failed?.code ?? 'PROCESS_EXIT', action: 'exit', exitCode, signal: exitSignal }); signal.removeEventListener('abort', aborted); if (killTimer)
121
122
  clearTimeout(killTimer); if (timeoutTimer)
122
123
  clearTimeout(timeoutTimer); failed ? no(failed) : yes({ stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), exitCode, signal: exitSignal }); });
123
124
  signal.addEventListener('abort', aborted, { once: true });
@@ -287,6 +288,6 @@ export function createEmployeeTools(options) {
287
288
  }
288
289
  return {
289
290
  call(name, input, requestSignal, observer) { const operation = call(name, input, requestSignal, observer); calls.add(operation); void operation.then(() => calls.delete(operation), () => calls.delete(operation)); return operation; },
290
- async close() { lifetime.abort(); await Promise.allSettled([...calls, ...children]); },
291
+ async close(cause = { layer: 'executor', initiator: 'tools.close', reason: 'EMPLOYEE_TOOLS_CLOSED', action: 'close' }) { options.closeDiagnostics?.record({ ...cause, changes: cause.changes ?? [{ field: 'tools.aborted', before: lifetime.signal.aborted, after: true }] }); lifetime.abort(); await Promise.allSettled([...calls, ...children]); },
291
292
  };
292
293
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type CloseChange } from './close-diagnostics.ts';
1
2
  import type { Context } from '@deepseek-ai/cordis';
2
3
  import Schema from '@deepseek-ai/schemastery';
3
4
  import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm';
@@ -74,6 +75,7 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
74
75
  private shuttingDown;
75
76
  private detached;
76
77
  private cleanups;
78
+ private preparationFailures;
77
79
  constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined, reportLoginFailure?: LoginFailureReporter | undefined, directory?: ModelDirectory | undefined, preflightDiagnostics?: PreflightDiagnostics | undefined);
78
80
  providerInfo(provider: string): {
79
81
  id: string;
@@ -86,6 +88,7 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
86
88
  resolveModel(provider: string, id: string, signal?: AbortSignal): Promise<import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo & {
87
89
  catalog: HostModel;
88
90
  }>;
91
+ observeModelInvalidation(fields: readonly string[], changes?: readonly CloseChange[]): void;
89
92
  invalidateModelState(): Promise<void>;
90
93
  invalidateEmployeeState(): Promise<void>;
91
94
  providerRetryPolicy(): {
@@ -118,6 +121,7 @@ export declare class RemoteAgentsAdapter extends LlmAdapter {
118
121
  resolveModel(provider: string, model: string, signal?: AbortSignal): Promise<import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo & {
119
122
  catalog: HostModel;
120
123
  }>;
124
+ observeModelInvalidation(fields: readonly string[], changes?: readonly CloseChange[]): void;
121
125
  invalidateModelState(): Promise<void>;
122
126
  invalidateEmployeeState(): Promise<void>;
123
127
  providerRetryPolicy(): {