llm-relay 0.82.2 → 0.83.2
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/config/routing-parser.js +22 -3
- package/dist/config/routing-parser.js.map +1 -1
- package/dist/config-types.d.ts +23 -0
- package/dist/config-types.js +1 -0
- package/dist/config-types.js.map +1 -1
- package/dist/dispatch.d.ts +2 -0
- package/dist/dispatch.js +4 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/mcp/job-archive.d.ts +9 -0
- package/dist/mcp/job-archive.js +50 -12
- package/dist/mcp/job-archive.js.map +1 -1
- package/dist/mcp/job-journal.d.ts +35 -2
- package/dist/mcp/job-journal.js +68 -9
- package/dist/mcp/job-journal.js.map +1 -1
- package/dist/mcp/lane-runner.d.ts +98 -1
- package/dist/mcp/lane-runner.js +145 -2
- package/dist/mcp/lane-runner.js.map +1 -1
- package/dist/mcp/server.d.ts +105 -0
- package/dist/mcp/server.js +321 -66
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tree-delta.d.ts +40 -0
- package/dist/mcp/tree-delta.js +121 -0
- package/dist/mcp/tree-delta.js.map +1 -0
- package/dist/setup-claude.d.ts +5 -0
- package/dist/setup-claude.js +21 -7
- package/dist/setup-claude.js.map +1 -1
- package/package.json +1 -1
- package/scripts/install-skill.mjs +9 -5
package/dist/mcp/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { DEFAULT_MCP_MAX_WAIT_MS } from "../config-types.js";
|
|
1
|
+
import { DEFAULT_MCP_BLOCKING_WAIT_MS, DEFAULT_MCP_MAX_WAIT_MS, EFFORT_LEVELS } from "../config-types.js";
|
|
2
2
|
import { isToolUseBlock } from "../anthropic.js";
|
|
3
3
|
import { formatAttemptBudget, formatLaneStats, isPassThroughSpec, LANE_UNRELIABLE_STREAK, mcpPassThroughReason } from "../dispatch.js";
|
|
4
4
|
import { laneOfRung } from "../lane-manifest.js";
|
|
5
|
+
import { defaultTreeSnapshot, newestChangeMs, renderTreeDelta, sameTree, } from "./tree-delta.js";
|
|
5
6
|
import { estimateTokensFromCharacters } from "../metadata.js";
|
|
6
|
-
import { DEFAULT_LANE_TIMEOUT_MS, DEFAULT_MAX_DEPTH, DEPTH_ENV, EMPTY_OUTPUT_REASON, LaneJobStore, SKIPPED_LANE_STATUS, attemptWasTried, classifyDispatchedResult, classifyLaneAttempt, checkCwd, currentDepth, defaultAnswerFetch, defaultLaneSpawner, isContentEmpty, readRelayAnnouncements, relayLoopbackUrl, } from "./lane-runner.js";
|
|
7
|
+
import { DEFAULT_LANE_TIMEOUT_MS, DEFAULT_MAX_DEPTH, DEPTH_ENV, EMPTY_OUTPUT_REASON, LaneJobStore, SKIPPED_LANE_STATUS, agyWorkingDirInvoke, attemptWasTried, classifyDispatchedResult, classifyLaneAttempt, checkCwd, currentDepth, defaultAnswerFetch, defaultLaneSpawner, expandEnvReferences, isContentEmpty, readRelayAnnouncements, relayLoopbackUrl, } from "./lane-runner.js";
|
|
7
8
|
import { readOnlyInvoke, readOnlyVerdict } from "./readonly-boundary.js";
|
|
8
9
|
import { agyQuotaStatement } from "./agy-quota-log.js";
|
|
9
10
|
import { nullJobJournal } from "./job-journal.js";
|
|
@@ -11,6 +12,7 @@ import { nullJobArchive } from "./job-archive.js";
|
|
|
11
12
|
import { RPC_INTERNAL_ERROR, RPC_INVALID_PARAMS, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, encodeMessage, errorResponse, isJsonRpcNotification, isJsonRpcRequest, logStderr, negotiateProtocolVersion, resultResponse, splitMessages, } from "./protocol.js";
|
|
12
13
|
export const MCP_SERVER_NAME = "llm-relay";
|
|
13
14
|
export const DEFAULT_ANSWER_MAX_TOKENS = 4096;
|
|
15
|
+
export const RECENT_JOBS_LISTED = 20;
|
|
14
16
|
export const MCP_INSTRUCTIONS = "Use dispatch from every MCP host — Claude, Codex, desktop, CLI, or otherwise — to hand a " +
|
|
15
17
|
"whole task to another agent lane and get its answer back. Never choose a host-specific child " +
|
|
16
18
|
"mechanism yourself; whether the lane is a model pool or a peer CLI is resolved here.\n\n" +
|
|
@@ -55,7 +57,8 @@ const TOOLS = [
|
|
|
55
57
|
"environment and idle timeouts are handled here, so you never build a command line. If the " +
|
|
56
58
|
"lane is still running, returns a jobId to poll with dispatch_status — it blocks at most " +
|
|
57
59
|
"routing.mcp.maxWaitMs first, so a slow lane degrades to polling instead of hitting the " +
|
|
58
|
-
"host's tool timeout. In
|
|
60
|
+
"host's tool timeout. In Claude Code, a call that carries a progress token instead waits " +
|
|
61
|
+
"for the answer (up to routing.mcp.blockingWaitMs) and reports progress. In Codex " +
|
|
59
62
|
"Desktop, use this instead of a pool/* collaboration child, which the ChatGPT launcher " +
|
|
60
63
|
"rejects before the custom provider or relay is reached. Set mode to \"answer\" for a " +
|
|
61
64
|
"question, draft, summary, or second opinion that needs no file access — it posts straight " +
|
|
@@ -111,13 +114,22 @@ const TOOLS = [
|
|
|
111
114
|
},
|
|
112
115
|
waitMs: {
|
|
113
116
|
type: "number",
|
|
114
|
-
description: "How long to block before returning a jobId instead (default routing.mcp.maxWaitMs
|
|
115
|
-
"
|
|
117
|
+
description: "How long to block before returning a jobId instead (default routing.mcp.maxWaitMs, or " +
|
|
118
|
+
"routing.mcp.blockingWaitMs for a Claude Code call that asked for progress; a larger " +
|
|
119
|
+
"waitMs is clamped to that ceiling and the clamp is announced in the reply).",
|
|
116
120
|
},
|
|
117
121
|
timeoutMs: {
|
|
118
122
|
type: "number",
|
|
119
123
|
description: `Hard ceiling on the lane run (default ${DEFAULT_LANE_TIMEOUT_MS}).`,
|
|
120
124
|
},
|
|
125
|
+
scope: {
|
|
126
|
+
type: "array",
|
|
127
|
+
items: { type: "string" },
|
|
128
|
+
description: "Agent mode only: the paths or globs (relative to cwd) the task may change. The answer " +
|
|
129
|
+
"always lists what changed in the git tree (a `tree delta` block); with scope, every " +
|
|
130
|
+
"changed path outside it is marked OUT OF SCOPE. The relay reports only — it never " +
|
|
131
|
+
"refuses or reverts.",
|
|
132
|
+
},
|
|
121
133
|
readOnly: {
|
|
122
134
|
type: "boolean",
|
|
123
135
|
description: "Declare that this dispatch must not mutate anything — a review, a survey, a second " +
|
|
@@ -139,12 +151,15 @@ const TOOLS = [
|
|
|
139
151
|
{
|
|
140
152
|
name: "dispatch_status",
|
|
141
153
|
title: "Check a dispatched job",
|
|
142
|
-
description: "Report
|
|
143
|
-
"
|
|
154
|
+
description: "Report a dispatched job. While it runs: its lane, elapsed time and output so far. Once it " +
|
|
155
|
+
"has ended: its full answer, exactly as dispatch_result returns it. Wait at least a few " +
|
|
156
|
+
"seconds between polls. Without jobId: list the recent jobs of every llm-relay MCP server " +
|
|
157
|
+
"on this machine, so a lost jobId can be found again.",
|
|
144
158
|
inputSchema: {
|
|
145
159
|
type: "object",
|
|
146
|
-
properties: {
|
|
147
|
-
|
|
160
|
+
properties: {
|
|
161
|
+
jobId: { type: "string", description: "The jobId dispatch returned. Omit it to list recent jobs." },
|
|
162
|
+
},
|
|
148
163
|
additionalProperties: false,
|
|
149
164
|
},
|
|
150
165
|
},
|
|
@@ -215,6 +230,13 @@ export function resolveWaitMs(requested, ceiling) {
|
|
|
215
230
|
}
|
|
216
231
|
return { waitMs: requested, clamped: false, requested };
|
|
217
232
|
}
|
|
233
|
+
function readStringArray(params, key) {
|
|
234
|
+
const value = params[key];
|
|
235
|
+
if (!Array.isArray(value))
|
|
236
|
+
return undefined;
|
|
237
|
+
const strings = value.filter((v) => typeof v === "string" && v.trim() !== "");
|
|
238
|
+
return strings.length > 0 ? strings : undefined;
|
|
239
|
+
}
|
|
218
240
|
function readRecord(params, key) {
|
|
219
241
|
const v = params[key];
|
|
220
242
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : undefined;
|
|
@@ -273,6 +295,23 @@ function laneEvidenceBits(lane) {
|
|
|
273
295
|
bits.push(`${lane.recentFailures} own failure${lane.recentFailures === 1 ? "" : "s"} in a row`);
|
|
274
296
|
return bits;
|
|
275
297
|
}
|
|
298
|
+
function relayProvenanceLines(job) {
|
|
299
|
+
const relay = job.relay;
|
|
300
|
+
if (!relay)
|
|
301
|
+
return [];
|
|
302
|
+
const lines = [];
|
|
303
|
+
if (relay.servedBy)
|
|
304
|
+
lines.push(`served-by: ${relay.servedBy}`);
|
|
305
|
+
if (relay.poolAttempts)
|
|
306
|
+
lines.push(`pool-attempts: ${relay.poolAttempts}`);
|
|
307
|
+
if (relay.hedged)
|
|
308
|
+
lines.push(`hedged: ${relay.hedged}`);
|
|
309
|
+
if (relay.latencyDemoted)
|
|
310
|
+
lines.push(`latency-demoted: ${relay.latencyDemoted}`);
|
|
311
|
+
if (relay.degraded)
|
|
312
|
+
lines.push(`degraded: ${relay.degraded}`);
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
276
315
|
function describeJob(job, now) {
|
|
277
316
|
const elapsed = Math.round(((job.endedAt ?? now) - job.startedAt) / 1000);
|
|
278
317
|
const head = [
|
|
@@ -287,22 +326,18 @@ function describeJob(job, now) {
|
|
|
287
326
|
head.push("timed out: yes");
|
|
288
327
|
if (job.error)
|
|
289
328
|
head.push(`error: ${job.error}`);
|
|
290
|
-
|
|
291
|
-
head.push(`served-by: ${job.relay.servedBy}`);
|
|
292
|
-
if (job.relay?.poolAttempts)
|
|
293
|
-
head.push(`pool-attempts: ${job.relay.poolAttempts}`);
|
|
294
|
-
if (job.relay?.hedged)
|
|
295
|
-
head.push(`hedged: ${job.relay.hedged}`);
|
|
296
|
-
if (job.relay?.latencyDemoted)
|
|
297
|
-
head.push(`latency-demoted: ${job.relay.latencyDemoted}`);
|
|
298
|
-
if (job.relay?.degraded)
|
|
299
|
-
head.push(`degraded: ${job.relay.degraded}`);
|
|
329
|
+
head.push(...relayProvenanceLines(job));
|
|
300
330
|
if (job.dispatchSource === "fallback")
|
|
301
331
|
head.push("dispatch-source: local-fallback (daemon unreachable)");
|
|
302
|
-
if (job.restored === true)
|
|
303
|
-
head.push("record: restored from disk — this job ended
|
|
332
|
+
if (job.restored === true) {
|
|
333
|
+
head.push("record: restored from disk — this job ended in an earlier llm-relay MCP server process or in another host's one");
|
|
334
|
+
}
|
|
304
335
|
if (job.readOnly)
|
|
305
336
|
head.push(`read-only: ${job.readOnly.binding}`);
|
|
337
|
+
if (job.launch?.length)
|
|
338
|
+
head.push(`launch: ${job.launch.join("; ")}`);
|
|
339
|
+
if (job.extended?.length)
|
|
340
|
+
head.push(`extended: ${job.extended.join("; ")}`);
|
|
306
341
|
const usually = runningTimeToAnswer(job);
|
|
307
342
|
if (usually !== null)
|
|
308
343
|
head.push(usually);
|
|
@@ -355,6 +390,10 @@ function describeAttempts(job) {
|
|
|
355
390
|
return `lanes tried:\n${lines.join("\n")}${capped}`;
|
|
356
391
|
}
|
|
357
392
|
function jobAnswer(job, now) {
|
|
393
|
+
const answer = jobAnswerBody(job, now);
|
|
394
|
+
return job.treeDelta === undefined ? answer : `${answer}\n\n${job.treeDelta}`;
|
|
395
|
+
}
|
|
396
|
+
function jobAnswerBody(job, now) {
|
|
358
397
|
const header = describeJob(job, now);
|
|
359
398
|
const body = job.stdout.trim();
|
|
360
399
|
const nothingAnswered = job.status !== "running"
|
|
@@ -412,6 +451,14 @@ function flagValue(args, flag) {
|
|
|
412
451
|
const joined = args.find((a) => a.startsWith(`${flag}=`));
|
|
413
452
|
return joined === undefined ? null : joined.slice(flag.length + 1);
|
|
414
453
|
}
|
|
454
|
+
export const LANE_ACTIVITY_WINDOW_MS = 60_000;
|
|
455
|
+
function budgetTimer(ms) {
|
|
456
|
+
return new Promise((resolve) => {
|
|
457
|
+
const t = setTimeout(() => resolve({ kind: "budget" }), ms);
|
|
458
|
+
if (typeof t.unref === "function")
|
|
459
|
+
t.unref();
|
|
460
|
+
});
|
|
461
|
+
}
|
|
415
462
|
function laneAnswered(r) {
|
|
416
463
|
return r.code === 0 && !r.timedOut && !isContentEmpty(r.stdout);
|
|
417
464
|
}
|
|
@@ -454,6 +501,9 @@ function laneSummary(lane, inFlight) {
|
|
|
454
501
|
bits.push(formatLaneStats(lane.stats));
|
|
455
502
|
return bits.join(" ");
|
|
456
503
|
}
|
|
504
|
+
export const BLOCKING_WAIT_CLIENTS = ["claude-code"];
|
|
505
|
+
export const HOST_WAIT_CEILING_MS = { "claude-ai": 50_000 };
|
|
506
|
+
export const PROGRESS_INTERVAL_MS = 30_000;
|
|
457
507
|
export class McpDispatchServer {
|
|
458
508
|
deps;
|
|
459
509
|
jobs;
|
|
@@ -463,6 +513,9 @@ export class McpDispatchServer {
|
|
|
463
513
|
cwd;
|
|
464
514
|
maxDepth;
|
|
465
515
|
buffer = "";
|
|
516
|
+
clientName;
|
|
517
|
+
cancellable = new Map();
|
|
518
|
+
trees = new Map();
|
|
466
519
|
constructor(deps) {
|
|
467
520
|
this.deps = deps;
|
|
468
521
|
this.jobs = new LaneJobStore(deps.journal ?? nullJobJournal, deps.archive ?? nullJobArchive);
|
|
@@ -505,12 +558,20 @@ export class McpDispatchServer {
|
|
|
505
558
|
this.deps.write(encodeMessage({ jsonrpc: "2.0", id: 0, error: { code: RPC_PARSE_ERROR, message: "invalid JSON" } }));
|
|
506
559
|
return;
|
|
507
560
|
}
|
|
508
|
-
if (isJsonRpcNotification(parsed))
|
|
561
|
+
if (isJsonRpcNotification(parsed)) {
|
|
562
|
+
if (parsed.method === "notifications/cancelled")
|
|
563
|
+
this.cancelRequest(parsed.params);
|
|
509
564
|
return;
|
|
565
|
+
}
|
|
510
566
|
if (!isJsonRpcRequest(parsed))
|
|
511
567
|
return;
|
|
568
|
+
const abort = new AbortController();
|
|
569
|
+
if (parsed.method === "tools/call")
|
|
570
|
+
this.cancellable.set(parsed.id, abort);
|
|
512
571
|
try {
|
|
513
|
-
const result = await this.route(parsed.method, parsed.params);
|
|
572
|
+
const result = await this.route(parsed.method, parsed.params, abort.signal);
|
|
573
|
+
if (abort.signal.aborted)
|
|
574
|
+
return;
|
|
514
575
|
if (result === undefined) {
|
|
515
576
|
this.send(errorResponse(parsed.id, RPC_METHOD_NOT_FOUND, `unknown method: ${parsed.method}`));
|
|
516
577
|
return;
|
|
@@ -520,8 +581,20 @@ export class McpDispatchServer {
|
|
|
520
581
|
catch (e) {
|
|
521
582
|
this.send(errorResponse(parsed.id, RPC_INTERNAL_ERROR, e.message));
|
|
522
583
|
}
|
|
584
|
+
finally {
|
|
585
|
+
if (this.cancellable.get(parsed.id) === abort)
|
|
586
|
+
this.cancellable.delete(parsed.id);
|
|
587
|
+
}
|
|
523
588
|
}
|
|
524
|
-
|
|
589
|
+
cancelRequest(params) {
|
|
590
|
+
if (typeof params !== "object" || params === null)
|
|
591
|
+
return;
|
|
592
|
+
const id = params["requestId"];
|
|
593
|
+
if (typeof id !== "string" && typeof id !== "number")
|
|
594
|
+
return;
|
|
595
|
+
this.cancellable.get(id)?.abort();
|
|
596
|
+
}
|
|
597
|
+
async route(method, params, signal) {
|
|
525
598
|
switch (method) {
|
|
526
599
|
case "initialize":
|
|
527
600
|
return this.initialize(params);
|
|
@@ -530,13 +603,16 @@ export class McpDispatchServer {
|
|
|
530
603
|
case "tools/list":
|
|
531
604
|
return { tools: TOOLS };
|
|
532
605
|
case "tools/call":
|
|
533
|
-
return this.callTool(params);
|
|
606
|
+
return this.callTool(params, signal);
|
|
534
607
|
default:
|
|
535
608
|
return undefined;
|
|
536
609
|
}
|
|
537
610
|
}
|
|
538
611
|
initialize(params) {
|
|
539
612
|
const p = (typeof params === "object" && params !== null ? params : {});
|
|
613
|
+
const info = p["clientInfo"];
|
|
614
|
+
const name = typeof info === "object" && info !== null ? info["name"] : undefined;
|
|
615
|
+
this.clientName = typeof name === "string" ? name : undefined;
|
|
540
616
|
return {
|
|
541
617
|
protocolVersion: negotiateProtocolVersion(p["protocolVersion"]),
|
|
542
618
|
capabilities: { tools: { listChanged: false } },
|
|
@@ -544,7 +620,7 @@ export class McpDispatchServer {
|
|
|
544
620
|
instructions: MCP_INSTRUCTIONS,
|
|
545
621
|
};
|
|
546
622
|
}
|
|
547
|
-
async callTool(params) {
|
|
623
|
+
async callTool(params, signal) {
|
|
548
624
|
const p = (typeof params === "object" && params !== null ? params : {});
|
|
549
625
|
const name = p["name"];
|
|
550
626
|
const args = (typeof p["arguments"] === "object" && p["arguments"] !== null
|
|
@@ -553,12 +629,18 @@ export class McpDispatchServer {
|
|
|
553
629
|
if (typeof name !== "string") {
|
|
554
630
|
throw Object.assign(new Error("tools/call requires a tool name"), { code: RPC_INVALID_PARAMS });
|
|
555
631
|
}
|
|
556
|
-
|
|
632
|
+
const meta = p["_meta"];
|
|
633
|
+
const token = typeof meta === "object" && meta !== null ? meta["progressToken"] : undefined;
|
|
634
|
+
const ctx = {
|
|
635
|
+
progressToken: typeof token === "string" || typeof token === "number" ? token : undefined,
|
|
636
|
+
signal,
|
|
637
|
+
};
|
|
638
|
+
return this.withVersionNotice(await this.runTool(name, args, ctx));
|
|
557
639
|
}
|
|
558
|
-
async runTool(name, args) {
|
|
640
|
+
async runTool(name, args, ctx) {
|
|
559
641
|
switch (name) {
|
|
560
642
|
case "dispatch":
|
|
561
|
-
return this.toolDispatch(args);
|
|
643
|
+
return this.toolDispatch(args, ctx);
|
|
562
644
|
case "dispatch_status":
|
|
563
645
|
return this.toolStatus(args);
|
|
564
646
|
case "dispatch_result":
|
|
@@ -608,32 +690,79 @@ export class McpDispatchServer {
|
|
|
608
690
|
toolStatus(args) {
|
|
609
691
|
const jobId = readString(args, "jobId");
|
|
610
692
|
if (!jobId)
|
|
611
|
-
return textResult(
|
|
612
|
-
const job = this.jobs.
|
|
693
|
+
return textResult(this.recentJobs());
|
|
694
|
+
const job = this.jobs.find(jobId);
|
|
613
695
|
if (!job)
|
|
614
|
-
return
|
|
615
|
-
|
|
696
|
+
return this.unknownJob(jobId);
|
|
697
|
+
if (job.status === "running")
|
|
698
|
+
return textResult(describeJob(job, this.now()));
|
|
699
|
+
return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
|
|
616
700
|
}
|
|
617
701
|
toolResult(args) {
|
|
618
702
|
const jobId = readString(args, "jobId");
|
|
619
703
|
if (!jobId)
|
|
620
704
|
return textResult("dispatch_result requires jobId", true);
|
|
621
|
-
const job = this.jobs.
|
|
705
|
+
const job = this.jobs.find(jobId);
|
|
622
706
|
if (!job)
|
|
623
|
-
return
|
|
707
|
+
return this.unknownJob(jobId);
|
|
624
708
|
return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
|
|
625
709
|
}
|
|
710
|
+
recentJobs() {
|
|
711
|
+
const now = this.now();
|
|
712
|
+
const rows = this.jobs.recent(RECENT_JOBS_LISTED);
|
|
713
|
+
if (rows.length === 0)
|
|
714
|
+
return "No dispatched jobs are recorded on this machine.";
|
|
715
|
+
const lines = rows.map((r) => {
|
|
716
|
+
const seconds = Math.max(0, Math.round(((r.endedAt ?? now) - r.startedAt) / 1000));
|
|
717
|
+
const where = r.elsewhere ? " [another MCP server process]" : "";
|
|
718
|
+
const label = r.label ? ` — ${r.label}` : "";
|
|
719
|
+
return `${r.id} ${r.status} ${seconds}s ${r.laneId}${where}${label}`;
|
|
720
|
+
});
|
|
721
|
+
return `recent jobs (newest first):\n${lines.join("\n")}\n\nPass a jobId to read one.`;
|
|
722
|
+
}
|
|
723
|
+
unknownJob(jobId) {
|
|
724
|
+
const elsewhere = this.jobs.runningElsewhere(jobId);
|
|
725
|
+
if (elsewhere === undefined)
|
|
726
|
+
return textResult(`unknown jobId: ${jobId}`, true);
|
|
727
|
+
const elapsed = Math.max(0, Math.round((this.now() - elsewhere.startedAt) / 1000));
|
|
728
|
+
return textResult([
|
|
729
|
+
`job: ${jobId}`,
|
|
730
|
+
`lane: ${elsewhere.laneId}${elsewhere.spec ? ` (${elsewhere.spec})` : ""}`,
|
|
731
|
+
"status: running",
|
|
732
|
+
`elapsed: ${elapsed}s`,
|
|
733
|
+
`record: running in another llm-relay MCP server process (pid ${elsewhere.pid})`,
|
|
734
|
+
].join("\n") +
|
|
735
|
+
"\n\nThis process cannot report its progress or cancel it. Poll again: its answer appears " +
|
|
736
|
+
"here once it ends.");
|
|
737
|
+
}
|
|
626
738
|
toolCancel(args) {
|
|
627
739
|
const jobId = readString(args, "jobId");
|
|
628
740
|
if (!jobId)
|
|
629
741
|
return textResult("dispatch_cancel requires jobId", true);
|
|
630
|
-
const job = this.jobs.
|
|
742
|
+
const job = this.jobs.find(jobId);
|
|
631
743
|
if (!job)
|
|
632
|
-
return
|
|
744
|
+
return this.unknownJob(jobId);
|
|
633
745
|
const cancelled = this.jobs.cancel(jobId);
|
|
746
|
+
if (cancelled)
|
|
747
|
+
void this.recordTreeDelta(jobId);
|
|
634
748
|
return textResult(cancelled ? `cancelled ${jobId}` : `${jobId} was already ${job.status}`);
|
|
635
749
|
}
|
|
636
|
-
|
|
750
|
+
blockingWaitFor(ctx, maxWaitMs) {
|
|
751
|
+
const cap = this.deps.config.routing?.mcp?.blockingWaitMs ?? DEFAULT_MCP_BLOCKING_WAIT_MS;
|
|
752
|
+
const client = this.clientName;
|
|
753
|
+
if (cap <= 0 || client === undefined)
|
|
754
|
+
return null;
|
|
755
|
+
if (ctx.progressToken !== undefined && BLOCKING_WAIT_CLIENTS.includes(client)) {
|
|
756
|
+
return { ms: Math.max(cap, maxWaitMs), progress: true, key: "routing.mcp.blockingWaitMs" };
|
|
757
|
+
}
|
|
758
|
+
if (!Object.hasOwn(HOST_WAIT_CEILING_MS, client))
|
|
759
|
+
return null;
|
|
760
|
+
const host = HOST_WAIT_CEILING_MS[client] ?? 0;
|
|
761
|
+
if (host <= maxWaitMs)
|
|
762
|
+
return null;
|
|
763
|
+
return { ms: Math.min(cap, host), progress: false, key: `the ${client} tool-call ceiling` };
|
|
764
|
+
}
|
|
765
|
+
async toolDispatch(args, ctx) {
|
|
637
766
|
const task = readString(args, "task");
|
|
638
767
|
if (!task)
|
|
639
768
|
return textResult("dispatch requires a non-empty task", true);
|
|
@@ -661,6 +790,7 @@ export class McpDispatchServer {
|
|
|
661
790
|
cwd: readString(args, "cwd") ?? this.cwd(),
|
|
662
791
|
depth,
|
|
663
792
|
readOnly: args["readOnly"] === true,
|
|
793
|
+
scope: readStringArray(args, "scope"),
|
|
664
794
|
tier: view.tier ?? undefined,
|
|
665
795
|
timeoutMs: readNumber(args, "timeoutMs") ?? DEFAULT_LANE_TIMEOUT_MS,
|
|
666
796
|
attemptMs: walk !== null ? walk.attemptMs : null,
|
|
@@ -678,17 +808,25 @@ export class McpDispatchServer {
|
|
|
678
808
|
if (!readOnlyVerdictResult.ok)
|
|
679
809
|
return textResult(readOnlyVerdictResult.refusal, true);
|
|
680
810
|
const first = view.ladder.find((l) => l.id === ordered[0]) ?? view.next;
|
|
681
|
-
const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource);
|
|
811
|
+
const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource, taskLabel(task));
|
|
682
812
|
this.jobs.noteWalkScope(job.id, { enabled: walk !== null, lanesNotTried: notTried, forced });
|
|
683
|
-
const
|
|
813
|
+
const maxWaitMs = this.deps.config.routing?.mcp?.maxWaitMs ?? DEFAULT_MCP_MAX_WAIT_MS;
|
|
814
|
+
const blocking = this.blockingWaitFor(ctx, maxWaitMs);
|
|
815
|
+
const ceiling = blocking?.ms ?? maxWaitMs;
|
|
816
|
+
const ceilingKey = blocking?.key ?? "routing.mcp.maxWaitMs";
|
|
684
817
|
const waitMs = resolveWaitMs(args["waitMs"], ceiling);
|
|
685
818
|
if ("refusal" in waitMs)
|
|
686
819
|
return textResult(waitMs.refusal, true);
|
|
687
|
-
const settled = this.runWalk(job.id, view, ordered, task, opts).catch((e) => {
|
|
820
|
+
const settled = this.runWalk(job.id, view, ordered, task, opts).catch(async (e) => {
|
|
821
|
+
await this.recordTreeDelta(job.id);
|
|
688
822
|
this.jobs.fail(job.id, `dispatch walk failed: ${e.message}`);
|
|
689
823
|
return "done";
|
|
690
824
|
});
|
|
691
|
-
return this.awaitOrPoll(job.id, settled, waitMs.waitMs,
|
|
825
|
+
return this.awaitOrPoll(job.id, settled, waitMs.waitMs, {
|
|
826
|
+
clamp: waitMs.clamped ? { requested: waitMs.requested, ceiling, key: ceilingKey } : undefined,
|
|
827
|
+
progressToken: blocking?.progress === true ? ctx.progressToken : undefined,
|
|
828
|
+
signal: ctx.signal,
|
|
829
|
+
});
|
|
692
830
|
}
|
|
693
831
|
walkOrder(view, walk, forced, nextId) {
|
|
694
832
|
const runnable = (id) => forced || !this.isPassThroughLane(view.ladder.find((l) => l.id === id));
|
|
@@ -707,6 +845,7 @@ export class McpDispatchServer {
|
|
|
707
845
|
}
|
|
708
846
|
}
|
|
709
847
|
async runWalk(jobId, view, laneIds, task, opts) {
|
|
848
|
+
await this.startTreeDelta(jobId, opts);
|
|
710
849
|
for (let i = 0; i < laneIds.length; i++) {
|
|
711
850
|
if (this.jobs.get(jobId)?.status !== "running")
|
|
712
851
|
return "done";
|
|
@@ -714,7 +853,7 @@ export class McpDispatchServer {
|
|
|
714
853
|
const lane = laneId === undefined ? undefined : view.ladder.find((l) => l.id === laneId);
|
|
715
854
|
if (lane === undefined)
|
|
716
855
|
continue;
|
|
717
|
-
if (this.
|
|
856
|
+
if (this.skipBeforeStart(jobId, lane, opts))
|
|
718
857
|
continue;
|
|
719
858
|
const readOnly = this.bindReadOnlyOrSkip(jobId, lane, opts);
|
|
720
859
|
if (readOnly.skipped)
|
|
@@ -731,6 +870,7 @@ export class McpDispatchServer {
|
|
|
731
870
|
if (this.jobs.get(jobId)?.status === "cancelled")
|
|
732
871
|
return "done";
|
|
733
872
|
if (outcome.refusal !== undefined) {
|
|
873
|
+
await this.recordTreeDelta(jobId);
|
|
734
874
|
this.jobs.fail(jobId, outcome.refusal);
|
|
735
875
|
return "done";
|
|
736
876
|
}
|
|
@@ -756,6 +896,7 @@ export class McpDispatchServer {
|
|
|
756
896
|
adHoc: lane.adHoc === true,
|
|
757
897
|
}, outcome.run.stdout.length, { status, wallClockMs: elapsedMs, exitCode: outcome.run.code });
|
|
758
898
|
if (status === "completed" || isLast) {
|
|
899
|
+
await this.recordTreeDelta(jobId);
|
|
759
900
|
this.jobs.complete(jobId, outcome.run, status === "completed" ? undefined : (outcome.semanticFailure ?? reason), outcome.relay);
|
|
760
901
|
return "done";
|
|
761
902
|
}
|
|
@@ -764,6 +905,7 @@ export class McpDispatchServer {
|
|
|
764
905
|
const ran = attempts.filter((a) => attemptWasTried(a.status)).length;
|
|
765
906
|
const allSkipped = attempts.length > 0 && ran === 0;
|
|
766
907
|
const skipReasons = [...new Set(attempts.filter((a) => !attemptWasTried(a.status)).map((a) => a.reason ?? "skipped"))];
|
|
908
|
+
await this.recordTreeDelta(jobId);
|
|
767
909
|
this.jobs.fail(jobId, allSkipped
|
|
768
910
|
? `every lane in the selection order was skipped before it ran: ${skipReasons.join("; ")}`
|
|
769
911
|
: ran === 0
|
|
@@ -771,6 +913,29 @@ export class McpDispatchServer {
|
|
|
771
913
|
: `no lane answered, and the ladder no longer holds the remaining lanes in the selection order (${ran} tried)`);
|
|
772
914
|
return "done";
|
|
773
915
|
}
|
|
916
|
+
async startTreeDelta(jobId, opts) {
|
|
917
|
+
if (opts.mode !== "agent")
|
|
918
|
+
return;
|
|
919
|
+
const before = await this.readTree(opts.cwd);
|
|
920
|
+
if (before !== null)
|
|
921
|
+
this.trees.set(jobId, { cwd: opts.cwd, before, scope: opts.scope });
|
|
922
|
+
}
|
|
923
|
+
async recordTreeDelta(jobId) {
|
|
924
|
+
const tree = this.trees.get(jobId);
|
|
925
|
+
if (tree === undefined)
|
|
926
|
+
return;
|
|
927
|
+
this.trees.delete(jobId);
|
|
928
|
+
const after = await this.readTree(tree.cwd);
|
|
929
|
+
this.jobs.noteTreeDelta(jobId, renderTreeDelta(tree.cwd, tree.before, after, tree.scope));
|
|
930
|
+
}
|
|
931
|
+
async readTree(cwd) {
|
|
932
|
+
try {
|
|
933
|
+
return await (this.deps.treeSnapshot ?? defaultTreeSnapshot)(cwd);
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
774
939
|
budgetWithheld(view, laneIds, i) {
|
|
775
940
|
return !laneIds.slice(i + 1).some((id) => {
|
|
776
941
|
const later = view.ladder.find((l) => l.id === id);
|
|
@@ -793,6 +958,50 @@ export class McpDispatchServer {
|
|
|
793
958
|
this.jobs.noteSkippedLane(jobId);
|
|
794
959
|
return true;
|
|
795
960
|
}
|
|
961
|
+
skipBeforeStart(jobId, lane, opts) {
|
|
962
|
+
return this.skipIfAtConcurrencyCap(jobId, lane) || this.skipIfBelowTier(jobId, lane, opts);
|
|
963
|
+
}
|
|
964
|
+
skipIfBelowTier(jobId, lane, opts) {
|
|
965
|
+
if (lane.capability === undefined || opts.tier === undefined)
|
|
966
|
+
return false;
|
|
967
|
+
if (this.jobs.get(jobId)?.forcedLane === true)
|
|
968
|
+
return false;
|
|
969
|
+
const packet = EFFORT_LEVELS.indexOf(opts.tier);
|
|
970
|
+
const rung = EFFORT_LEVELS.indexOf(lane.capability);
|
|
971
|
+
if (packet < 0 || rung < 0 || rung >= packet)
|
|
972
|
+
return false;
|
|
973
|
+
this.jobs.recordAttempt(jobId, {
|
|
974
|
+
laneId: lane.id,
|
|
975
|
+
spec: lane.spec,
|
|
976
|
+
status: SKIPPED_LANE_STATUS,
|
|
977
|
+
elapsedMs: 0,
|
|
978
|
+
reason: `lane "${lane.id}" declares capability ${lane.capability}, below this ${opts.tier} dispatch`,
|
|
979
|
+
});
|
|
980
|
+
this.jobs.noteSkippedLane(jobId);
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
async laneStillWorking(jobId) {
|
|
984
|
+
const now = this.now();
|
|
985
|
+
const lastOutput = this.jobs.get(jobId)?.activity?.lastOutputAt;
|
|
986
|
+
if (typeof lastOutput === "number" && now - lastOutput <= LANE_ACTIVITY_WINDOW_MS) {
|
|
987
|
+
return `it wrote output ${Math.round((now - lastOutput) / 1000)}s ago`;
|
|
988
|
+
}
|
|
989
|
+
const tree = this.trees.get(jobId);
|
|
990
|
+
if (tree === undefined)
|
|
991
|
+
return null;
|
|
992
|
+
const reading = await this.readTree(tree.cwd);
|
|
993
|
+
if (reading === null)
|
|
994
|
+
return null;
|
|
995
|
+
const previous = tree.lastSeen ?? tree.before;
|
|
996
|
+
tree.lastSeen = reading;
|
|
997
|
+
if (!sameTree(previous, reading))
|
|
998
|
+
return "its working tree changed";
|
|
999
|
+
const newest = newestChangeMs(tree.cwd, reading);
|
|
1000
|
+
if (newest !== null && now - newest <= LANE_ACTIVITY_WINDOW_MS) {
|
|
1001
|
+
return `a file in its working tree changed ${Math.max(0, Math.round((now - newest) / 1000))}s ago`;
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
796
1005
|
bindReadOnlyOrSkip(jobId, lane, opts) {
|
|
797
1006
|
if (!opts.readOnly)
|
|
798
1007
|
return { skipped: false };
|
|
@@ -825,16 +1034,20 @@ export class McpDispatchServer {
|
|
|
825
1034
|
}));
|
|
826
1035
|
if (budgetMs === null)
|
|
827
1036
|
return guarded;
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1037
|
+
const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
|
|
1038
|
+
let waitMs = budgetMs;
|
|
1039
|
+
for (;;) {
|
|
1040
|
+
const raced = await Promise.race([settled, budgetTimer(waitMs)]);
|
|
1041
|
+
if (raced.kind === "settled")
|
|
1042
|
+
return raced.outcome;
|
|
1043
|
+
if (this.jobs.get(jobId)?.status !== "running")
|
|
1044
|
+
break;
|
|
1045
|
+
const working = await this.laneStillWorking(jobId);
|
|
1046
|
+
if (working === null)
|
|
1047
|
+
break;
|
|
1048
|
+
this.jobs.noteExtension(jobId, lane.id, `${lane.id} kept past its ${Math.round(budgetMs / 1000)}s budget because ${working}`);
|
|
1049
|
+
waitMs = LANE_ACTIVITY_WINDOW_MS;
|
|
1050
|
+
}
|
|
838
1051
|
started.kill();
|
|
839
1052
|
return { run: emptyRun(), abandoned: true };
|
|
840
1053
|
}
|
|
@@ -864,8 +1077,8 @@ export class McpDispatchServer {
|
|
|
864
1077
|
const cwdCheck = checkCwd(opts.cwd, this.deps.allowedRoots);
|
|
865
1078
|
if (!cwdCheck.ok)
|
|
866
1079
|
return { refusal: `dispatch refused: ${cwdCheck.reason}` };
|
|
867
|
-
const
|
|
868
|
-
if (!
|
|
1080
|
+
const declared = invokeOverride ?? lane.invoke;
|
|
1081
|
+
if (!declared) {
|
|
869
1082
|
const why = lane.unreachable ??
|
|
870
1083
|
(lane.spec !== undefined && this.isPassThroughLane(lane)
|
|
871
1084
|
? mcpPassThroughReason(lane.spec)
|
|
@@ -875,8 +1088,12 @@ export class McpDispatchServer {
|
|
|
875
1088
|
kill: () => { },
|
|
876
1089
|
};
|
|
877
1090
|
}
|
|
878
|
-
const
|
|
1091
|
+
const agy = agyWorkingDirInvoke(declared, opts.cwd);
|
|
1092
|
+
const invoke = agy?.invoke ?? declared;
|
|
1093
|
+
const expansion = expandEnvReferences(applyLaneEnv(process.env, invoke.env), this.deps.platform);
|
|
1094
|
+
const env = expansion.env;
|
|
879
1095
|
env[DEPTH_ENV] = String(opts.depth + 1);
|
|
1096
|
+
this.jobs.noteLaunch(jobId, [...expansion.notes, ...(agy === null ? [] : [agy.note])]);
|
|
880
1097
|
const startedAt = this.now();
|
|
881
1098
|
this.jobs.beginAttemptActivity(jobId, startedAt);
|
|
882
1099
|
let run;
|
|
@@ -1029,15 +1246,33 @@ export class McpDispatchServer {
|
|
|
1029
1246
|
const text = extractAnswerText(parsed, opts.schema !== undefined);
|
|
1030
1247
|
return { run: { code: 0, stdout: text, stderr: "", timedOut: false }, relay };
|
|
1031
1248
|
}
|
|
1032
|
-
async awaitOrPoll(jobId, settled, waitMs,
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1249
|
+
async awaitOrPoll(jobId, settled, waitMs, opts = {}) {
|
|
1250
|
+
const { clamp, progressToken, signal } = opts;
|
|
1251
|
+
const timers = [];
|
|
1252
|
+
const startedAt = this.now();
|
|
1253
|
+
if (progressToken !== undefined) {
|
|
1254
|
+
const tick = setInterval(() => this.sendProgress(jobId, progressToken, startedAt), PROGRESS_INTERVAL_MS);
|
|
1255
|
+
if (typeof tick.unref === "function")
|
|
1256
|
+
tick.unref();
|
|
1257
|
+
timers.push(tick);
|
|
1258
|
+
}
|
|
1259
|
+
let raced;
|
|
1260
|
+
try {
|
|
1261
|
+
raced = await Promise.race([
|
|
1262
|
+
settled,
|
|
1263
|
+
new Promise((resolve) => {
|
|
1264
|
+
const t = setTimeout(() => resolve("pending"), waitMs);
|
|
1265
|
+
if (typeof t.unref === "function")
|
|
1266
|
+
t.unref();
|
|
1267
|
+
timers.push(t);
|
|
1268
|
+
signal?.addEventListener("abort", () => resolve("pending"), { once: true });
|
|
1269
|
+
}),
|
|
1270
|
+
]);
|
|
1271
|
+
}
|
|
1272
|
+
finally {
|
|
1273
|
+
for (const t of timers)
|
|
1274
|
+
clearTimeout(t);
|
|
1275
|
+
}
|
|
1041
1276
|
const current = this.jobs.get(jobId);
|
|
1042
1277
|
if (!current)
|
|
1043
1278
|
return textResult(`unknown jobId: ${jobId}`, true);
|
|
@@ -1045,12 +1280,32 @@ export class McpDispatchServer {
|
|
|
1045
1280
|
const waitedS = Math.round(waitMs / 1000);
|
|
1046
1281
|
const announcement = clamp === undefined
|
|
1047
1282
|
? ""
|
|
1048
|
-
: `waited ${waitedS} s (waitMs ${clamp.requested} clamped to
|
|
1283
|
+
: `waited ${waitedS} s (waitMs ${clamp.requested} clamped to ${clamp.key} ${clamp.ceiling})\n`;
|
|
1049
1284
|
return textResult(`${describeJob(current, this.now())}\n\n${announcement}Still running after ${waitedS}s. ` +
|
|
1050
1285
|
`Poll dispatch_status with jobId "${jobId}", then call dispatch_result.`);
|
|
1051
1286
|
}
|
|
1052
1287
|
return textResult(jobAnswer(current, this.now()), isFailureStatus(current.status));
|
|
1053
1288
|
}
|
|
1289
|
+
sendProgress(jobId, progressToken, startedAt) {
|
|
1290
|
+
const job = this.jobs.get(jobId);
|
|
1291
|
+
if (!job || job.status !== "running")
|
|
1292
|
+
return;
|
|
1293
|
+
const elapsed = Math.max(1, Math.round((this.now() - startedAt) / 1000));
|
|
1294
|
+
try {
|
|
1295
|
+
this.deps.write(encodeMessage({
|
|
1296
|
+
jsonrpc: "2.0",
|
|
1297
|
+
method: "notifications/progress",
|
|
1298
|
+
params: { progressToken, progress: elapsed, message: `${jobId} running on ${job.laneId} for ${elapsed}s` },
|
|
1299
|
+
}));
|
|
1300
|
+
}
|
|
1301
|
+
catch (e) {
|
|
1302
|
+
logStderr(`progress write failed: ${e.message}`);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
export function taskLabel(task) {
|
|
1307
|
+
const line = task.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
1308
|
+
return line.length > 80 ? `${line.slice(0, 77)}...` : line;
|
|
1054
1309
|
}
|
|
1055
1310
|
function extractAnswerText(parsed, hasSchema) {
|
|
1056
1311
|
const message = parsed;
|