@automatalabs/pi-acp 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -1,11 +1,100 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { isAbsolute } from "node:path";
4
+ import { DefaultResourceLoader, SettingsManager, createBashToolDefinition, getAgentDir, } from "@earendil-works/pi-coding-agent";
3
5
  import { AUTH_METHODS, authenticateMethod } from "./auth.js";
4
- import { adapterError, isRequestError, unexpectedError } from "./errors.js";
5
- import { bridgeMcpServers, disposeMcpBridge, } from "./mcp-bridge.js";
6
+ import { adapterError, isChildCleanupError, isRequestError, unexpectedError } from "./errors.js";
7
+ import { bridgeMcpServers, } from "./mcp-bridge.js";
6
8
  import { PiSession } from "./session.js";
7
- import { StructuredOutputState, STRUCTURED_TOOL_NAME } from "./structured-output.js";
8
- export const PKG_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
9
+ import { ChildProcessRegistrySlot, createTrackedBashOperations } from "./child-process-registry.js";
10
+ import { PKG_VERSION } from "./version.js";
11
+ export { PKG_VERSION } from "./version.js";
12
+ /** Retry owner for the narrow interval after Pi exists but before PiSession is
13
+ * publishable. It gives failed-open rollback the same abort/tree barrier and
14
+ * hidden-record ownership as a fully constructed session. */
15
+ class FailedOpenCleanup {
16
+ pi;
17
+ bridge;
18
+ children;
19
+ lifecycle;
20
+ deps;
21
+ resourceDispose;
22
+ constructor(pi, bridge, children, lifecycle, deps) {
23
+ this.pi = pi;
24
+ this.bridge = bridge;
25
+ this.children = children;
26
+ this.lifecycle = lifecycle;
27
+ this.deps = deps;
28
+ }
29
+ get remainingChildren() { return this.children.remainingChildren; }
30
+ startResources(bridgeClose) {
31
+ this.resourceDispose ??= (async () => {
32
+ await this.bridge.drainRefreshes().catch((error) => {
33
+ console.error("pi-acp failed-open refresh drain error:", error);
34
+ });
35
+ const results = await Promise.allSettled([
36
+ Promise.resolve().then(() => this.pi.dispose()),
37
+ bridgeClose,
38
+ ]);
39
+ for (const result of results) {
40
+ if (result.status === "rejected")
41
+ console.error("pi-acp failed-open resource disposal error:", result.reason);
42
+ }
43
+ })();
44
+ return this.resourceDispose;
45
+ }
46
+ async dispose() {
47
+ const deadline = new AbortController();
48
+ const timer = new AbortController();
49
+ const expiry = this.deps.sleep(this.deps.graceMs, timer.signal).then(() => {
50
+ const failure = adapterError("child_cleanup_error", {
51
+ details: { remainingChildren: this.children.remainingChildren },
52
+ });
53
+ deadline.abort(failure);
54
+ throw failure;
55
+ });
56
+ expiry.catch(() => undefined);
57
+ // Failed-open cleanup has the same synchronous prefix as a published
58
+ // session: close spawn admission first, logically close every MCP client
59
+ // before aborting the binding lifetime, then start Pi abort before its
60
+ // one-shot resource disposal can run.
61
+ const captured = this.children.closeEpoch(deadline.signal);
62
+ this.bridge.startDisposal();
63
+ if (!this.lifecycle.signal.aborted)
64
+ this.lifecycle.abort(new Error("failed open disposed"));
65
+ this.bridge.abortRefreshes();
66
+ const bridgeClose = this.bridge.close();
67
+ bridgeClose.catch(() => undefined);
68
+ let abort;
69
+ try {
70
+ abort = this.pi.abort();
71
+ }
72
+ catch (error) {
73
+ abort = Promise.reject(error);
74
+ }
75
+ abort.catch(() => undefined);
76
+ const settled = Promise.allSettled([abort, captured.drain]);
77
+ let cleanupError;
78
+ await Promise.race([settled, expiry]).then((results) => {
79
+ if (!Array.isArray(results))
80
+ return;
81
+ const failure = results.find((result) => result.status === "rejected");
82
+ if (failure) {
83
+ cleanupError = adapterError("child_cleanup_error", {
84
+ details: { remainingChildren: this.children.remainingChildren },
85
+ });
86
+ }
87
+ }, (error) => { cleanupError = error; });
88
+ timer.abort();
89
+ // Pi disposal is defense in depth only after the abort/tree barrier has
90
+ // committed success or failure. MCP physical close was already started
91
+ // by the synchronous disposal prefix above.
92
+ const resources = this.startResources(bridgeClose);
93
+ await resources;
94
+ if (cleanupError)
95
+ throw cleanupError;
96
+ }
97
+ }
9
98
  function validateCwd(cwd) {
10
99
  if (!isAbsolute(cwd))
11
100
  throw adapterError("invalid_cwd");
@@ -35,7 +124,11 @@ export class PiAcpAgent {
35
124
  openingControllers = new Set();
36
125
  openingTasks = new Set();
37
126
  tombstones = new Set();
127
+ cleanupRecords = new Map();
128
+ mcpOwnerToken = {};
38
129
  disposed = false;
130
+ disposePromise;
131
+ disposeSucceeded = false;
39
132
  constructor(deps) {
40
133
  this.deps = deps;
41
134
  }
@@ -50,9 +143,8 @@ export class PiAcpAgent {
50
143
  agentCapabilities: {
51
144
  loadSession: true,
52
145
  promptCapabilities: { image: true },
53
- mcpCapabilities: {},
146
+ mcpCapabilities: { http: true, sse: true },
54
147
  sessionCapabilities: { resume: {}, fork: {}, list: {}, close: {} },
55
- _meta: { "@automatalabs/pi-acp": { outputSchema: true } },
56
148
  },
57
149
  authMethods: AUTH_METHODS,
58
150
  };
@@ -68,16 +160,29 @@ export class PiAcpAgent {
68
160
  if (this.live.has(id) || this.opening.has(id))
69
161
  throw adapterError("session_already_open");
70
162
  }
71
- reserve(id, controller) {
163
+ reserve(id, opening) {
72
164
  this.ensureMayOpen(id);
73
- this.opening.set(id, controller);
165
+ this.opening.set(id, opening);
74
166
  }
75
167
  beginOpening(requestSignal) {
76
168
  if (this.disposed)
77
169
  throw adapterError("internal_error");
78
170
  const controller = openSignal(requestSignal);
171
+ let settled = false;
172
+ let resolveSettlement;
173
+ const settlement = new Promise((resolve) => { resolveSettlement = resolve; });
174
+ const opening = {
175
+ controller,
176
+ settlement,
177
+ settle: () => {
178
+ if (settled)
179
+ return;
180
+ settled = true;
181
+ resolveSettlement();
182
+ },
183
+ };
79
184
  this.openingControllers.add(controller);
80
- return { controller };
185
+ return opening;
81
186
  }
82
187
  track(task) {
83
188
  this.openingTasks.add(task);
@@ -90,78 +195,213 @@ export class PiAcpAgent {
90
195
  throw opening.controller.signal.reason ?? adapterError("internal_error");
91
196
  }
92
197
  }
198
+ async connectMcp(opening, sessionId, cwd, client, mcpServers) {
199
+ const lifecycle = new AbortController();
200
+ const state = { published: false };
201
+ const binding = {
202
+ sessionId,
203
+ cwd,
204
+ client,
205
+ sessionSignal: lifecycle.signal,
206
+ getPi: () => state.pi,
207
+ getTurnSignal: () => state.wrapper?.activeTurnSignal(),
208
+ isPublished: () => state.published,
209
+ emitDiagnostic: (text) => {
210
+ if (state.wrapper)
211
+ state.wrapper.emitMcpDiagnostic(text);
212
+ else
213
+ console.error(text);
214
+ },
215
+ poison: () => state.wrapper?.poison(),
216
+ ownerToken: this.mcpOwnerToken,
217
+ modelRuntime: this.deps.modelRuntime,
218
+ };
219
+ try {
220
+ const bridge = await bridgeMcpServers(mcpServers, opening.controller.signal, this.deps, binding);
221
+ return { bridge, lifecycle, state };
222
+ }
223
+ catch (error) {
224
+ lifecycle.abort(new Error("MCP opening failed"));
225
+ throw error;
226
+ }
227
+ }
93
228
  async construct(opening, manager, cwd, client, mcpServers, replay, preconnected) {
94
229
  const id = manager.getSessionId();
95
230
  if (opening.id === undefined) {
96
- this.reserve(id, opening.controller);
231
+ this.reserve(id, opening);
97
232
  opening.id = id;
98
233
  }
99
- let bridge = preconnected;
234
+ let prepared = preconnected;
235
+ let bridge = prepared?.bridge;
100
236
  let pi;
101
237
  let wrapper;
238
+ let lifecycle = prepared?.lifecycle;
239
+ let bindingState = prepared?.state;
240
+ const childRegistry = new ChildProcessRegistrySlot(this.deps);
102
241
  try {
103
242
  this.gate(opening);
104
- bridge ??= await bridgeMcpServers(mcpServers, opening.controller.signal, this.deps);
243
+ prepared ??= await this.connectMcp(opening, id, cwd, client, mcpServers);
244
+ bridge = prepared.bridge;
245
+ lifecycle = prepared.lifecycle;
246
+ bindingState = prepared.state;
105
247
  this.gate(opening);
106
- const structured = new StructuredOutputState();
248
+ const settingsManager = SettingsManager.create(cwd, getAgentDir());
249
+ const instructionFactory = bridge.instructionsExtension;
250
+ const controlExtension = {
251
+ name: "agentprism-pi-acp-control",
252
+ factory: async (api) => {
253
+ if (typeof instructionFactory === "function")
254
+ await instructionFactory(api);
255
+ else
256
+ await instructionFactory.factory(api);
257
+ api.registerTool(createBashToolDefinition(cwd, {
258
+ commandPrefix: settingsManager.getShellCommandPrefix(),
259
+ operations: createTrackedBashOperations(childRegistry, settingsManager.getShellPath(), this.deps, () => wrapper?.childCleanupFailure()),
260
+ }));
261
+ },
262
+ };
263
+ const resourceLoader = new DefaultResourceLoader({
264
+ cwd,
265
+ agentDir: getAgentDir(),
266
+ settingsManager,
267
+ extensionFactories: [bridge.inlineExtension, controlExtension],
268
+ extensionsOverride: (base) => {
269
+ const matches = base.extensions.filter(({ path }) => path === "<inline:agentprism-pi-acp-mcp>");
270
+ const controls = base.extensions.filter(({ path }) => path === "<inline:agentprism-pi-acp-control>");
271
+ if (matches.length !== 1 || controls.length !== 1)
272
+ throw adapterError("extension_setup_error");
273
+ const reserved = matches[0];
274
+ const control = controls[0];
275
+ const configured = base.extensions.filter(({ path }) => !path.startsWith("<inline:"));
276
+ const configuredBash = configured.some(({ tools }) => tools.has("bash"));
277
+ if (configuredBash)
278
+ control.tools.delete("bash");
279
+ // Pi records conflict diagnostics before this override can impose
280
+ // the adapter's deliberate precedence. Remove only the conflicts
281
+ // that this transaction has actually resolved: the reserved MCP
282
+ // extension is moved first, and core bash is omitted when a
283
+ // configured extension already owns it. User/user conflicts and
284
+ // every loader/factory error remain fatal and retain their order.
285
+ const reservedNames = new Set(reserved.tools.keys());
286
+ for (let index = base.errors.length - 1; index >= 0; index -= 1) {
287
+ const issue = base.errors[index];
288
+ const resolvedReserved = issue.path === reserved.path
289
+ && [...reservedNames].some((name) => issue.error.startsWith(`Tool "${name}" conflicts with `));
290
+ const resolvedBash = configuredBash
291
+ && issue.path === control.path
292
+ && issue.error.startsWith('Tool "bash" conflicts with ');
293
+ if (resolvedReserved || resolvedBash)
294
+ base.errors.splice(index, 1);
295
+ }
296
+ const result = { ...base, extensions: [reserved, ...base.extensions.filter((item) => item !== reserved)] };
297
+ if (result.runtime !== base.runtime || result.errors !== base.errors)
298
+ throw adapterError("extension_setup_error");
299
+ return result;
300
+ },
301
+ });
302
+ await resourceLoader.reload();
303
+ if (resourceLoader.getExtensions().errors.length > 0)
304
+ throw adapterError("extension_setup_error");
107
305
  const created = await this.deps.createAgentSession({
108
306
  cwd,
109
307
  sessionManager: manager,
110
308
  modelRuntime: this.deps.modelRuntime,
111
- customTools: [...bridge.tools, structured.tool],
309
+ resourceLoader,
310
+ settingsManager,
112
311
  });
113
312
  pi = created.session;
114
- this.gate(opening);
313
+ bindingState.pi = pi;
115
314
  wrapper = new PiSession({
116
315
  sessionId: id,
117
316
  session: pi,
118
317
  manager,
119
318
  client,
120
319
  deps: this.deps,
121
- mcpClients: bridge.clients,
320
+ mcpBridge: bridge,
122
321
  failedMcpResults: bridge.failedResults,
123
- structured,
124
- onWedged: (sessionId, session) => this.terminateWedged(sessionId, session),
322
+ availableModels: [],
323
+ childRegistry,
324
+ lifecycleController: lifecycle,
325
+ onWedged: (sessionId, session, cleanupRetryRequired) => this.terminateWedged(sessionId, session, cleanupRetryRequired),
125
326
  });
126
- structured.install(pi);
127
- const names = new Set(pi.getAllTools().map(({ name }) => name));
128
- if (!names.has(STRUCTURED_TOOL_NAME))
129
- throw adapterError("structured_tool_collision");
327
+ bindingState.wrapper = wrapper;
328
+ await pi.bindExtensions({});
329
+ const availableModels = [...await this.deps.modelRuntime.getAvailable()];
330
+ wrapper.publishAvailableModels(availableModels);
331
+ this.gate(opening);
332
+ bridge.bindSession(pi);
333
+ const toolInfos = pi.getAllTools();
334
+ const names = new Set(toolInfos.map(({ name }) => name));
130
335
  const missingAlias = bridge.aliases.find((alias) => !names.has(alias));
131
336
  if (missingAlias) {
132
337
  throw adapterError("mcp_init_error", {
133
338
  server: bridge.aliasServers.get(missingAlias) ?? "unknown",
134
339
  });
135
340
  }
341
+ for (const alias of bridge.aliases) {
342
+ const info = toolInfos.find(({ name }) => name === alias);
343
+ if (info?.sourceInfo.path !== "<inline:agentprism-pi-acp-mcp>") {
344
+ throw adapterError("mcp_init_error", { server: bridge.aliasServers.get(alias) ?? "unknown" });
345
+ }
346
+ }
347
+ const bash = toolInfos.find(({ name }) => name === "bash");
348
+ if (!bash || bash.sourceInfo.path === "<builtin:bash>")
349
+ throw adapterError("extension_setup_error");
136
350
  if (replay)
137
351
  await wrapper.replay(manager.getBranch());
352
+ bridge.assertReady();
138
353
  this.gate(opening);
139
354
  this.live.set(id, wrapper);
355
+ bindingState.published = true;
140
356
  this.opening.delete(id);
141
357
  return wrapper;
142
358
  }
143
359
  catch (error) {
144
- if (wrapper)
145
- await wrapper.disposeResources();
360
+ let cleanupError;
361
+ if (wrapper) {
362
+ try {
363
+ await wrapper.dispose();
364
+ }
365
+ catch (candidate) {
366
+ if (isChildCleanupError(candidate)) {
367
+ this.cleanupRecords.set(id, wrapper);
368
+ cleanupError = candidate;
369
+ }
370
+ }
371
+ }
146
372
  else {
147
373
  if (pi) {
374
+ const rollback = new FailedOpenCleanup(pi, bridge, childRegistry, lifecycle, this.deps);
148
375
  try {
149
- await pi.dispose();
376
+ await rollback.dispose();
150
377
  }
151
- catch (disposeError) {
152
- console.error("pi-acp rollback dispose error:", disposeError);
378
+ catch (candidate) {
379
+ if (isChildCleanupError(candidate)) {
380
+ this.cleanupRecords.set(id, rollback);
381
+ cleanupError = candidate;
382
+ }
153
383
  }
154
384
  }
155
- if (bridge)
156
- await disposeMcpBridge(bridge.clients, this.deps);
385
+ else if (bridge) {
386
+ bridge.startDisposal();
387
+ if (lifecycle && !lifecycle.signal.aborted)
388
+ lifecycle.abort(new Error("failed open disposed"));
389
+ bridge.abortRefreshes();
390
+ await bridge.close();
391
+ }
392
+ }
393
+ if (cleanupError) {
394
+ opening.cleanupError = cleanupError;
395
+ throw cleanupError;
157
396
  }
158
397
  throw error;
159
398
  }
160
399
  finally {
161
- if (opening.id !== undefined && this.opening.get(opening.id) === opening.controller) {
400
+ if (opening.id !== undefined && this.opening.get(opening.id) === opening) {
162
401
  this.opening.delete(opening.id);
163
402
  }
164
403
  this.openingControllers.delete(opening.controller);
404
+ opening.settle();
165
405
  }
166
406
  }
167
407
  openingError(error) {
@@ -180,11 +420,12 @@ export class PiAcpAgent {
180
420
  }
181
421
  const opening = this.beginOpening(context.signal);
182
422
  try {
183
- this.reserve(manager.getSessionId(), opening.controller);
423
+ this.reserve(manager.getSessionId(), opening);
184
424
  opening.id = manager.getSessionId();
185
425
  }
186
426
  catch (error) {
187
427
  this.openingControllers.delete(opening.controller);
428
+ opening.settle();
188
429
  throw error;
189
430
  }
190
431
  const task = this.construct(opening, manager, context.params.cwd, context.client, context.params.mcpServers, false).then((session) => ({ sessionId: session.sessionId, configOptions: session.configOptions(), modes: null }))
@@ -195,11 +436,12 @@ export class PiAcpAgent {
195
436
  validateCwd(context.params.cwd);
196
437
  const opening = this.beginOpening(context.signal);
197
438
  try {
198
- this.reserve(context.params.sessionId, opening.controller);
439
+ this.reserve(context.params.sessionId, opening);
199
440
  opening.id = context.params.sessionId;
200
441
  }
201
442
  catch (error) {
202
443
  this.openingControllers.delete(opening.controller);
444
+ opening.settle();
203
445
  throw error;
204
446
  }
205
447
  const task = (async () => {
@@ -221,10 +463,11 @@ export class PiAcpAgent {
221
463
  return { configOptions: session.configOptions(), modes: null };
222
464
  }
223
465
  catch (error) {
224
- if (opening.id !== undefined && this.opening.get(opening.id) === opening.controller) {
466
+ if (opening.id !== undefined && this.opening.get(opening.id) === opening) {
225
467
  this.opening.delete(opening.id);
226
468
  }
227
469
  this.openingControllers.delete(opening.controller);
470
+ opening.settle();
228
471
  return this.openingError(error);
229
472
  }
230
473
  })();
@@ -245,7 +488,8 @@ export class PiAcpAgent {
245
488
  validateCwd(context.params.cwd);
246
489
  const opening = this.beginOpening(context.signal);
247
490
  const task = (async () => {
248
- let bridge;
491
+ let prepared;
492
+ let preparedTransferred = false;
249
493
  try {
250
494
  let sourcePath;
251
495
  if (liveSource) {
@@ -260,32 +504,43 @@ export class PiAcpAgent {
260
504
  throw adapterError("unknown_session");
261
505
  }
262
506
  this.gate(opening);
263
- bridge = await bridgeMcpServers(context.params.mcpServers ?? [], opening.controller.signal, this.deps);
264
- this.gate(opening);
265
507
  if (this.tombstones.has(context.params.sessionId))
266
508
  throw adapterError("session_terminated");
267
509
  if (liveSource?.busy)
268
510
  throw adapterError("session_busy");
511
+ // Pin the target id before the irreversible journal write so the MCP
512
+ // binding can be fully connected and owned first. SessionManager
513
+ // validates and uses this exact id when forkFrom eventually writes.
514
+ const targetId = randomUUID();
515
+ this.reserve(targetId, opening);
516
+ opening.id = targetId;
517
+ prepared = await this.connectMcp(opening, targetId, context.params.cwd, context.client, context.params.mcpServers ?? []);
518
+ this.gate(opening);
269
519
  let manager;
270
520
  try {
271
- manager = this.deps.sessions.forkFrom(sourcePath, context.params.cwd, this.deps.sessionDir);
521
+ manager = this.deps.sessions.forkFrom(sourcePath, context.params.cwd, this.deps.sessionDir, { id: targetId });
272
522
  }
273
523
  catch {
274
524
  throw adapterError("session_corrupt");
275
525
  }
276
- this.reserve(manager.getSessionId(), opening.controller);
277
- opening.id = manager.getSessionId();
278
- const session = await this.construct(opening, manager, context.params.cwd, context.client, context.params.mcpServers ?? [], false, bridge);
279
- bridge = undefined;
526
+ preparedTransferred = true;
527
+ const session = await this.construct(opening, manager, context.params.cwd, context.client, context.params.mcpServers ?? [], false, prepared);
280
528
  return { sessionId: session.sessionId, configOptions: session.configOptions(), modes: null };
281
529
  }
282
530
  catch (error) {
283
- if (bridge)
284
- await disposeMcpBridge(bridge.clients, this.deps);
285
- if (opening.id !== undefined && this.opening.get(opening.id) === opening.controller) {
531
+ if (prepared && !preparedTransferred) {
532
+ prepared.bridge.startDisposal();
533
+ prepared.lifecycle.abort(new Error("fork failed before construction"));
534
+ prepared.bridge.abortRefreshes();
535
+ await prepared.bridge.close().catch((closeError) => {
536
+ console.error("pi-acp fork MCP rollback error:", closeError);
537
+ });
538
+ }
539
+ if (opening.id !== undefined && this.opening.get(opening.id) === opening) {
286
540
  this.opening.delete(opening.id);
287
541
  }
288
542
  this.openingControllers.delete(opening.controller);
543
+ opening.settle();
289
544
  return this.openingError(error);
290
545
  }
291
546
  })();
@@ -333,18 +588,27 @@ export class PiAcpAgent {
333
588
  };
334
589
  }
335
590
  async closeSession(context) {
336
- const controller = this.opening.get(context.params.sessionId);
337
- if (controller) {
338
- controller.abort(new Error("session closed while opening"));
591
+ const opening = this.opening.get(context.params.sessionId);
592
+ if (opening) {
593
+ opening.controller.abort(new Error("session closed while opening"));
594
+ await opening.settlement;
595
+ if (opening.cleanupError)
596
+ throw opening.cleanupError;
339
597
  return {};
340
598
  }
341
- const session = this.live.get(context.params.sessionId);
599
+ const session = this.live.get(context.params.sessionId) ?? this.cleanupRecords.get(context.params.sessionId);
342
600
  if (!session)
343
601
  return {};
344
602
  try {
345
603
  await session.dispose();
604
+ this.cleanupRecords.delete(context.params.sessionId);
346
605
  }
347
606
  catch (error) {
607
+ if (isChildCleanupError(error)) {
608
+ this.tombstones.add(context.params.sessionId);
609
+ this.cleanupRecords.set(context.params.sessionId, session);
610
+ throw error;
611
+ }
348
612
  console.error("pi-acp close error:", error);
349
613
  }
350
614
  finally {
@@ -377,22 +641,72 @@ export class PiAcpAgent {
377
641
  cancel(context) {
378
642
  this.live.get(context.params.sessionId)?.cancel();
379
643
  }
380
- async terminateWedged(id, session) {
644
+ async terminateWedged(id, session, cleanupRetryRequired) {
381
645
  this.tombstones.add(id);
382
646
  if (this.live.get(id) === session)
383
647
  this.live.delete(id);
384
- await session.disposeResources();
648
+ if (cleanupRetryRequired)
649
+ this.cleanupRecords.set(id, session);
650
+ try {
651
+ if (cleanupRetryRequired) {
652
+ await session.disposeAfterCleanupFailure();
653
+ }
654
+ else {
655
+ await session.dispose();
656
+ this.cleanupRecords.delete(id);
657
+ }
658
+ }
659
+ catch (error) {
660
+ if (isChildCleanupError(error) || session.cleanupRetryRequired) {
661
+ this.cleanupRecords.set(id, session);
662
+ }
663
+ else {
664
+ console.error("pi-acp wedged-session resource disposal error:", error);
665
+ }
666
+ }
385
667
  }
386
- async dispose() {
387
- if (this.disposed)
668
+ dispose() {
669
+ if (this.disposePromise)
670
+ return this.disposePromise;
671
+ if (this.disposeSucceeded)
672
+ return Promise.resolve();
673
+ this.disposePromise = this.disposeGeneration()
674
+ .then(() => { this.disposeSucceeded = true; })
675
+ .finally(() => {
676
+ if (!this.disposeSucceeded)
677
+ this.disposePromise = undefined;
678
+ });
679
+ return this.disposePromise;
680
+ }
681
+ async disposeGeneration() {
682
+ if (this.disposed && this.cleanupRecords.size === 0)
388
683
  return;
389
- this.disposed = true;
390
- for (const controller of this.openingControllers)
391
- controller.abort(new Error("agent disposed"));
392
- await Promise.allSettled([...this.openingTasks]);
393
- const sessions = [...this.live.values()];
684
+ if (!this.disposed) {
685
+ this.disposed = true;
686
+ for (const controller of this.openingControllers)
687
+ controller.abort(new Error("agent disposed"));
688
+ await Promise.allSettled([...this.openingTasks]);
689
+ }
690
+ const records = new Map(this.cleanupRecords);
691
+ for (const [id, session] of this.live)
692
+ records.set(id, session);
394
693
  this.live.clear();
395
- await Promise.allSettled(sessions.map((session) => session.dispose()));
396
- this.tombstones.clear();
694
+ const entries = [...records.entries()];
695
+ const results = await Promise.allSettled(entries.map(([, session]) => session.dispose()));
696
+ this.cleanupRecords.clear();
697
+ let sawChildFailure = false;
698
+ for (let index = 0; index < results.length; index += 1) {
699
+ const result = results[index];
700
+ if (result.status === "rejected" && isChildCleanupError(result.reason)) {
701
+ const [id, session] = entries[index];
702
+ this.cleanupRecords.set(id, session);
703
+ sawChildFailure = true;
704
+ }
705
+ }
706
+ if (sawChildFailure) {
707
+ const remainingChildren = [...this.cleanupRecords.values()]
708
+ .reduce((sum, session) => sum + session.remainingChildren, 0);
709
+ throw adapterError("child_cleanup_error", { details: { remainingChildren } });
710
+ }
397
711
  }
398
712
  }