agent-trellis 0.18.0 → 0.20.0
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/cli.js +12 -1
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +87 -2
- package/dist/commands/evolve.d.ts +43 -0
- package/dist/commands/evolve.js +479 -0
- package/dist/commands/mcpGateway.d.ts +28 -0
- package/dist/commands/mcpGateway.js +40 -6
- package/dist/evolve/agentView.d.ts +32 -0
- package/dist/evolve/agentView.js +34 -0
- package/dist/evolve/apply.d.ts +71 -0
- package/dist/evolve/apply.js +162 -0
- package/dist/evolve/archive.d.ts +23 -0
- package/dist/evolve/archive.js +58 -0
- package/dist/evolve/erase.d.ts +17 -0
- package/dist/evolve/erase.js +65 -0
- package/dist/evolve/extension.d.ts +37 -0
- package/dist/evolve/extension.js +63 -0
- package/dist/evolve/gate.d.ts +95 -0
- package/dist/evolve/gate.js +295 -0
- package/dist/evolve/importHistory.d.ts +91 -0
- package/dist/evolve/importHistory.js +239 -0
- package/dist/evolve/jobMain.d.ts +22 -0
- package/dist/evolve/jobMain.js +52 -0
- package/dist/evolve/jobRun.d.ts +94 -0
- package/dist/evolve/jobRun.js +273 -0
- package/dist/evolve/jobShell.d.ts +46 -0
- package/dist/evolve/jobShell.js +75 -0
- package/dist/evolve/l2.d.ts +93 -0
- package/dist/evolve/l2.js +81 -0
- package/dist/evolve/lesson.d.ts +57 -0
- package/dist/evolve/lesson.js +97 -0
- package/dist/evolve/pending.d.ts +69 -0
- package/dist/evolve/pending.js +116 -0
- package/dist/evolve/probeWorkspace.d.ts +57 -0
- package/dist/evolve/probeWorkspace.js +69 -0
- package/dist/evolve/propose.d.ts +73 -0
- package/dist/evolve/propose.js +136 -0
- package/dist/evolve/recorder.d.ts +68 -0
- package/dist/evolve/recorder.js +169 -0
- package/dist/evolve/report.d.ts +26 -0
- package/dist/evolve/report.js +37 -0
- package/dist/evolve/reportView.d.ts +96 -0
- package/dist/evolve/reportView.js +311 -0
- package/dist/evolve/risk.d.ts +106 -0
- package/dist/evolve/risk.js +224 -0
- package/dist/evolve/sources.d.ts +19 -0
- package/dist/evolve/sources.js +29 -0
- package/dist/evolve/stateDir.d.ts +21 -0
- package/dist/evolve/stateDir.js +56 -0
- package/dist/evolve/surface.d.ts +34 -0
- package/dist/evolve/surface.js +72 -0
- package/dist/evolve/trajectory.d.ts +25 -0
- package/dist/evolve/trajectory.js +88 -0
- package/dist/evolve/treeToCanonical.d.ts +28 -0
- package/dist/evolve/treeToCanonical.js +79 -0
- package/dist/evolve/trellisSubstrate.d.ts +65 -0
- package/dist/evolve/trellisSubstrate.js +154 -0
- package/dist/evolve/trigger.d.ts +177 -0
- package/dist/evolve/trigger.js +400 -0
- package/dist/evolve/types.d.ts +126 -0
- package/dist/evolve/types.js +15 -0
- package/dist/evolve/validate.d.ts +139 -0
- package/dist/evolve/validate.js +315 -0
- package/dist/host/checks.d.ts +57 -0
- package/dist/host/checks.js +126 -0
- package/dist/host/delivery.d.ts +42 -0
- package/dist/host/delivery.js +59 -0
- package/dist/host/edits.d.ts +87 -0
- package/dist/host/edits.js +102 -0
- package/dist/host/hostEvents.d.ts +74 -0
- package/dist/host/hostEvents.js +45 -0
- package/dist/host/index.d.ts +22 -0
- package/dist/host/index.js +17 -0
- package/dist/host/levels.d.ts +106 -0
- package/dist/host/levels.js +206 -0
- package/dist/host/staging.d.ts +56 -0
- package/dist/host/staging.js +106 -0
- package/dist/host/surface.d.ts +36 -0
- package/dist/host/surface.js +20 -0
- package/dist/lib/backup.js +12 -3
- package/dist/lib/fsAtomic.d.ts +7 -0
- package/dist/lib/fsAtomic.js +14 -0
- package/dist/lib/gatewayBackend.d.ts +72 -2
- package/dist/lib/gatewayBackend.js +254 -13
- package/dist/lib/hostEventBus.d.ts +47 -0
- package/dist/lib/hostEventBus.js +94 -0
- package/dist/lib/hostExtensions.d.ts +75 -0
- package/dist/lib/hostExtensions.js +196 -0
- package/dist/lib/mcpRuntime.d.ts +12 -1
- package/dist/lib/mcpRuntime.js +34 -2
- package/dist/lib/mcpToolRegistry.d.ts +28 -2
- package/dist/lib/mcpToolRegistry.js +47 -8
- package/dist/lib/upstreamResources.d.ts +5 -0
- package/dist/lib/upstreamResources.js +10 -0
- package/dist/pi-bridge/bundle.js +33 -5
- package/docs/magpie-gateway-research.md +422 -0
- package/docs/roadmap.md +22 -12
- package/docs/runtime-infra.md +74 -0
- package/docs/self-evolution.md +138 -44
- package/package.json +7 -2
|
@@ -41,7 +41,11 @@ export interface UpstreamSpec {
|
|
|
41
41
|
name: string;
|
|
42
42
|
def: McpServerDef;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/** `reconnecting` is the transient state between a detected session
|
|
45
|
+
* invalidation and the recovery outcome (spec: an upstream whose MCP
|
|
46
|
+
* session is invalidated mid-session is recovered alone, with a status
|
|
47
|
+
* that never claims ready in the meantime). */
|
|
48
|
+
export type UpstreamStatusKind = "ready" | "auth-required" | "unavailable" | "timeout" | "degraded" | "reconnecting";
|
|
45
49
|
export interface UpstreamStatus {
|
|
46
50
|
name: string;
|
|
47
51
|
transport: "stdio" | "http" | "sse";
|
|
@@ -75,6 +79,10 @@ export interface LocalBackendOptions {
|
|
|
75
79
|
* no terminal attached (design.md D9).
|
|
76
80
|
*/
|
|
77
81
|
homeDir?: string;
|
|
82
|
+
/** How long a server whose mid-session recovery failed refuses automatic
|
|
83
|
+
* re-attempts, so a down upstream cannot trigger a reconnect storm. Test
|
|
84
|
+
* seam; defaults to `SESSION_RECOVERY_COOLDOWN_MS`. */
|
|
85
|
+
sessionRecoveryCooldownMs?: number;
|
|
78
86
|
}
|
|
79
87
|
/**
|
|
80
88
|
* v1's backend: connects each upstream itself and aggregates the result.
|
|
@@ -87,9 +95,26 @@ export interface LocalBackendOptions {
|
|
|
87
95
|
*/
|
|
88
96
|
export declare class LocalBackend implements GatewayBackend {
|
|
89
97
|
private readonly warn;
|
|
98
|
+
private readonly sessionCooldownMs;
|
|
90
99
|
private readonly registry;
|
|
100
|
+
/** server name → the client currently serving that upstream. Keyed by
|
|
101
|
+
* name (not a Set of clients) because recovery swaps the client under a
|
|
102
|
+
* stable name and callTool must ask "is this still the client I called?"
|
|
103
|
+
* by comparing against the current registration. */
|
|
91
104
|
private readonly connected;
|
|
92
105
|
private readonly statuses;
|
|
106
|
+
/** Saved at connect time so a mid-session recovery can rebuild the
|
|
107
|
+
* upstream through `addUpstream` itself — the recovery path IS the
|
|
108
|
+
* startup path, and inherits its OAuth resolution and special cases
|
|
109
|
+
* (mcp-router) wholesale instead of growing a parallel one. */
|
|
110
|
+
private readonly upstreamConfigs;
|
|
111
|
+
/** server name → in-flight session recovery; one recovery per server at
|
|
112
|
+
* a time, every racing caller awaits the same promise. */
|
|
113
|
+
private readonly recoveries;
|
|
114
|
+
/** server name → cooldown expiry; a recently failed recovery is not
|
|
115
|
+
* re-attempted until it lapses, so a down upstream sees one reconnect
|
|
116
|
+
* attempt per window, not a storm. */
|
|
117
|
+
private readonly sessionCooldowns;
|
|
93
118
|
private closed;
|
|
94
119
|
private readonly resources;
|
|
95
120
|
private constructor();
|
|
@@ -103,14 +128,59 @@ export declare class LocalBackend implements GatewayBackend {
|
|
|
103
128
|
getSkill(uri: string): Promise<SkillEntry | undefined>;
|
|
104
129
|
listStatus(): readonly UpstreamStatus[];
|
|
105
130
|
close(): Promise<void>;
|
|
106
|
-
private
|
|
131
|
+
private closeClient;
|
|
107
132
|
/** A dead grant surfaces mid-session: this upstream's calls now fail,
|
|
108
133
|
* but its tools stay registered and every other upstream keeps serving.
|
|
109
134
|
* The credential resolves per request, so the human's re-authorization
|
|
110
135
|
* takes effect on the next call — no session restart required. */
|
|
111
136
|
private markCredentialFailure;
|
|
112
137
|
private restoreIfAuthRequired;
|
|
138
|
+
/** The pre-call hard failure for a server inside its recovery cooldown. */
|
|
139
|
+
private sessionCooldownBlock;
|
|
140
|
+
private enterSessionCooldown;
|
|
141
|
+
/** Join the server's in-flight recovery, or start exactly one. */
|
|
142
|
+
private ensureSessionRecovered;
|
|
143
|
+
/** Drop and rebuild one upstream through the exact startup path
|
|
144
|
+
* (`addUpstream`), keeping the old client connected until the fresh
|
|
145
|
+
* registration serves — so a failed recovery still surfaces the old
|
|
146
|
+
* session errors that re-trigger recovery after the cooldown, and the
|
|
147
|
+
* replaced connection is never closed before its successor exists. */
|
|
148
|
+
private reconnectUpstream;
|
|
149
|
+
/** The retried request failed on the fresh session too: an honest
|
|
150
|
+
* terminal state plus a cooldown — never a stale ready (D4). */
|
|
151
|
+
private markRetrySessionFailure;
|
|
152
|
+
/** Upstream-originated failures name their server (spec: call errors
|
|
153
|
+
* name the failing upstream server). */
|
|
154
|
+
private upstreamCallError;
|
|
113
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* How long a server whose session recovery failed refuses automatic
|
|
158
|
+
* re-attempts. Fixed by design: a reconnect storm against a down upstream
|
|
159
|
+
* buys nothing; the honest failure state plus this window is the v1
|
|
160
|
+
* answer, to be revisited with real-world data.
|
|
161
|
+
*/
|
|
162
|
+
export declare const SESSION_RECOVERY_COOLDOWN_MS = 30000;
|
|
163
|
+
/**
|
|
164
|
+
* Whether an upstream error means "your MCP session no longer exists".
|
|
165
|
+
*
|
|
166
|
+
* The official TypeScript server SDK rejects an unknown session with HTTP
|
|
167
|
+
* 404 carrying the JSON-RPC error "Session not found", and pairs 401/403
|
|
168
|
+
* with -32000 for authentication rejections — so 401/403 gate the match
|
|
169
|
+
* out first, and an auth rejection can never enter recovery. The body
|
|
170
|
+
* reaches us inside the client SDK's `Error POSTing to endpoint: …`
|
|
171
|
+
* wrapper, so a word match covers custom upstream wordings (the real-world
|
|
172
|
+
* one says "MCP session no longer exists"). Real-machine verification
|
|
173
|
+
* (2026-10-23) showed custom upstreams also answer with other non-auth
|
|
174
|
+
* statuses — a 400 carrying that same -32000 body took the plain
|
|
175
|
+
* `SdkHttpError` path, not the enveloped `ProtocolError` one — hence the
|
|
176
|
+
* status gate is "not an auth rejection" rather than "exactly 404". A
|
|
177
|
+
* stray non-auth response that merely mentions "session" costs one
|
|
178
|
+
* idempotent-safe reconnect+retry, an acceptable price for covering every
|
|
179
|
+
* upstream wording. The `ProtocolError` branch covers the HTTP-200
|
|
180
|
+
* JSON-RPC error case, where the client SDK parses the envelope and
|
|
181
|
+
* rejects with the upstream's error code instead.
|
|
182
|
+
*/
|
|
183
|
+
export declare function isSessionInvalidation(err: unknown): boolean;
|
|
114
184
|
/**
|
|
115
185
|
* Whether the gateway manages the OAuth credential for this definition:
|
|
116
186
|
* Trellis-owned OAuth, and no explicit `Authorization` header — someone
|
|
@@ -21,6 +21,7 @@ import { withTimeout } from "./mcpConnect.js";
|
|
|
21
21
|
import { ensureFreshToken, forceRefresh, RefreshError } from "./oauth/refresh.js";
|
|
22
22
|
import { readToken } from "./oauth/store.js";
|
|
23
23
|
import { McpToolRegistry } from "./mcpToolRegistry.js";
|
|
24
|
+
import { ProtocolError, SdkHttpError } from "@modelcontextprotocol/client";
|
|
24
25
|
import { UpstreamResources } from "./upstreamResources.js";
|
|
25
26
|
/**
|
|
26
27
|
* v1's backend: connects each upstream itself and aggregates the result.
|
|
@@ -33,24 +34,47 @@ import { UpstreamResources } from "./upstreamResources.js";
|
|
|
33
34
|
*/
|
|
34
35
|
export class LocalBackend {
|
|
35
36
|
warn;
|
|
37
|
+
sessionCooldownMs;
|
|
36
38
|
registry = new McpToolRegistry();
|
|
37
|
-
|
|
39
|
+
/** server name → the client currently serving that upstream. Keyed by
|
|
40
|
+
* name (not a Set of clients) because recovery swaps the client under a
|
|
41
|
+
* stable name and callTool must ask "is this still the client I called?"
|
|
42
|
+
* by comparing against the current registration. */
|
|
43
|
+
connected = new Map();
|
|
38
44
|
statuses = new Map();
|
|
45
|
+
/** Saved at connect time so a mid-session recovery can rebuild the
|
|
46
|
+
* upstream through `addUpstream` itself — the recovery path IS the
|
|
47
|
+
* startup path, and inherits its OAuth resolution and special cases
|
|
48
|
+
* (mcp-router) wholesale instead of growing a parallel one. */
|
|
49
|
+
upstreamConfigs = new Map();
|
|
50
|
+
/** server name → in-flight session recovery; one recovery per server at
|
|
51
|
+
* a time, every racing caller awaits the same promise. */
|
|
52
|
+
recoveries = new Map();
|
|
53
|
+
/** server name → cooldown expiry; a recently failed recovery is not
|
|
54
|
+
* re-attempted until it lapses, so a down upstream sees one reconnect
|
|
55
|
+
* attempt per window, not a storm. */
|
|
56
|
+
sessionCooldowns = new Map();
|
|
39
57
|
closed = false;
|
|
40
58
|
resources;
|
|
41
|
-
constructor(warn, timeoutMs) {
|
|
59
|
+
constructor(warn, timeoutMs, sessionCooldownMs) {
|
|
42
60
|
this.warn = warn;
|
|
61
|
+
this.sessionCooldownMs = sessionCooldownMs;
|
|
43
62
|
this.resources = new UpstreamResources(timeoutMs, warn);
|
|
44
63
|
}
|
|
45
64
|
static async connect(upstreams, options) {
|
|
46
65
|
const warn = options.onWarning ?? ((message) => console.error(message));
|
|
47
|
-
const backend = new LocalBackend(warn, options.connectTimeoutMs);
|
|
66
|
+
const backend = new LocalBackend(warn, options.connectTimeoutMs, options.sessionRecoveryCooldownMs ?? SESSION_RECOVERY_COOLDOWN_MS);
|
|
48
67
|
await Promise.all(upstreams.map((upstream) => backend.addUpstream(upstream, options)));
|
|
49
68
|
return backend;
|
|
50
69
|
}
|
|
51
|
-
async addUpstream({ name, def }, options) {
|
|
70
|
+
async addUpstream({ name, def }, options, replaceOf) {
|
|
71
|
+
this.upstreamConfigs.set(name, { spec: { name, def }, options });
|
|
52
72
|
const transport = def.transport === "http" || def.transport === "sse" ? def.transport : "stdio";
|
|
53
|
-
|
|
73
|
+
// A recovery set `reconnecting` before calling us; a first connect
|
|
74
|
+
// reports "connecting". The recovery's status stays until the outcome.
|
|
75
|
+
if (replaceOf === undefined) {
|
|
76
|
+
this.statuses.set(name, { name, transport, status: "unavailable", detail: "connecting" });
|
|
77
|
+
}
|
|
54
78
|
let effectiveDef = def;
|
|
55
79
|
if (options.homeDir && def.url) {
|
|
56
80
|
try {
|
|
@@ -70,6 +94,10 @@ export class LocalBackend {
|
|
|
70
94
|
// process, or written to disk by `trellis mcp auth` in another
|
|
71
95
|
// terminal — affects the very next request, no restart. The static
|
|
72
96
|
// header from withOAuthHeader stays as the connect-time fallback.
|
|
97
|
+
// (A mid-session recovery rebuilds this provider, so its `lastIssued`
|
|
98
|
+
// starts empty again: the first post-recovery 401 force-refreshes with
|
|
99
|
+
// no previous-token guard — at most one extra rotation, and safe,
|
|
100
|
+
// because forceRefresh re-reads the token file under its lock.)
|
|
73
101
|
const authProvider = options.homeDir && (transport === "http" || transport === "sse") && oauthAttachApplies(def)
|
|
74
102
|
? trellisAuthProvider({
|
|
75
103
|
homeDir: options.homeDir,
|
|
@@ -84,10 +112,19 @@ export class LocalBackend {
|
|
|
84
112
|
}
|
|
85
113
|
catch (err) {
|
|
86
114
|
this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
|
|
115
|
+
// A failed recovery keeps the replaced client: its dead session is
|
|
116
|
+
// what keeps surfacing the error that re-triggers recovery once the
|
|
117
|
+
// cooldown expires — close-after-success, never orphaned mid-failure.
|
|
118
|
+
if (replaceOf !== undefined)
|
|
119
|
+
this.connected.set(name, replaceOf);
|
|
87
120
|
this.statuses.set(name, failureStatus(name, def, err));
|
|
88
121
|
return;
|
|
89
122
|
}
|
|
90
|
-
this.connected.
|
|
123
|
+
this.connected.set(name, client);
|
|
124
|
+
// Routes (and any skill registrations) a previous incarnation of this
|
|
125
|
+
// source left behind would route reads at the replaced client — forget
|
|
126
|
+
// the source entirely; the fresh registration re-adds its routes below.
|
|
127
|
+
this.resources.removeSource(name);
|
|
91
128
|
let tools;
|
|
92
129
|
try {
|
|
93
130
|
const result = client.getServerCapabilities()?.tools
|
|
@@ -97,11 +134,19 @@ export class LocalBackend {
|
|
|
97
134
|
}
|
|
98
135
|
catch (err) {
|
|
99
136
|
this.warn(`trellis-mcp-gateway: failed to list tools for MCP server "${name}": ${errorText(err)}`);
|
|
100
|
-
|
|
137
|
+
this.connected.delete(name);
|
|
138
|
+
if (replaceOf !== undefined)
|
|
139
|
+
this.connected.set(name, replaceOf);
|
|
140
|
+
await this.closeClient(client);
|
|
101
141
|
this.statuses.set(name, failureStatus(name, def, err));
|
|
102
142
|
return;
|
|
103
143
|
}
|
|
104
|
-
|
|
144
|
+
// A recovery swaps this server's whole registration: its previous
|
|
145
|
+
// candidates are dropped first, so a changed upstream tool set is
|
|
146
|
+
// reflected exactly rather than accumulated.
|
|
147
|
+
const { skipped } = replaceOf !== undefined
|
|
148
|
+
? this.registry.replace(name, client, tools)
|
|
149
|
+
: this.registry.add(name, client, tools);
|
|
105
150
|
this.resources.add(name, client);
|
|
106
151
|
for (const collided of skipped) {
|
|
107
152
|
this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
|
|
@@ -122,12 +167,63 @@ export class LocalBackend {
|
|
|
122
167
|
else {
|
|
123
168
|
this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
|
|
124
169
|
}
|
|
170
|
+
// The fresh registration serves; only now is the replaced client
|
|
171
|
+
// retired. Callers mid-flight on it finish against it — their stale
|
|
172
|
+
// session errors are guarded in callTool and never start a second
|
|
173
|
+
// recovery — and a failed recovery left the old client connected.
|
|
174
|
+
if (replaceOf !== undefined)
|
|
175
|
+
await this.closeClient(replaceOf);
|
|
125
176
|
}
|
|
126
177
|
async listTools() {
|
|
127
178
|
return this.registry.listTools();
|
|
128
179
|
}
|
|
129
180
|
async callTool(name, args) {
|
|
130
|
-
|
|
181
|
+
let target = this.registry.resolve(name);
|
|
182
|
+
if (!target)
|
|
183
|
+
throw new Error(`unknown tool "${name}"`);
|
|
184
|
+
// A server inside its recovery cooldown fails hard and immediately:
|
|
185
|
+
// its automatic recovery is known to be failing, and a reconnect
|
|
186
|
+
// storm against a down upstream buys nothing (D3).
|
|
187
|
+
const blocked = this.sessionCooldownBlock(target.serverName);
|
|
188
|
+
if (blocked)
|
|
189
|
+
throw blocked;
|
|
190
|
+
// A recovery already in flight — started by a concurrent caller.
|
|
191
|
+
// Wait it out and aim at whatever registration it lands.
|
|
192
|
+
const pending = this.recoveries.get(target.serverName);
|
|
193
|
+
if (pending) {
|
|
194
|
+
await pending;
|
|
195
|
+
target = this.registry.resolve(name);
|
|
196
|
+
if (!target)
|
|
197
|
+
throw new Error(`unknown tool "${name}"`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await target.upstream.callTool({ name: target.toolName, arguments: args });
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const { serverName } = target;
|
|
204
|
+
if (this.closed || !isSessionInvalidation(err))
|
|
205
|
+
throw this.upstreamCallError(serverName, err);
|
|
206
|
+
// A completed recovery already replaced the client this error came
|
|
207
|
+
// from (a racing caller's reconnect won): the stale error must not
|
|
208
|
+
// start a second recovery — just aim at the fresh registration.
|
|
209
|
+
const current = this.connected.get(serverName);
|
|
210
|
+
if (current === undefined || current === target.upstream) {
|
|
211
|
+
await this.ensureSessionRecovered(serverName, err);
|
|
212
|
+
}
|
|
213
|
+
const retried = this.registry.resolve(name);
|
|
214
|
+
if (!retried)
|
|
215
|
+
throw new Error(`unknown tool "${name}"`);
|
|
216
|
+
try {
|
|
217
|
+
return await retried.upstream.callTool({ name: retried.toolName, arguments: args });
|
|
218
|
+
}
|
|
219
|
+
catch (retryErr) {
|
|
220
|
+
// The fresh session failed the same way: an honest terminal state
|
|
221
|
+
// plus a cooldown — never a stale ready (D4).
|
|
222
|
+
if (isSessionInvalidation(retryErr))
|
|
223
|
+
this.markRetrySessionFailure(retried.serverName, retryErr);
|
|
224
|
+
throw this.upstreamCallError(retried.serverName, retryErr);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
131
227
|
}
|
|
132
228
|
listResources() { return this.resources.listResources(); }
|
|
133
229
|
readResource(uri) { return this.resources.readResource(uri); }
|
|
@@ -140,12 +236,11 @@ export class LocalBackend {
|
|
|
140
236
|
if (this.closed)
|
|
141
237
|
return;
|
|
142
238
|
this.closed = true;
|
|
143
|
-
const pending = [...this.connected];
|
|
239
|
+
const pending = [...this.connected.values()];
|
|
144
240
|
this.connected.clear();
|
|
145
|
-
await Promise.all(pending.map((client) => this.
|
|
241
|
+
await Promise.all(pending.map((client) => this.closeClient(client)));
|
|
146
242
|
}
|
|
147
|
-
async
|
|
148
|
-
this.connected.delete(client);
|
|
243
|
+
async closeClient(client) {
|
|
149
244
|
try {
|
|
150
245
|
await client.close();
|
|
151
246
|
}
|
|
@@ -187,6 +282,152 @@ export class LocalBackend {
|
|
|
187
282
|
requiresSessionRestart: undefined,
|
|
188
283
|
});
|
|
189
284
|
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Mid-session recovery (spec: an upstream whose MCP session is invalidated
|
|
287
|
+
// is recovered alone, in flight — restart is the remediation of last
|
|
288
|
+
// resort, not the default)
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
/** The pre-call hard failure for a server inside its recovery cooldown. */
|
|
291
|
+
sessionCooldownBlock(serverName) {
|
|
292
|
+
const until = this.sessionCooldowns.get(serverName);
|
|
293
|
+
if (until === undefined || Date.now() >= until)
|
|
294
|
+
return undefined;
|
|
295
|
+
return new Error(sessionRecoveryHint(serverName, "a recovery attempt failed recently and its cooldown has not expired"));
|
|
296
|
+
}
|
|
297
|
+
enterSessionCooldown(serverName) {
|
|
298
|
+
this.sessionCooldowns.set(serverName, Date.now() + this.sessionCooldownMs);
|
|
299
|
+
}
|
|
300
|
+
/** Join the server's in-flight recovery, or start exactly one. */
|
|
301
|
+
ensureSessionRecovered(serverName, trigger) {
|
|
302
|
+
const blocked = this.sessionCooldownBlock(serverName);
|
|
303
|
+
if (blocked)
|
|
304
|
+
return Promise.reject(blocked);
|
|
305
|
+
const existing = this.recoveries.get(serverName);
|
|
306
|
+
if (existing)
|
|
307
|
+
return existing;
|
|
308
|
+
const pending = this.reconnectUpstream(serverName, trigger);
|
|
309
|
+
this.recoveries.set(serverName, pending);
|
|
310
|
+
const cleanup = () => {
|
|
311
|
+
if (this.recoveries.get(serverName) === pending)
|
|
312
|
+
this.recoveries.delete(serverName);
|
|
313
|
+
};
|
|
314
|
+
pending.then(cleanup, cleanup);
|
|
315
|
+
return pending;
|
|
316
|
+
}
|
|
317
|
+
/** Drop and rebuild one upstream through the exact startup path
|
|
318
|
+
* (`addUpstream`), keeping the old client connected until the fresh
|
|
319
|
+
* registration serves — so a failed recovery still surfaces the old
|
|
320
|
+
* session errors that re-trigger recovery after the cooldown, and the
|
|
321
|
+
* replaced connection is never closed before its successor exists. */
|
|
322
|
+
async reconnectUpstream(serverName, trigger) {
|
|
323
|
+
const config = this.upstreamConfigs.get(serverName);
|
|
324
|
+
if (!config)
|
|
325
|
+
throw new Error(sessionRecoveryHint(serverName, "no saved connection config exists for it"));
|
|
326
|
+
// Preserve the tool count through reconnecting and failure: Agents
|
|
327
|
+
// reasoning about capability see the same number on both sides of a
|
|
328
|
+
// recovery, whatever its outcome (a success overwrites it with the
|
|
329
|
+
// fresh count inside addUpstream).
|
|
330
|
+
const toolCount = this.statuses.get(serverName)?.toolCount;
|
|
331
|
+
this.statuses.set(serverName, {
|
|
332
|
+
name: serverName,
|
|
333
|
+
transport: transportOf(config.spec.def),
|
|
334
|
+
status: "reconnecting",
|
|
335
|
+
toolCount,
|
|
336
|
+
detail: `MCP session invalidated; reconnecting (${errorText(trigger).slice(0, 160)})`,
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
await this.addUpstream(config.spec, config.options, this.connected.get(serverName));
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
// addUpstream isolates its own failures into statuses; reaching here
|
|
343
|
+
// is a bug guard, not the expected failure path.
|
|
344
|
+
this.statuses.set(serverName, { ...failureStatus(serverName, config.spec.def, err), toolCount, remediation: sessionRestartRemediation(serverName) });
|
|
345
|
+
this.enterSessionCooldown(serverName);
|
|
346
|
+
throw new Error(sessionRecoveryHint(serverName, errorText(err)));
|
|
347
|
+
}
|
|
348
|
+
const status = this.statuses.get(serverName);
|
|
349
|
+
if (status?.status === "ready" || status?.status === "degraded") {
|
|
350
|
+
this.sessionCooldowns.delete(serverName);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// A failed recovery already landed in its classified failure status;
|
|
354
|
+
// sharpen its remediation to the restart hint and open the cooldown.
|
|
355
|
+
this.statuses.set(serverName, { ...status, toolCount, remediation: sessionRestartRemediation(serverName) });
|
|
356
|
+
this.enterSessionCooldown(serverName);
|
|
357
|
+
throw new Error(sessionRecoveryHint(serverName, status?.detail ?? "the reconnect failed"));
|
|
358
|
+
}
|
|
359
|
+
/** The retried request failed on the fresh session too: an honest
|
|
360
|
+
* terminal state plus a cooldown — never a stale ready (D4). */
|
|
361
|
+
markRetrySessionFailure(serverName, err) {
|
|
362
|
+
const config = this.upstreamConfigs.get(serverName);
|
|
363
|
+
const previous = this.statuses.get(serverName);
|
|
364
|
+
const base = config
|
|
365
|
+
? failureStatus(serverName, config.spec.def, err)
|
|
366
|
+
: {
|
|
367
|
+
name: serverName,
|
|
368
|
+
transport: "http",
|
|
369
|
+
status: "unavailable",
|
|
370
|
+
detail: errorText(err).slice(0, 300),
|
|
371
|
+
requiresHuman: true,
|
|
372
|
+
requiresSessionRestart: true,
|
|
373
|
+
};
|
|
374
|
+
this.statuses.set(serverName, {
|
|
375
|
+
...base,
|
|
376
|
+
toolCount: previous?.toolCount,
|
|
377
|
+
remediation: sessionRestartRemediation(serverName),
|
|
378
|
+
});
|
|
379
|
+
this.enterSessionCooldown(serverName);
|
|
380
|
+
}
|
|
381
|
+
/** Upstream-originated failures name their server (spec: call errors
|
|
382
|
+
* name the failing upstream server). */
|
|
383
|
+
upstreamCallError(serverName, err) {
|
|
384
|
+
return new Error(`upstream "${serverName}" tool call failed: ${errorText(err)}`, err instanceof Error ? { cause: err } : undefined);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* How long a server whose session recovery failed refuses automatic
|
|
389
|
+
* re-attempts. Fixed by design: a reconnect storm against a down upstream
|
|
390
|
+
* buys nothing; the honest failure state plus this window is the v1
|
|
391
|
+
* answer, to be revisited with real-world data.
|
|
392
|
+
*/
|
|
393
|
+
export const SESSION_RECOVERY_COOLDOWN_MS = 30_000;
|
|
394
|
+
function transportOf(def) {
|
|
395
|
+
if (def.transport === "sse")
|
|
396
|
+
return "sse";
|
|
397
|
+
return def.url ? "http" : "stdio";
|
|
398
|
+
}
|
|
399
|
+
function sessionRestartRemediation(serverName) {
|
|
400
|
+
return `restart the Agent session to re-establish the MCP session for "${serverName}"`;
|
|
401
|
+
}
|
|
402
|
+
function sessionRecoveryHint(serverName, reason) {
|
|
403
|
+
return `upstream "${serverName}" MCP session invalidated and automatic recovery failed (${reason}) — ${sessionRestartRemediation(serverName)}`;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Whether an upstream error means "your MCP session no longer exists".
|
|
407
|
+
*
|
|
408
|
+
* The official TypeScript server SDK rejects an unknown session with HTTP
|
|
409
|
+
* 404 carrying the JSON-RPC error "Session not found", and pairs 401/403
|
|
410
|
+
* with -32000 for authentication rejections — so 401/403 gate the match
|
|
411
|
+
* out first, and an auth rejection can never enter recovery. The body
|
|
412
|
+
* reaches us inside the client SDK's `Error POSTing to endpoint: …`
|
|
413
|
+
* wrapper, so a word match covers custom upstream wordings (the real-world
|
|
414
|
+
* one says "MCP session no longer exists"). Real-machine verification
|
|
415
|
+
* (2026-10-23) showed custom upstreams also answer with other non-auth
|
|
416
|
+
* statuses — a 400 carrying that same -32000 body took the plain
|
|
417
|
+
* `SdkHttpError` path, not the enveloped `ProtocolError` one — hence the
|
|
418
|
+
* status gate is "not an auth rejection" rather than "exactly 404". A
|
|
419
|
+
* stray non-auth response that merely mentions "session" costs one
|
|
420
|
+
* idempotent-safe reconnect+retry, an acceptable price for covering every
|
|
421
|
+
* upstream wording. The `ProtocolError` branch covers the HTTP-200
|
|
422
|
+
* JSON-RPC error case, where the client SDK parses the envelope and
|
|
423
|
+
* rejects with the upstream's error code instead.
|
|
424
|
+
*/
|
|
425
|
+
export function isSessionInvalidation(err) {
|
|
426
|
+
if (err instanceof SdkHttpError)
|
|
427
|
+
return err.status !== 401 && err.status !== 403 && /session/i.test(err.message);
|
|
428
|
+
if (err instanceof ProtocolError)
|
|
429
|
+
return err.code === -32000 && /session/i.test(err.message);
|
|
430
|
+
return false;
|
|
190
431
|
}
|
|
191
432
|
function failureStatus(name, def, error) {
|
|
192
433
|
const detail = errorText(error).slice(0, 300);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process host event bus (host-extension-points spec).
|
|
3
|
+
*
|
|
4
|
+
* Dispatch is synchronous and ordered: `emit` walks listeners at the event
|
|
5
|
+
* point so a session-end event is observably delivered before upstream
|
|
6
|
+
* release continues. Failures are contained per listener — a synchronous
|
|
7
|
+
* throw or a rejected promise is swallowed and warned, never propagated to
|
|
8
|
+
* the tool call or to sibling listeners. With no subscribers `emit` is a
|
|
9
|
+
* single length check (zero overhead; runtime behavior stays byte-identical
|
|
10
|
+
* to the pre-event world). Listeners are expected to return promptly; slow
|
|
11
|
+
* work belongs behind the returned promise, which the caller never awaits.
|
|
12
|
+
*/
|
|
13
|
+
import { type HostEvent, type ToolCallInfo } from "../host/hostEvents.js";
|
|
14
|
+
export type HostEventListener = (event: HostEvent) => void | Promise<void>;
|
|
15
|
+
export declare class HostEventBus {
|
|
16
|
+
private readonly warn;
|
|
17
|
+
private readonly listeners;
|
|
18
|
+
constructor(warn?: (message: string) => void);
|
|
19
|
+
/** Register a listener; returns its unsubscribe function. Registration
|
|
20
|
+
* failure isolation for extensions is the loader's job (8.6) — this method
|
|
21
|
+
* only ever pushes. */
|
|
22
|
+
subscribe(listener: HostEventListener): () => void;
|
|
23
|
+
emit(event: HostEvent): void;
|
|
24
|
+
get subscriberCount(): number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Stamps events with process-level identity. One instance per gateway
|
|
28
|
+
* process: `sessionId` is generated here and carried unchanged on every
|
|
29
|
+
* event the process emits ("会话开始"/tool calls/"会话结束").
|
|
30
|
+
*/
|
|
31
|
+
export declare class HostEventEmitter {
|
|
32
|
+
private readonly opts;
|
|
33
|
+
readonly sessionId: string;
|
|
34
|
+
constructor(opts: {
|
|
35
|
+
bus: HostEventBus;
|
|
36
|
+
agent: string;
|
|
37
|
+
/** Workspace root observed by the process; null = no workspace
|
|
38
|
+
* attribution (consumers derive layer from this). */
|
|
39
|
+
workspaceRoot: string | null;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
});
|
|
43
|
+
private ts;
|
|
44
|
+
sessionStart(): void;
|
|
45
|
+
toolCall(info: ToolCallInfo): void;
|
|
46
|
+
sessionEnd(): void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process host event bus (host-extension-points spec).
|
|
3
|
+
*
|
|
4
|
+
* Dispatch is synchronous and ordered: `emit` walks listeners at the event
|
|
5
|
+
* point so a session-end event is observably delivered before upstream
|
|
6
|
+
* release continues. Failures are contained per listener — a synchronous
|
|
7
|
+
* throw or a rejected promise is swallowed and warned, never propagated to
|
|
8
|
+
* the tool call or to sibling listeners. With no subscribers `emit` is a
|
|
9
|
+
* single length check (zero overhead; runtime behavior stays byte-identical
|
|
10
|
+
* to the pre-event world). Listeners are expected to return promptly; slow
|
|
11
|
+
* work belongs behind the returned promise, which the caller never awaits.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { sessionEndEvent, sessionStartEvent, toolCallEvent, } from "../host/hostEvents.js";
|
|
15
|
+
export class HostEventBus {
|
|
16
|
+
warn;
|
|
17
|
+
listeners = [];
|
|
18
|
+
constructor(warn = console.error) {
|
|
19
|
+
this.warn = warn;
|
|
20
|
+
}
|
|
21
|
+
/** Register a listener; returns its unsubscribe function. Registration
|
|
22
|
+
* failure isolation for extensions is the loader's job (8.6) — this method
|
|
23
|
+
* only ever pushes. */
|
|
24
|
+
subscribe(listener) {
|
|
25
|
+
this.listeners.push(listener);
|
|
26
|
+
return () => {
|
|
27
|
+
const index = this.listeners.indexOf(listener);
|
|
28
|
+
if (index >= 0)
|
|
29
|
+
this.listeners.splice(index, 1);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
emit(event) {
|
|
33
|
+
if (this.listeners.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
for (const listener of this.listeners) {
|
|
36
|
+
try {
|
|
37
|
+
const pending = listener(event);
|
|
38
|
+
if (pending && typeof pending.then === "function") {
|
|
39
|
+
void pending.catch((err) => {
|
|
40
|
+
this.warn(`trellis-host-events: subscriber failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
this.warn(`trellis-host-events: subscriber failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
get subscriberCount() {
|
|
50
|
+
return this.listeners.length;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Stamps events with process-level identity. One instance per gateway
|
|
55
|
+
* process: `sessionId` is generated here and carried unchanged on every
|
|
56
|
+
* event the process emits ("会话开始"/tool calls/"会话结束").
|
|
57
|
+
*/
|
|
58
|
+
export class HostEventEmitter {
|
|
59
|
+
opts;
|
|
60
|
+
sessionId;
|
|
61
|
+
constructor(opts) {
|
|
62
|
+
this.opts = opts;
|
|
63
|
+
this.sessionId = opts.sessionId ?? randomUUID();
|
|
64
|
+
}
|
|
65
|
+
ts() {
|
|
66
|
+
return (this.opts.now ?? Date.now)();
|
|
67
|
+
}
|
|
68
|
+
sessionStart() {
|
|
69
|
+
this.opts.bus.emit(sessionStartEvent({
|
|
70
|
+
ts: this.ts(),
|
|
71
|
+
agent: this.opts.agent,
|
|
72
|
+
session: this.sessionId,
|
|
73
|
+
workspaceRoot: this.opts.workspaceRoot,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
toolCall(info) {
|
|
77
|
+
this.opts.bus.emit(toolCallEvent({
|
|
78
|
+
ts: this.ts(),
|
|
79
|
+
agent: this.opts.agent,
|
|
80
|
+
session: this.sessionId,
|
|
81
|
+
provider: info.provider,
|
|
82
|
+
tool: info.tool,
|
|
83
|
+
durationMs: info.durationMs,
|
|
84
|
+
result: info.result,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
sessionEnd() {
|
|
88
|
+
this.opts.bus.emit(sessionEndEvent({
|
|
89
|
+
ts: this.ts(),
|
|
90
|
+
agent: this.opts.agent,
|
|
91
|
+
session: this.sessionId,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External host extension loading (host-extension-points spec, 8.6).
|
|
3
|
+
*
|
|
4
|
+
* The ONLY way an external module becomes a Trellis provider or event
|
|
5
|
+
* subscriber: it is declared in the canonical extension manifest at
|
|
6
|
+
* `~/.trellis/mcp/extensions.yaml` (entries carry `id` + a `load` module
|
|
7
|
+
* specifier) and its module exports `activate(ctx)` returning
|
|
8
|
+
* `{ providers?, subscribers? }`. There is no directory scan and no
|
|
9
|
+
* naming convention — a module on disk that the manifest does not name
|
|
10
|
+
* is never imported.
|
|
11
|
+
*
|
|
12
|
+
* Fault isolation is per extension and total: a manifest that fails to
|
|
13
|
+
* parse, an entry of the wrong shape, an import that throws, an
|
|
14
|
+
* `activate` that throws, or an activation of the wrong shape are each
|
|
15
|
+
* recorded as a failure (and warned), and none of them affect the core
|
|
16
|
+
* providers, upstream servers, or any other extension. With no manifest
|
|
17
|
+
* the loader returns empty everything and the gateway is byte-identical
|
|
18
|
+
* to the pre-extension world.
|
|
19
|
+
*/
|
|
20
|
+
import type { AgentId } from "../core/types.js";
|
|
21
|
+
import type { HostEventListener } from "./hostEventBus.js";
|
|
22
|
+
import type { TrellisProvider } from "./mcpRuntime.js";
|
|
23
|
+
/** Manifest location relative to the home directory — canonical, not
|
|
24
|
+
* user-home-adjacent: extension declarations are canonical content. */
|
|
25
|
+
export declare const EXTENSIONS_MANIFEST_REL: string;
|
|
26
|
+
export declare function extensionsManifestPath(homeDir: string): string;
|
|
27
|
+
export interface HostExtensionEntry {
|
|
28
|
+
id: string;
|
|
29
|
+
/** Module specifier for dynamic import — an absolute path or a file:
|
|
30
|
+
* URL is the reliable form; bare specifiers resolve by Node's rules
|
|
31
|
+
* from the trellis package itself. */
|
|
32
|
+
load: string;
|
|
33
|
+
}
|
|
34
|
+
/** What an extension's `activate` receives. Metadata only — the extension
|
|
35
|
+
* never gets the bus or registry itself; it RETURNS providers and
|
|
36
|
+
* subscribers and the host wires them through the same registration
|
|
37
|
+
* surface as everything else. */
|
|
38
|
+
export interface HostExtensionContext {
|
|
39
|
+
homeDir: string;
|
|
40
|
+
agent: AgentId;
|
|
41
|
+
workspaceRoot: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface HostExtensionActivation {
|
|
44
|
+
providers?: TrellisProvider[];
|
|
45
|
+
subscribers?: HostEventListener[];
|
|
46
|
+
}
|
|
47
|
+
export type HostExtensionFailureStage = "manifest" | "invalid" | "import" | "activate";
|
|
48
|
+
export interface HostExtensionFailure {
|
|
49
|
+
/** The manifest entry id, or the manifest itself when it could not be
|
|
50
|
+
* read as an extension list at all. */
|
|
51
|
+
id: string;
|
|
52
|
+
stage: HostExtensionFailureStage;
|
|
53
|
+
message: string;
|
|
54
|
+
}
|
|
55
|
+
export interface LoadedHostExtensions {
|
|
56
|
+
providers: TrellisProvider[];
|
|
57
|
+
subscribers: HostEventListener[];
|
|
58
|
+
failures: HostExtensionFailure[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Load every extension the canonical manifest declares. Never throws:
|
|
62
|
+
* the worst case is a `failures` entry per broken extension.
|
|
63
|
+
*/
|
|
64
|
+
export declare function loadHostExtensions(context: HostExtensionContext, warn?: (message: string) => void): Promise<LoadedHostExtensions>;
|
|
65
|
+
/**
|
|
66
|
+
* 写入(或原位更新)一条清单条目;幂等——同 id 旧条目被替换,其余条目
|
|
67
|
+
* 与未知键原样保留。清单是 canonical 内容,写入是显式管理动作(进化
|
|
68
|
+
* 启用命令 8.1),loader 侧永不写。
|
|
69
|
+
*/
|
|
70
|
+
export declare function upsertExtensionEntry(homeDir: string, entry: HostExtensionEntry): void;
|
|
71
|
+
/**
|
|
72
|
+
* 移除一条清单条目;不存在时是无副作用的 no-op(返回 false)。不清空
|
|
73
|
+
* 文件、不动其他条目——清单还可能声明别的扩展。
|
|
74
|
+
*/
|
|
75
|
+
export declare function removeExtensionEntry(homeDir: string, id: string): boolean;
|