agent-trellis 0.18.0 → 0.19.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/lib/gatewayBackend.d.ts +72 -2
- package/dist/lib/gatewayBackend.js +254 -13
- package/dist/lib/mcpToolRegistry.d.ts +10 -0
- package/dist/lib/mcpToolRegistry.js +19 -0
- package/dist/lib/upstreamResources.d.ts +5 -0
- package/dist/lib/upstreamResources.js +10 -0
- package/dist/pi-bridge/bundle.js +18 -0
- package/docs/runtime-infra.md +55 -0
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -82,6 +82,16 @@ export declare class McpToolRegistry {
|
|
|
82
82
|
* aggregated, with no special-casing here.
|
|
83
83
|
*/
|
|
84
84
|
add(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
|
|
85
|
+
/**
|
|
86
|
+
* Swap one server's entire registration — the mid-session recovery path.
|
|
87
|
+
* Every candidate the server previously contributed is dropped before the
|
|
88
|
+
* fresh tool set is added, so a changed upstream tool set is reflected
|
|
89
|
+
* exactly rather than accumulated. Exposed names are re-allocated across
|
|
90
|
+
* the whole registry, so a name this server frees may be re-assigned
|
|
91
|
+
* deterministically to another server's colliding tool — accepted drift,
|
|
92
|
+
* pinned by the spec scenario.
|
|
93
|
+
*/
|
|
94
|
+
replace(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
|
|
85
95
|
private exposedNameFor;
|
|
86
96
|
/** Every registered tool, under its prefixed name, in registration
|
|
87
97
|
* order. Each tool's own `description`/`inputSchema` and any additional
|
|
@@ -127,6 +127,25 @@ export class McpToolRegistry {
|
|
|
127
127
|
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
|
|
128
128
|
return { added, skipped: [] };
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Swap one server's entire registration — the mid-session recovery path.
|
|
132
|
+
* Every candidate the server previously contributed is dropped before the
|
|
133
|
+
* fresh tool set is added, so a changed upstream tool set is reflected
|
|
134
|
+
* exactly rather than accumulated. Exposed names are re-allocated across
|
|
135
|
+
* the whole registry, so a name this server frees may be re-assigned
|
|
136
|
+
* deterministically to another server's colliding tool — accepted drift,
|
|
137
|
+
* pinned by the spec scenario.
|
|
138
|
+
*/
|
|
139
|
+
replace(serverName, upstream, tools) {
|
|
140
|
+
const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
|
|
141
|
+
this.candidates.length = 0;
|
|
142
|
+
this.candidates.push(...kept);
|
|
143
|
+
for (const tool of tools)
|
|
144
|
+
this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
|
|
145
|
+
this.rebuild();
|
|
146
|
+
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
|
|
147
|
+
return { added, skipped: [] };
|
|
148
|
+
}
|
|
130
149
|
exposedNameFor(serverName, toolName) {
|
|
131
150
|
for (const [name, entry] of this.entries) {
|
|
132
151
|
if (entry.serverName === serverName && entry.toolName === toolName)
|
|
@@ -13,6 +13,11 @@ export declare class UpstreamResources {
|
|
|
13
13
|
private readonly files;
|
|
14
14
|
constructor(timeoutMs: number, warn: (message: string) => void);
|
|
15
15
|
add(source: string, client: Client): void;
|
|
16
|
+
/** Forget one source entirely: its client and every file route it
|
|
17
|
+
* registered. Called before a source is re-added mid-session — stale
|
|
18
|
+
* routes would otherwise route reads at the closed previous client
|
|
19
|
+
* (`listResources`/`listSkills` re-register the fresh client's routes). */
|
|
20
|
+
removeSource(source: string): void;
|
|
16
21
|
private skillsEnabled;
|
|
17
22
|
private isolated;
|
|
18
23
|
listResources(): Promise<Resource[]>;
|
|
@@ -54,6 +54,16 @@ export class UpstreamResources {
|
|
|
54
54
|
this.warn = warn;
|
|
55
55
|
}
|
|
56
56
|
add(source, client) { this.clients.set(source, client); }
|
|
57
|
+
/** Forget one source entirely: its client and every file route it
|
|
58
|
+
* registered. Called before a source is re-added mid-session — stale
|
|
59
|
+
* routes would otherwise route reads at the closed previous client
|
|
60
|
+
* (`listResources`/`listSkills` re-register the fresh client's routes). */
|
|
61
|
+
removeSource(source) {
|
|
62
|
+
this.clients.delete(source);
|
|
63
|
+
for (const [uri, route] of this.files)
|
|
64
|
+
if (route.source === source)
|
|
65
|
+
this.files.delete(uri);
|
|
66
|
+
}
|
|
57
67
|
skillsEnabled(client) {
|
|
58
68
|
return Boolean(client.getServerCapabilities()?.resources && client.getDiscoverResult()?.capabilities.extensions?.[EXTENSION]);
|
|
59
69
|
}
|
package/dist/pi-bridge/bundle.js
CHANGED
|
@@ -46047,6 +46047,24 @@ var McpToolRegistry = class {
|
|
|
46047
46047
|
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
|
|
46048
46048
|
return { added, skipped: [] };
|
|
46049
46049
|
}
|
|
46050
|
+
/**
|
|
46051
|
+
* Swap one server's entire registration — the mid-session recovery path.
|
|
46052
|
+
* Every candidate the server previously contributed is dropped before the
|
|
46053
|
+
* fresh tool set is added, so a changed upstream tool set is reflected
|
|
46054
|
+
* exactly rather than accumulated. Exposed names are re-allocated across
|
|
46055
|
+
* the whole registry, so a name this server frees may be re-assigned
|
|
46056
|
+
* deterministically to another server's colliding tool — accepted drift,
|
|
46057
|
+
* pinned by the spec scenario.
|
|
46058
|
+
*/
|
|
46059
|
+
replace(serverName, upstream, tools) {
|
|
46060
|
+
const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
|
|
46061
|
+
this.candidates.length = 0;
|
|
46062
|
+
this.candidates.push(...kept);
|
|
46063
|
+
for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
|
|
46064
|
+
this.rebuild();
|
|
46065
|
+
const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
|
|
46066
|
+
return { added, skipped: [] };
|
|
46067
|
+
}
|
|
46050
46068
|
exposedNameFor(serverName, toolName) {
|
|
46051
46069
|
for (const [name, entry] of this.entries) {
|
|
46052
46070
|
if (entry.serverName === serverName && entry.toolName === toolName) return name;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Runtime 基建调研议程
|
|
2
|
+
|
|
3
|
+
> **状态:调研议程,尚未实施。** 记录于 2026-10-23。四个方向是"我们的 runtime"离不开的
|
|
4
|
+
> 基建层;逐项调研,调研结果各自沉淀后再决定实施。自我进化(第 4 项)已有完整草案,
|
|
5
|
+
> 见 `docs/self-evolution.md` 与 `openspec/changes/trellis-evolve/`。
|
|
6
|
+
|
|
7
|
+
## 建议顺序:1 → 2 → 3,第 4 项按既有 tasks 推进
|
|
8
|
+
|
|
9
|
+
先吃透模型接入层(网关),再往网络层(代理、多机)走;进化已是实施轨道,不是调研轨道。
|
|
10
|
+
|
|
11
|
+
## 1. 大模型网关(Magpie)——子仓库化 + 核心实现研究
|
|
12
|
+
|
|
13
|
+
已知:
|
|
14
|
+
- Magpie 已在 Air 两层实测运行(macOS 与 OrbStack worker 各自 `127.0.0.1:3425/v1`);
|
|
15
|
+
worker 层 Codex 流式/工具往返/Responses 通过。用法见 `$local-model-gateway`、
|
|
16
|
+
`$mac-air-usage`。
|
|
17
|
+
|
|
18
|
+
调研问题:
|
|
19
|
+
- Magpie 源码在本机的位置(**未定位,第一步**);
|
|
20
|
+
- 子仓库形态:git submodule / subtree / 研究副本(先例:`~/www/morphix-research/sources/`);
|
|
21
|
+
- 核心实现:provider 路由与适配、流式转发、工具调用透传、错误与重试语义。
|
|
22
|
+
|
|
23
|
+
## 2. 代理——稳定访问层
|
|
24
|
+
|
|
25
|
+
调研问题:
|
|
26
|
+
- Magpie 到各上游 provider 的代理路由(per-provider 配置、failover 策略);
|
|
27
|
+
- npm/git 等开发工具链的代理;
|
|
28
|
+
- 与网关层的边界:重试/切换由代理层还是网关层负责;
|
|
29
|
+
- 与 Nore 的职责区分(设备互联 vs 外网代理,别混为一谈)。
|
|
30
|
+
|
|
31
|
+
已有数据点:2026-10-22 npm publish 上传连接 ECONNRESET,IPv4-first + 单 socket 解决;
|
|
32
|
+
GET/ping 正常、仅上传路径异常。
|
|
33
|
+
|
|
34
|
+
## 3. 多机互通——相互委派任务的网络底座
|
|
35
|
+
|
|
36
|
+
已知锚点:
|
|
37
|
+
- Pro↔Air 私网由 Nore 设备互联提供(`ssh morphix-air-private` → `100.127.171.120`);
|
|
38
|
+
- 本机有 `~/www/nore/` 与 `~/www/nore-next/` 两个仓库,调研以 Nore 源码为主对象之一;
|
|
39
|
+
- 现网痛点:SSH 中继偶发超时(Air doctor 详情两次读取均超时)。
|
|
40
|
+
|
|
41
|
+
委派已有雏形(分散,未统一):Trellis `tasks_handoff`(MCP)、Air 侧 Dagu
|
|
42
|
+
(`morphix-air-tasks`,当前已禁用)、agent-collab mailbox。
|
|
43
|
+
|
|
44
|
+
调研问题:网络可靠性(重连/唤醒)、服务发现、委派语义与安全边界。
|
|
45
|
+
|
|
46
|
+
## 4. 自我进化——已是实施轨道
|
|
47
|
+
|
|
48
|
+
不在本文重复。接手入口:`docs/self-evolution.md` 的"接手指南";下一步是其
|
|
49
|
+
tasks 第 1 组的三个只读前置验证(元数据信号采样、provider 读取路径、扩展加载机制),
|
|
50
|
+
之后才是托管扩展点(P1)。
|
|
51
|
+
|
|
52
|
+
## 对照样本(见记忆 research-subjects-backlog)
|
|
53
|
+
|
|
54
|
+
Raven(harness of harnesses)、Octop(腾讯云多 Agent,coordinator 调度与 workspace
|
|
55
|
+
分层)、asyar(轻量启动器,仅交互效率参照)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-trellis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Paul Leo",
|