@gajae-code/coding-agent 0.16.3 → 0.16.4
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/CHANGELOG.md +59 -0
- package/dist/types/cli/args.d.ts +2 -2
- package/dist/types/cli/root-flags.d.ts +4 -2
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/model-resolver.d.ts +9 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- package/dist/types/discovery/claude-plugins.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/settings-selector.d.ts +6 -1
- package/dist/types/modes/components/status-line/command.d.ts +35 -0
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +5 -0
- package/dist/types/modes/components/tool-status-header.d.ts +14 -0
- package/dist/types/modes/controllers/event-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/client.d.ts +9 -1
- package/dist/types/runtime-mcp/manager.d.ts +7 -1
- package/dist/types/runtime-mcp/pool.d.ts +22 -3
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/runtime-mcp/types.d.ts +22 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
- package/dist/types/sdk/host/session-runtime.d.ts +23 -0
- package/dist/types/sdk/session-api-key.d.ts +26 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/setup/paseo/announce.d.ts +111 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/fetch.d.ts +7 -0
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/path-utils.d.ts +2 -0
- package/dist/types/tools/search.d.ts +6 -0
- package/package.json +7 -7
- package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
- package/scripts/generate-sdk-operation-inventory.ts +9 -0
- package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
- package/src/cli/args.ts +20 -9
- package/src/cli/root-flags.ts +5 -3
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/file-lock.ts +181 -17
- package/src/config/model-preset-registry.ts +5 -3
- package/src/config/model-profiles.ts +40 -0
- package/src/config/model-registry.ts +57 -11
- package/src/config/model-resolver.ts +48 -0
- package/src/config/settings-schema.ts +33 -1
- package/src/config/settings.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
- package/src/discovery/builtin.ts +13 -4
- package/src/discovery/claude-plugins.ts +12 -4
- package/src/discovery/helpers.ts +10 -2
- package/src/extensibility/gjc-plugins/compiler.ts +208 -58
- package/src/extensibility/gjc-plugins/installer.ts +13 -12
- package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
- package/src/extensibility/gjc-plugins/paths.ts +13 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
- package/src/extensibility/runtime-skill-discovery.ts +77 -151
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +44 -16
- package/src/gjc-runtime/repository-binding.ts +1 -1
- package/src/gjc-runtime/session-state-lock.ts +92 -2
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/main.ts +72 -34
- package/src/modes/DESIGN.md +54 -0
- package/src/modes/components/assistant-message.ts +22 -9
- package/src/modes/components/model-selector.ts +166 -33
- package/src/modes/components/settings-selector.ts +1075 -235
- package/src/modes/components/status-line/command.ts +190 -0
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/segments.ts +32 -5
- package/src/modes/components/status-line/types.ts +5 -0
- package/src/modes/components/tool-status-header.ts +393 -21
- package/src/modes/controllers/command-controller.ts +2 -0
- package/src/modes/controllers/event-controller.ts +148 -5
- package/src/modes/controllers/selector-controller.ts +21 -0
- package/src/modes/interactive-mode.ts +47 -1
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/system/system-prompt.md +8 -0
- package/src/prompts/tools/search.md +2 -0
- package/src/runtime-mcp/client.ts +60 -17
- package/src/runtime-mcp/manager.ts +171 -14
- package/src/runtime-mcp/pool.test.ts +209 -0
- package/src/runtime-mcp/pool.ts +224 -23
- package/src/runtime-mcp/transports/stdio.ts +158 -24
- package/src/runtime-mcp/types.ts +24 -0
- package/src/sdk/broker/broker.ts +5 -4
- package/src/sdk/bus/chat-daemon-control.ts +1 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +37 -1
- package/src/sdk/bus/slack-daemon.ts +92 -2
- package/src/sdk/bus/slack-live-provider.ts +12 -0
- package/src/sdk/bus/slack-provider.ts +5 -0
- package/src/sdk/cli/session-cli.ts +9 -6
- package/src/sdk/host/session-runtime.test.ts +2 -2
- package/src/sdk/host/session-runtime.ts +275 -43
- package/src/sdk/protocol/operation-inventory.generated.json +66 -0
- package/src/sdk/session-api-key.ts +94 -0
- package/src/sdk/session.ts +73 -29
- package/src/sdk/transport/serve-cli.ts +3 -3
- package/src/session/agent-session.ts +123 -38
- package/src/session/contribution-prep.ts +214 -21
- package/src/setup/paseo/announce.ts +450 -0
- package/src/setup/paseo/setup-deps.ts +15 -3
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/system-prompt.ts +4 -0
- package/src/task/executor.ts +2 -1
- package/src/task/index.ts +17 -4
- package/src/tools/bash.ts +35 -8
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +29 -1
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +8 -2
- package/src/tools/monitor.ts +8 -2
- package/src/tools/path-utils.ts +7 -0
- package/src/tools/search.ts +116 -58
- package/src/tools/skill.ts +22 -2
- package/src/tools/subagent.ts +3 -1
- package/src/tools/tool-catalog.generated.ts +8 -1
- package/src/web/insane/bridge.ts +113 -23
- package/src/web/search/providers/perplexity.ts +102 -41
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
listResources,
|
|
21
21
|
listResourceTemplates,
|
|
22
22
|
listTools,
|
|
23
|
+
MCPConnectionCleanupFailure,
|
|
23
24
|
readResource,
|
|
24
25
|
serverSupportsPrompts,
|
|
25
26
|
serverSupportsResources,
|
|
@@ -344,6 +345,8 @@ export class MCPManager {
|
|
|
344
345
|
#pendingConnections = new Map<string, Promise<MCPServerConnection>>();
|
|
345
346
|
#pendingConnectionControllers = new Map<string, AbortController>();
|
|
346
347
|
#pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
|
|
348
|
+
#pendingConnectionCleanups = new Map<string, MCPConnectionCleanupFailure>();
|
|
349
|
+
#pendingAcquireCleanups = new Map<string, Set<MCPPoolAcquireAbortError>>();
|
|
347
350
|
#sources = new Map<string, SourceMeta>();
|
|
348
351
|
#authStorage: AuthStorage | null = null;
|
|
349
352
|
#inputRequestHandler: MCPInputRequestHandler | null = null;
|
|
@@ -373,6 +376,79 @@ export class MCPManager {
|
|
|
373
376
|
#serverError(message: string): string {
|
|
374
377
|
return this.#toolsOnly ? "MCP server unavailable" : message;
|
|
375
378
|
}
|
|
379
|
+
|
|
380
|
+
#removePendingAcquireCleanup(name: string, error: MCPPoolAcquireAbortError): void {
|
|
381
|
+
const pending = this.#pendingAcquireCleanups.get(name);
|
|
382
|
+
pending?.delete(error);
|
|
383
|
+
if (pending?.size === 0) this.#pendingAcquireCleanups.delete(name);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#retainPendingAcquireCleanup(name: string, error: MCPPoolAcquireAbortError): void {
|
|
387
|
+
if (error.cleanupGeneration === undefined || !error.cleanup || !this.#pool.hasPendingAcquireCleanup(error))
|
|
388
|
+
return;
|
|
389
|
+
let pending = this.#pendingAcquireCleanups.get(name);
|
|
390
|
+
if (!pending) {
|
|
391
|
+
pending = new Set();
|
|
392
|
+
this.#pendingAcquireCleanups.set(name, pending);
|
|
393
|
+
}
|
|
394
|
+
if (pending.has(error)) return;
|
|
395
|
+
pending.add(error);
|
|
396
|
+
void error.cleanup.catch(() => {});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#retainConnectionCleanupFailure(name: string, error: unknown): MCPConnectionCleanupFailure | undefined {
|
|
400
|
+
if (error instanceof MCPPoolAcquireAbortError) {
|
|
401
|
+
this.#retainPendingAcquireCleanup(name, error);
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
if (!(error instanceof MCPConnectionCleanupFailure)) return undefined;
|
|
405
|
+
if (!this.#pool.ownsPendingAcquireCleanupFailure(error)) this.#pendingConnectionCleanups.set(name, error);
|
|
406
|
+
return error;
|
|
407
|
+
}
|
|
408
|
+
async #closePendingConnectionCleanup(name: string, waitForAcquireFlight = true): Promise<void> {
|
|
409
|
+
const failures: unknown[] = [];
|
|
410
|
+
const pendingAcquires = [...(this.#pendingAcquireCleanups.get(name) ?? [])];
|
|
411
|
+
const acquireResults = await Promise.allSettled(
|
|
412
|
+
pendingAcquires.map(error => {
|
|
413
|
+
if (!error.cleanup) return Promise.resolve();
|
|
414
|
+
if (!waitForAcquireFlight && !this.#pool.getPendingAcquireCleanupFailure(error)) return Promise.resolve();
|
|
415
|
+
return error.cleanup.then(
|
|
416
|
+
() => undefined,
|
|
417
|
+
() =>
|
|
418
|
+
this.#pool.closePendingAcquireCleanup(error.poolKey, error.cleanupGeneration!).then(() => undefined),
|
|
419
|
+
);
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
for (const [index, result] of acquireResults.entries()) {
|
|
423
|
+
const pendingAcquire = pendingAcquires[index];
|
|
424
|
+
if (!pendingAcquire) continue;
|
|
425
|
+
if (result.status === "rejected") failures.push(result.reason);
|
|
426
|
+
if (!this.#pool.hasPendingAcquireCleanup(pendingAcquire)) {
|
|
427
|
+
this.#removePendingAcquireCleanup(name, pendingAcquire);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const cleanup = this.#pendingConnectionCleanups.get(name);
|
|
432
|
+
if (cleanup) {
|
|
433
|
+
let cleanupFailed = false;
|
|
434
|
+
try {
|
|
435
|
+
await cleanup.close();
|
|
436
|
+
} catch (error) {
|
|
437
|
+
cleanupFailed = true;
|
|
438
|
+
const retained = error instanceof MCPConnectionCleanupFailure ? error : cleanup;
|
|
439
|
+
if (this.#pendingConnectionCleanups.get(name) === cleanup) {
|
|
440
|
+
this.#pendingConnectionCleanups.set(name, retained);
|
|
441
|
+
}
|
|
442
|
+
failures.push(error);
|
|
443
|
+
}
|
|
444
|
+
if (this.#pendingConnectionCleanups.get(name) === cleanup && !cleanupFailed) {
|
|
445
|
+
this.#pendingConnectionCleanups.delete(name);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (failures.length === 1) throw failures[0];
|
|
449
|
+
if (failures.length > 1) throw new AggregateError(failures, `MCP server cleanup failed: ${name}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
376
452
|
#assertRawMCPAccessAllowed(): void {
|
|
377
453
|
if (this.#toolsOnly) throw new Error("Tools-only MCP manager does not allow raw MCP access");
|
|
378
454
|
}
|
|
@@ -413,6 +489,11 @@ export class MCPManager {
|
|
|
413
489
|
connectionAbort: AbortController,
|
|
414
490
|
trackManagerLease = true,
|
|
415
491
|
): Promise<MCPPoolLease> {
|
|
492
|
+
const pendingCleanup = this.#pendingConnectionCleanups.get(name);
|
|
493
|
+
if (pendingCleanup) throw pendingCleanup;
|
|
494
|
+
if (connectionAbort.signal.aborted) {
|
|
495
|
+
throw connectionAbort.signal.reason ?? new Error(`MCP connection acquisition aborted: ${name}`);
|
|
496
|
+
}
|
|
416
497
|
const sharedConfig = originalConfig.sharing === "shared";
|
|
417
498
|
const sharedEligible =
|
|
418
499
|
sharedConfig &&
|
|
@@ -435,6 +516,7 @@ export class MCPManager {
|
|
|
435
516
|
effectiveHeaders:
|
|
436
517
|
resolvedConfig.type === "http" || resolvedConfig.type === "sse" ? resolvedConfig.headers : undefined,
|
|
437
518
|
onRequest: (method, params) => this.#handleServerRequest(method, params),
|
|
519
|
+
onCleanupPending: error => this.#retainPendingAcquireCleanup(name, error),
|
|
438
520
|
});
|
|
439
521
|
if (trackManagerLease) this.#leaseByConnection.set(lease.connection, lease);
|
|
440
522
|
if (!this.#toolsOnly) lease.updateRoots(this.#getRoots().roots);
|
|
@@ -958,6 +1040,21 @@ export class MCPManager {
|
|
|
958
1040
|
const connectionTasks: ConnectionTask[] = [];
|
|
959
1041
|
|
|
960
1042
|
for (const [name, config] of Object.entries(configs)) {
|
|
1043
|
+
const pendingAcquire = this.#pendingAcquireCleanups.get(name);
|
|
1044
|
+
const pendingCleanup = this.#pendingConnectionCleanups.get(name);
|
|
1045
|
+
if (pendingCleanup || pendingAcquire) {
|
|
1046
|
+
const message =
|
|
1047
|
+
pendingCleanup?.message ??
|
|
1048
|
+
(pendingAcquire
|
|
1049
|
+
? [...pendingAcquire]
|
|
1050
|
+
.map(error => this.#pool.getPendingAcquireCleanupFailure(error)?.message ?? error.message)
|
|
1051
|
+
.at(0)
|
|
1052
|
+
: undefined) ??
|
|
1053
|
+
"MCP connection cleanup pending";
|
|
1054
|
+
errors.set(name, this.#serverError(message));
|
|
1055
|
+
reportedErrors.add(name);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
961
1058
|
if (sources[name]) {
|
|
962
1059
|
this.#sources.set(name, sources[name]);
|
|
963
1060
|
const existing = this.#connections.get(name);
|
|
@@ -1136,6 +1233,7 @@ export class MCPManager {
|
|
|
1136
1233
|
if (!this.#toolsOnly) await this.#loadServerResourcesAndPrompts(name, connection);
|
|
1137
1234
|
})
|
|
1138
1235
|
.catch(error => {
|
|
1236
|
+
this.#retainConnectionCleanupFailure(name, error);
|
|
1139
1237
|
if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
|
|
1140
1238
|
this.#pendingToolLoads.delete(name);
|
|
1141
1239
|
if (!allowBackgroundLogging || reportedErrors.has(name) || this.#toolsOnly) return;
|
|
@@ -1270,6 +1368,7 @@ export class MCPManager {
|
|
|
1270
1368
|
}
|
|
1271
1369
|
} else if (task.tracked.status === "rejected") {
|
|
1272
1370
|
const reason = task.tracked.reason;
|
|
1371
|
+
this.#retainConnectionCleanupFailure(name, reason);
|
|
1273
1372
|
const message = reason instanceof Error ? reason.message : String(reason);
|
|
1274
1373
|
errors.set(name, this.#serverError(message));
|
|
1275
1374
|
reportedErrors.add(name);
|
|
@@ -1582,6 +1681,7 @@ export class MCPManager {
|
|
|
1582
1681
|
});
|
|
1583
1682
|
result = await Promise.race([callbackPromise, abortPromise]);
|
|
1584
1683
|
} catch (error) {
|
|
1684
|
+
this.#retainConnectionCleanupFailure(operation.name, error);
|
|
1585
1685
|
failed = true;
|
|
1586
1686
|
primaryError = error;
|
|
1587
1687
|
} finally {
|
|
@@ -1609,6 +1709,28 @@ export class MCPManager {
|
|
|
1609
1709
|
get retiredLeaseReleaseCountForTests(): number {
|
|
1610
1710
|
return this.#retiredLeaseReleases.size;
|
|
1611
1711
|
}
|
|
1712
|
+
|
|
1713
|
+
/** Read-only test seam for retained failed-connection cleanup owners. */
|
|
1714
|
+
get pendingConnectionCleanupCountForTests(): number {
|
|
1715
|
+
return (
|
|
1716
|
+
this.#pendingConnectionCleanups.size +
|
|
1717
|
+
[...this.#pendingAcquireCleanups.values()].reduce((count, pending) => count + pending.size, 0)
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Read-only test seam for retained failed-connection cleanup causes. */
|
|
1722
|
+
getPendingConnectionCleanupFailureForTests(name: string): MCPConnectionCleanupFailure | undefined {
|
|
1723
|
+
const pendingAcquire = this.#pendingAcquireCleanups.get(name);
|
|
1724
|
+
return (
|
|
1725
|
+
this.#pendingConnectionCleanups.get(name) ??
|
|
1726
|
+
(pendingAcquire
|
|
1727
|
+
? [...pendingAcquire]
|
|
1728
|
+
.map(error => this.#pool.getPendingAcquireCleanupFailure(error))
|
|
1729
|
+
.find((error): error is MCPConnectionCleanupFailure => error !== undefined)
|
|
1730
|
+
: undefined)
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1612
1734
|
/**
|
|
1613
1735
|
* Get all connected server names.
|
|
1614
1736
|
*/
|
|
@@ -1621,7 +1743,13 @@ export class MCPManager {
|
|
|
1621
1743
|
*/
|
|
1622
1744
|
getAllServerNames(): string[] {
|
|
1623
1745
|
return Array.from(
|
|
1624
|
-
new Set([
|
|
1746
|
+
new Set([
|
|
1747
|
+
...this.#sources.keys(),
|
|
1748
|
+
...this.#connections.keys(),
|
|
1749
|
+
...this.#pendingConnections.keys(),
|
|
1750
|
+
...this.#pendingConnectionCleanups.keys(),
|
|
1751
|
+
...this.#pendingAcquireCleanups.keys(),
|
|
1752
|
+
]),
|
|
1625
1753
|
);
|
|
1626
1754
|
}
|
|
1627
1755
|
|
|
@@ -1652,11 +1780,20 @@ export class MCPManager {
|
|
|
1652
1780
|
this.#subscribedResources.delete(name);
|
|
1653
1781
|
|
|
1654
1782
|
let closeError: unknown;
|
|
1783
|
+
try {
|
|
1784
|
+
await this.#closePendingConnectionCleanup(name);
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
closeError = error;
|
|
1787
|
+
}
|
|
1655
1788
|
if (connection) {
|
|
1656
1789
|
try {
|
|
1657
1790
|
await this.#releaseLease(name);
|
|
1658
1791
|
} catch (error) {
|
|
1659
|
-
|
|
1792
|
+
const leaseError = this.#logLeaseReleaseFailure(name, connection, error);
|
|
1793
|
+
closeError =
|
|
1794
|
+
closeError !== undefined
|
|
1795
|
+
? new AggregateError([closeError, leaseError], `MCP server cleanup failed: ${name}`)
|
|
1796
|
+
: leaseError;
|
|
1660
1797
|
}
|
|
1661
1798
|
if (this.#connections.get(name) === connection) this.#connections.delete(name);
|
|
1662
1799
|
}
|
|
@@ -1672,7 +1809,7 @@ export class MCPManager {
|
|
|
1672
1809
|
|
|
1673
1810
|
// Notify prompt consumers so stale commands are cleared
|
|
1674
1811
|
if (connection?.prompts?.length) this.#onPromptsChanged?.(name);
|
|
1675
|
-
if (closeError) throw closeError;
|
|
1812
|
+
if (closeError !== undefined) throw closeError;
|
|
1676
1813
|
}
|
|
1677
1814
|
|
|
1678
1815
|
#abortConnectionTask(task: ConnectionTask): void {
|
|
@@ -1692,12 +1829,8 @@ export class MCPManager {
|
|
|
1692
1829
|
}
|
|
1693
1830
|
|
|
1694
1831
|
async #terminateConnectionTask(task: ConnectionTask): Promise<void> {
|
|
1695
|
-
const connection = await task.connectionPromise.catch(
|
|
1696
|
-
|
|
1697
|
-
await error.cleanup.catch(cleanupError => {
|
|
1698
|
-
logger.error("MCP aborted acquire cleanup failed", { path: `mcp:${task.name}`, error: cleanupError });
|
|
1699
|
-
});
|
|
1700
|
-
}
|
|
1832
|
+
const connection = await task.connectionPromise.catch(error => {
|
|
1833
|
+
this.#retainConnectionCleanupFailure(task.name, error);
|
|
1701
1834
|
logger.debug("MCP connection task did not publish before cleanup", { path: `mcp:${task.name}`, error });
|
|
1702
1835
|
return undefined;
|
|
1703
1836
|
});
|
|
@@ -1731,6 +1864,14 @@ export class MCPManager {
|
|
|
1731
1864
|
const lifecycleEpoch = this.#beginScopedLifecycle("disconnecting");
|
|
1732
1865
|
this.#deferredSharedRebinds.clear();
|
|
1733
1866
|
try {
|
|
1867
|
+
const cleanupNames = new Set([
|
|
1868
|
+
...this.#pendingConnectionCleanups.keys(),
|
|
1869
|
+
...this.#pendingAcquireCleanups.keys(),
|
|
1870
|
+
...this.#pendingConnectionControllers.keys(),
|
|
1871
|
+
...this.#pendingConnections.keys(),
|
|
1872
|
+
...this.#serverConfigs.keys(),
|
|
1873
|
+
...[...this.#scopedOperations.values()].map(operation => operation.name),
|
|
1874
|
+
]);
|
|
1734
1875
|
// Invalidate any in-flight reconnection attempts that outlive this call.
|
|
1735
1876
|
// They captured the old epoch; after increment they'll detect staleness.
|
|
1736
1877
|
this.#epoch++;
|
|
@@ -1757,6 +1898,11 @@ export class MCPManager {
|
|
|
1757
1898
|
}
|
|
1758
1899
|
this.#reconnectBackoffs.clear();
|
|
1759
1900
|
this.#pendingReconnections.clear();
|
|
1901
|
+
for (const name of this.#pendingConnectionCleanups.keys()) cleanupNames.add(name);
|
|
1902
|
+
for (const name of this.#pendingAcquireCleanups.keys()) cleanupNames.add(name);
|
|
1903
|
+
const pendingCleanupResults = await Promise.allSettled(
|
|
1904
|
+
[...cleanupNames].map(name => this.#closePendingConnectionCleanup(name, false)),
|
|
1905
|
+
);
|
|
1760
1906
|
const retiredReleaseFailures = await this.#drainRetiredLeaseReleases();
|
|
1761
1907
|
this.#deferredSharedRebinds.clear();
|
|
1762
1908
|
this.#pendingResourceRefresh.clear();
|
|
@@ -1770,6 +1916,9 @@ export class MCPManager {
|
|
|
1770
1916
|
...releaseResults
|
|
1771
1917
|
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
|
|
1772
1918
|
.map(result => result.reason),
|
|
1919
|
+
...pendingCleanupResults
|
|
1920
|
+
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
|
|
1921
|
+
.map(result => result.reason),
|
|
1773
1922
|
...retiredReleaseFailures,
|
|
1774
1923
|
];
|
|
1775
1924
|
if (releaseFailures.length > 0) {
|
|
@@ -1793,7 +1942,8 @@ export class MCPManager {
|
|
|
1793
1942
|
* Tears down the stale connection, re-resolves auth, establishes a new
|
|
1794
1943
|
* connection, reloads tools, and notifies consumers.
|
|
1795
1944
|
* Concurrent calls for the same server share one reconnection attempt.
|
|
1796
|
-
* Returns the new connection, or null if reconnection
|
|
1945
|
+
* Returns the new connection, or null if reconnection fails after cleanup.
|
|
1946
|
+
* Throws a retryable cleanup failure while the previous transport owner remains incomplete.
|
|
1797
1947
|
*/
|
|
1798
1948
|
async reconnectServer(name: string): Promise<MCPServerConnection | null> {
|
|
1799
1949
|
if (this.#connectionSetSealed) return null;
|
|
@@ -1801,6 +1951,14 @@ export class MCPManager {
|
|
|
1801
1951
|
const pending = this.#pendingReconnections.get(name);
|
|
1802
1952
|
if (pending) return pending;
|
|
1803
1953
|
|
|
1954
|
+
const attempt = this.#closePendingConnectionCleanup(name).then(() => this.#startReconnect(name));
|
|
1955
|
+
this.#pendingReconnections.set(name, attempt);
|
|
1956
|
+
return attempt.finally(() => {
|
|
1957
|
+
if (this.#pendingReconnections.get(name) === attempt) this.#pendingReconnections.delete(name);
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
#startReconnect(name: string): Promise<MCPServerConnection | null> {
|
|
1804
1962
|
const lease = this.#leases.get(name);
|
|
1805
1963
|
const sharedKey = lease?.sharingMode === "shared" ? lease.key : undefined;
|
|
1806
1964
|
let attempt: Promise<MCPServerConnection | null>;
|
|
@@ -1831,10 +1989,7 @@ export class MCPManager {
|
|
|
1831
1989
|
.finally(() => this.#pool.releaseRestart(sharedKey));
|
|
1832
1990
|
}
|
|
1833
1991
|
}
|
|
1834
|
-
|
|
1835
|
-
return attempt.finally(() => {
|
|
1836
|
-
if (this.#pendingReconnections.get(name) === attempt) this.#pendingReconnections.delete(name);
|
|
1837
|
-
});
|
|
1992
|
+
return attempt;
|
|
1838
1993
|
}
|
|
1839
1994
|
|
|
1840
1995
|
async #doReconnect(name: string): Promise<MCPServerConnection | null> {
|
|
@@ -1905,6 +2060,7 @@ export class MCPManager {
|
|
|
1905
2060
|
logger.debug("MCP reconnected", { path: `mcp:${name}`, tools: connection.tools?.length ?? 0 });
|
|
1906
2061
|
return connection;
|
|
1907
2062
|
} catch (error) {
|
|
2063
|
+
const cleanupFailure = this.#retainConnectionCleanupFailure(name, error);
|
|
1908
2064
|
if ((this.#disconnectEpochs.get(name) ?? 0) !== reconnectEpoch || backoffAbort.signal.aborted) {
|
|
1909
2065
|
logger.debug("MCP reconnect aborted after server disconnected", {
|
|
1910
2066
|
path: `mcp:${name}`,
|
|
@@ -1913,6 +2069,7 @@ export class MCPManager {
|
|
|
1913
2069
|
});
|
|
1914
2070
|
return null;
|
|
1915
2071
|
}
|
|
2072
|
+
if (cleanupFailure) throw cleanupFailure;
|
|
1916
2073
|
|
|
1917
2074
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1918
2075
|
if (attempt < delays.length) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MCPConnectionCleanupFailure } from "./client";
|
|
3
|
+
import { MCPManager } from "./manager";
|
|
2
4
|
import { MCPConnectionPool, MCPPoolAcquireAbortError, MCPPoolLeaseInvalidatedError } from "./pool";
|
|
3
5
|
import { MCPPoolConfigError } from "./pool-key";
|
|
4
6
|
import { legacyEraObservation } from "./protocol";
|
|
@@ -48,6 +50,17 @@ class FakeTransport implements MCPTransport {
|
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
class ToolListTransport extends FakeTransport {
|
|
54
|
+
override async request<T = unknown>(
|
|
55
|
+
method: string,
|
|
56
|
+
params?: Record<string, unknown>,
|
|
57
|
+
options?: MCPRequestOptions,
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
if (method === "tools/list") return { tools: [] } as T;
|
|
60
|
+
return super.request<T>(method, params, options);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
class DelayedSubscriptionTransport extends FakeTransport {
|
|
52
65
|
readonly subscribeStarted = Promise.withResolvers<void>();
|
|
53
66
|
readonly allowSubscribe = Promise.withResolvers<void>();
|
|
@@ -373,6 +386,202 @@ describe("MCPConnectionPool", () => {
|
|
|
373
386
|
await pool.shutdown();
|
|
374
387
|
});
|
|
375
388
|
|
|
389
|
+
test("retains cancelled cleanup before late failure and fences each cleanup generation", async () => {
|
|
390
|
+
const flights = [Promise.withResolvers<MCPServerConnection>(), Promise.withResolvers<MCPServerConnection>()];
|
|
391
|
+
const starts = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
|
|
392
|
+
const recoveryTransports: FakeTransport[] = [];
|
|
393
|
+
let opens = 0;
|
|
394
|
+
const pool = new MCPConnectionPool({
|
|
395
|
+
connect: async (name, cfg) => {
|
|
396
|
+
opens += 1;
|
|
397
|
+
if (opens === 1) {
|
|
398
|
+
starts[0]?.resolve();
|
|
399
|
+
return flights[0]!.promise;
|
|
400
|
+
}
|
|
401
|
+
if (opens === 3) {
|
|
402
|
+
starts[1]?.resolve();
|
|
403
|
+
return flights[1]!.promise;
|
|
404
|
+
}
|
|
405
|
+
const transport = new FakeTransport();
|
|
406
|
+
recoveryTransports.push(transport);
|
|
407
|
+
return connection(name, cfg, transport);
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
const acquireCancelledFlight = async (index: number): Promise<MCPPoolAcquireAbortError> => {
|
|
411
|
+
const controller = new AbortController();
|
|
412
|
+
const acquire = pool.acquire("server", config(), {
|
|
413
|
+
sessionId: "retained-cleanup",
|
|
414
|
+
signal: controller.signal,
|
|
415
|
+
});
|
|
416
|
+
await starts[index]!.promise;
|
|
417
|
+
controller.abort(new Error(`caller abort ${index}`));
|
|
418
|
+
const error = await acquire.catch(error => error);
|
|
419
|
+
expect(error).toBeInstanceOf(MCPPoolAcquireAbortError);
|
|
420
|
+
if (!(error instanceof MCPPoolAcquireAbortError)) throw new Error("Expected aborted pool acquisition");
|
|
421
|
+
return error;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const firstAbort = await acquireCancelledFlight(0);
|
|
425
|
+
expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
|
|
426
|
+
expect(opens).toBe(1);
|
|
427
|
+
const firstCleanupTransport = new FakeTransport();
|
|
428
|
+
let fencedSettled = false;
|
|
429
|
+
const fenced = pool.acquire("server", config(), { sessionId: "retained-cleanup" });
|
|
430
|
+
void fenced.then(
|
|
431
|
+
() => {
|
|
432
|
+
fencedSettled = true;
|
|
433
|
+
},
|
|
434
|
+
() => {
|
|
435
|
+
fencedSettled = true;
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
await Promise.resolve();
|
|
439
|
+
expect(fencedSettled).toBe(false);
|
|
440
|
+
expect(opens).toBe(1);
|
|
441
|
+
flights[0]!.reject(new MCPConnectionCleanupFailure(new Error("late cleanup failure 1"), firstCleanupTransport));
|
|
442
|
+
await expect(fenced).resolves.toBeDefined();
|
|
443
|
+
expect(firstCleanupTransport.closeCount).toBe(1);
|
|
444
|
+
expect(opens).toBe(2);
|
|
445
|
+
|
|
446
|
+
const secondLease = await fenced;
|
|
447
|
+
await secondLease.release();
|
|
448
|
+
const secondAbort = await acquireCancelledFlight(1);
|
|
449
|
+
expect(secondAbort.cleanupGeneration).toBeGreaterThan(firstAbort.cleanupGeneration ?? 0);
|
|
450
|
+
const secondCleanupTransport = new FakeTransport();
|
|
451
|
+
flights[1]!.reject(new MCPConnectionCleanupFailure(new Error("late cleanup failure 2"), secondCleanupTransport));
|
|
452
|
+
await expect(secondAbort.cleanup).rejects.toBeInstanceOf(MCPConnectionCleanupFailure);
|
|
453
|
+
await pool.closePendingAcquireCleanup(firstAbort.poolKey, firstAbort.cleanupGeneration!);
|
|
454
|
+
expect(secondCleanupTransport.closeCount).toBe(0);
|
|
455
|
+
expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
|
|
456
|
+
await pool.closePendingAcquireCleanup(secondAbort.poolKey, secondAbort.cleanupGeneration!);
|
|
457
|
+
expect(secondCleanupTransport.closeCount).toBe(1);
|
|
458
|
+
expect(pool.pendingAcquireCleanupCountForTests).toBe(0);
|
|
459
|
+
const recovered = await pool.acquire("server", config(), { sessionId: "retained-cleanup" });
|
|
460
|
+
expect(opens).toBe(4);
|
|
461
|
+
await recovered.release();
|
|
462
|
+
expect(recoveryTransports).toHaveLength(2);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("manager disconnect joins and retries pool-owned late cleanup without spawning", async () => {
|
|
466
|
+
const firstFlight = Promise.withResolvers<MCPServerConnection>();
|
|
467
|
+
const firstStarted = Promise.withResolvers<void>();
|
|
468
|
+
const cleanupTransport = new FakeTransport();
|
|
469
|
+
const retryFailure = new Error("deferred cleanup retry failed");
|
|
470
|
+
let cleanupAttempts = 0;
|
|
471
|
+
cleanupTransport.close = async () => {
|
|
472
|
+
cleanupAttempts += 1;
|
|
473
|
+
if (cleanupAttempts === 1) throw retryFailure;
|
|
474
|
+
cleanupTransport.connected = false;
|
|
475
|
+
};
|
|
476
|
+
let opens = 0;
|
|
477
|
+
const pool = new MCPConnectionPool({
|
|
478
|
+
connect: async (name, cfg) => {
|
|
479
|
+
opens += 1;
|
|
480
|
+
if (opens === 1) {
|
|
481
|
+
firstStarted.resolve();
|
|
482
|
+
return firstFlight.promise;
|
|
483
|
+
}
|
|
484
|
+
return connection(name, cfg, new FakeTransport());
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
const manager = new MCPManager(".", null, { pool, sessionId: "disconnect-cleanup" });
|
|
488
|
+
const serverConfig = config();
|
|
489
|
+
const controller = new AbortController();
|
|
490
|
+
const operation = manager.withPreparedLease("server", serverConfig, async () => undefined, {
|
|
491
|
+
signal: controller.signal,
|
|
492
|
+
});
|
|
493
|
+
await firstStarted.promise;
|
|
494
|
+
controller.abort(new Error("abort before deferred cleanup failure"));
|
|
495
|
+
await expect(operation).rejects.toBeInstanceOf(MCPPoolAcquireAbortError);
|
|
496
|
+
expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
|
|
497
|
+
|
|
498
|
+
const disconnect = manager.disconnectServer("server");
|
|
499
|
+
const fenced = await manager.connectServers({ server: serverConfig }, {});
|
|
500
|
+
expect(fenced.errors.has("server")).toBe(true);
|
|
501
|
+
expect(opens).toBe(1);
|
|
502
|
+
const primaryFailure = new Error("late connection cleanup failed");
|
|
503
|
+
firstFlight.reject(new MCPConnectionCleanupFailure(primaryFailure, cleanupTransport));
|
|
504
|
+
const disconnectError = await disconnect.catch(error => error);
|
|
505
|
+
expect(disconnectError).toBeInstanceOf(MCPConnectionCleanupFailure);
|
|
506
|
+
if (!(disconnectError instanceof MCPConnectionCleanupFailure)) {
|
|
507
|
+
throw new Error("Expected retained disconnect cleanup failure");
|
|
508
|
+
}
|
|
509
|
+
expect(disconnectError.cause).toBeInstanceOf(AggregateError);
|
|
510
|
+
if (!(disconnectError.cause instanceof AggregateError)) throw new Error("Expected combined cleanup causes");
|
|
511
|
+
expect(disconnectError.cause.errors).toEqual([primaryFailure, retryFailure]);
|
|
512
|
+
expect(cleanupAttempts).toBe(1);
|
|
513
|
+
expect(manager.pendingConnectionCleanupCountForTests).toBe(1);
|
|
514
|
+
expect(pool.pendingAcquireCleanupCountForTests).toBe(1);
|
|
515
|
+
|
|
516
|
+
await manager.disconnectServer("server");
|
|
517
|
+
expect(cleanupAttempts).toBe(2);
|
|
518
|
+
expect(manager.pendingConnectionCleanupCountForTests).toBe(0);
|
|
519
|
+
expect(pool.pendingAcquireCleanupCountForTests).toBe(0);
|
|
520
|
+
await manager.withPreparedLease("server", serverConfig, async lease => {
|
|
521
|
+
expect(lease.serverName).toBe("server");
|
|
522
|
+
});
|
|
523
|
+
expect(opens).toBe(2);
|
|
524
|
+
await manager.disconnectAll();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("manager reconnect retries pool-owned cleanup before replacing the live connection", async () => {
|
|
528
|
+
const transientFlight = Promise.withResolvers<MCPServerConnection>();
|
|
529
|
+
const transientStarted = Promise.withResolvers<void>();
|
|
530
|
+
const cleanupTransport = new FakeTransport();
|
|
531
|
+
const cleanupRetryFailure = new Error("reconnect cleanup retry failed");
|
|
532
|
+
let cleanupAttempts = 0;
|
|
533
|
+
cleanupTransport.close = async () => {
|
|
534
|
+
cleanupAttempts += 1;
|
|
535
|
+
if (cleanupAttempts === 1) throw cleanupRetryFailure;
|
|
536
|
+
cleanupTransport.connected = false;
|
|
537
|
+
};
|
|
538
|
+
let opens = 0;
|
|
539
|
+
const transports: ToolListTransport[] = [];
|
|
540
|
+
const pool = new MCPConnectionPool({
|
|
541
|
+
connect: async (name, cfg) => {
|
|
542
|
+
opens += 1;
|
|
543
|
+
if (opens === 2) {
|
|
544
|
+
transientStarted.resolve();
|
|
545
|
+
return transientFlight.promise;
|
|
546
|
+
}
|
|
547
|
+
const transport = new ToolListTransport();
|
|
548
|
+
transports.push(transport);
|
|
549
|
+
return connection(name, cfg, transport);
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
const manager = new MCPManager(".", null, { pool, sessionId: "reconnect-cleanup", sleep: async () => {} });
|
|
553
|
+
const serverConfig = config();
|
|
554
|
+
await manager.connectServers({ server: serverConfig }, {});
|
|
555
|
+
const original = manager.getConnection("server");
|
|
556
|
+
const controller = new AbortController();
|
|
557
|
+
const transient = manager.withPreparedLease(
|
|
558
|
+
"server",
|
|
559
|
+
{ type: "stdio", command: "fake-mcp", args: ["--transient"] },
|
|
560
|
+
async () => undefined,
|
|
561
|
+
{ signal: controller.signal },
|
|
562
|
+
);
|
|
563
|
+
await transientStarted.promise;
|
|
564
|
+
controller.abort(new Error("abort transient before reconnect"));
|
|
565
|
+
await expect(transient).rejects.toBeInstanceOf(MCPPoolAcquireAbortError);
|
|
566
|
+
|
|
567
|
+
const firstReconnect = manager.reconnectServer("server");
|
|
568
|
+
transientFlight.reject(
|
|
569
|
+
new MCPConnectionCleanupFailure(new Error("late reconnect cleanup failed"), cleanupTransport),
|
|
570
|
+
);
|
|
571
|
+
await expect(firstReconnect).rejects.toBeInstanceOf(MCPConnectionCleanupFailure);
|
|
572
|
+
expect(cleanupAttempts).toBe(1);
|
|
573
|
+
expect(opens).toBe(2);
|
|
574
|
+
expect(manager.getConnection("server")).toBe(original);
|
|
575
|
+
const replacement = await manager.reconnectServer("server");
|
|
576
|
+
expect(cleanupAttempts).toBe(2);
|
|
577
|
+
expect(opens).toBe(3);
|
|
578
|
+
expect(replacement).toBeDefined();
|
|
579
|
+
expect(replacement).not.toBe(original);
|
|
580
|
+
expect(manager.pendingConnectionCleanupCountForTests).toBe(0);
|
|
581
|
+
await manager.disconnectAll();
|
|
582
|
+
expect(transports.every(transport => transport.closeCount === 1)).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
|
|
376
585
|
test("shutdown aborts hanging opens, settles waiters, and closes late transports", async () => {
|
|
377
586
|
let resolveOpen: ((value: MCPServerConnection) => void) | undefined;
|
|
378
587
|
let openSignal: AbortSignal | undefined;
|