llm-relay 0.82.2 → 0.83.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +94 -0
- package/dist/mcp/server.js +313 -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/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,8 @@ 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 PROGRESS_INTERVAL_MS = 30_000;
|
|
457
506
|
export class McpDispatchServer {
|
|
458
507
|
deps;
|
|
459
508
|
jobs;
|
|
@@ -463,6 +512,9 @@ export class McpDispatchServer {
|
|
|
463
512
|
cwd;
|
|
464
513
|
maxDepth;
|
|
465
514
|
buffer = "";
|
|
515
|
+
clientName;
|
|
516
|
+
cancellable = new Map();
|
|
517
|
+
trees = new Map();
|
|
466
518
|
constructor(deps) {
|
|
467
519
|
this.deps = deps;
|
|
468
520
|
this.jobs = new LaneJobStore(deps.journal ?? nullJobJournal, deps.archive ?? nullJobArchive);
|
|
@@ -505,12 +557,20 @@ export class McpDispatchServer {
|
|
|
505
557
|
this.deps.write(encodeMessage({ jsonrpc: "2.0", id: 0, error: { code: RPC_PARSE_ERROR, message: "invalid JSON" } }));
|
|
506
558
|
return;
|
|
507
559
|
}
|
|
508
|
-
if (isJsonRpcNotification(parsed))
|
|
560
|
+
if (isJsonRpcNotification(parsed)) {
|
|
561
|
+
if (parsed.method === "notifications/cancelled")
|
|
562
|
+
this.cancelRequest(parsed.params);
|
|
509
563
|
return;
|
|
564
|
+
}
|
|
510
565
|
if (!isJsonRpcRequest(parsed))
|
|
511
566
|
return;
|
|
567
|
+
const abort = new AbortController();
|
|
568
|
+
if (parsed.method === "tools/call")
|
|
569
|
+
this.cancellable.set(parsed.id, abort);
|
|
512
570
|
try {
|
|
513
|
-
const result = await this.route(parsed.method, parsed.params);
|
|
571
|
+
const result = await this.route(parsed.method, parsed.params, abort.signal);
|
|
572
|
+
if (abort.signal.aborted)
|
|
573
|
+
return;
|
|
514
574
|
if (result === undefined) {
|
|
515
575
|
this.send(errorResponse(parsed.id, RPC_METHOD_NOT_FOUND, `unknown method: ${parsed.method}`));
|
|
516
576
|
return;
|
|
@@ -520,8 +580,20 @@ export class McpDispatchServer {
|
|
|
520
580
|
catch (e) {
|
|
521
581
|
this.send(errorResponse(parsed.id, RPC_INTERNAL_ERROR, e.message));
|
|
522
582
|
}
|
|
583
|
+
finally {
|
|
584
|
+
if (this.cancellable.get(parsed.id) === abort)
|
|
585
|
+
this.cancellable.delete(parsed.id);
|
|
586
|
+
}
|
|
523
587
|
}
|
|
524
|
-
|
|
588
|
+
cancelRequest(params) {
|
|
589
|
+
if (typeof params !== "object" || params === null)
|
|
590
|
+
return;
|
|
591
|
+
const id = params["requestId"];
|
|
592
|
+
if (typeof id !== "string" && typeof id !== "number")
|
|
593
|
+
return;
|
|
594
|
+
this.cancellable.get(id)?.abort();
|
|
595
|
+
}
|
|
596
|
+
async route(method, params, signal) {
|
|
525
597
|
switch (method) {
|
|
526
598
|
case "initialize":
|
|
527
599
|
return this.initialize(params);
|
|
@@ -530,13 +602,16 @@ export class McpDispatchServer {
|
|
|
530
602
|
case "tools/list":
|
|
531
603
|
return { tools: TOOLS };
|
|
532
604
|
case "tools/call":
|
|
533
|
-
return this.callTool(params);
|
|
605
|
+
return this.callTool(params, signal);
|
|
534
606
|
default:
|
|
535
607
|
return undefined;
|
|
536
608
|
}
|
|
537
609
|
}
|
|
538
610
|
initialize(params) {
|
|
539
611
|
const p = (typeof params === "object" && params !== null ? params : {});
|
|
612
|
+
const info = p["clientInfo"];
|
|
613
|
+
const name = typeof info === "object" && info !== null ? info["name"] : undefined;
|
|
614
|
+
this.clientName = typeof name === "string" ? name : undefined;
|
|
540
615
|
return {
|
|
541
616
|
protocolVersion: negotiateProtocolVersion(p["protocolVersion"]),
|
|
542
617
|
capabilities: { tools: { listChanged: false } },
|
|
@@ -544,7 +619,7 @@ export class McpDispatchServer {
|
|
|
544
619
|
instructions: MCP_INSTRUCTIONS,
|
|
545
620
|
};
|
|
546
621
|
}
|
|
547
|
-
async callTool(params) {
|
|
622
|
+
async callTool(params, signal) {
|
|
548
623
|
const p = (typeof params === "object" && params !== null ? params : {});
|
|
549
624
|
const name = p["name"];
|
|
550
625
|
const args = (typeof p["arguments"] === "object" && p["arguments"] !== null
|
|
@@ -553,12 +628,18 @@ export class McpDispatchServer {
|
|
|
553
628
|
if (typeof name !== "string") {
|
|
554
629
|
throw Object.assign(new Error("tools/call requires a tool name"), { code: RPC_INVALID_PARAMS });
|
|
555
630
|
}
|
|
556
|
-
|
|
631
|
+
const meta = p["_meta"];
|
|
632
|
+
const token = typeof meta === "object" && meta !== null ? meta["progressToken"] : undefined;
|
|
633
|
+
const ctx = {
|
|
634
|
+
progressToken: typeof token === "string" || typeof token === "number" ? token : undefined,
|
|
635
|
+
signal,
|
|
636
|
+
};
|
|
637
|
+
return this.withVersionNotice(await this.runTool(name, args, ctx));
|
|
557
638
|
}
|
|
558
|
-
async runTool(name, args) {
|
|
639
|
+
async runTool(name, args, ctx) {
|
|
559
640
|
switch (name) {
|
|
560
641
|
case "dispatch":
|
|
561
|
-
return this.toolDispatch(args);
|
|
642
|
+
return this.toolDispatch(args, ctx);
|
|
562
643
|
case "dispatch_status":
|
|
563
644
|
return this.toolStatus(args);
|
|
564
645
|
case "dispatch_result":
|
|
@@ -608,32 +689,72 @@ export class McpDispatchServer {
|
|
|
608
689
|
toolStatus(args) {
|
|
609
690
|
const jobId = readString(args, "jobId");
|
|
610
691
|
if (!jobId)
|
|
611
|
-
return textResult(
|
|
612
|
-
const job = this.jobs.
|
|
692
|
+
return textResult(this.recentJobs());
|
|
693
|
+
const job = this.jobs.find(jobId);
|
|
613
694
|
if (!job)
|
|
614
|
-
return
|
|
615
|
-
|
|
695
|
+
return this.unknownJob(jobId);
|
|
696
|
+
if (job.status === "running")
|
|
697
|
+
return textResult(describeJob(job, this.now()));
|
|
698
|
+
return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
|
|
616
699
|
}
|
|
617
700
|
toolResult(args) {
|
|
618
701
|
const jobId = readString(args, "jobId");
|
|
619
702
|
if (!jobId)
|
|
620
703
|
return textResult("dispatch_result requires jobId", true);
|
|
621
|
-
const job = this.jobs.
|
|
704
|
+
const job = this.jobs.find(jobId);
|
|
622
705
|
if (!job)
|
|
623
|
-
return
|
|
706
|
+
return this.unknownJob(jobId);
|
|
624
707
|
return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
|
|
625
708
|
}
|
|
709
|
+
recentJobs() {
|
|
710
|
+
const now = this.now();
|
|
711
|
+
const rows = this.jobs.recent(RECENT_JOBS_LISTED);
|
|
712
|
+
if (rows.length === 0)
|
|
713
|
+
return "No dispatched jobs are recorded on this machine.";
|
|
714
|
+
const lines = rows.map((r) => {
|
|
715
|
+
const seconds = Math.max(0, Math.round(((r.endedAt ?? now) - r.startedAt) / 1000));
|
|
716
|
+
const where = r.elsewhere ? " [another MCP server process]" : "";
|
|
717
|
+
const label = r.label ? ` — ${r.label}` : "";
|
|
718
|
+
return `${r.id} ${r.status} ${seconds}s ${r.laneId}${where}${label}`;
|
|
719
|
+
});
|
|
720
|
+
return `recent jobs (newest first):\n${lines.join("\n")}\n\nPass a jobId to read one.`;
|
|
721
|
+
}
|
|
722
|
+
unknownJob(jobId) {
|
|
723
|
+
const elsewhere = this.jobs.runningElsewhere(jobId);
|
|
724
|
+
if (elsewhere === undefined)
|
|
725
|
+
return textResult(`unknown jobId: ${jobId}`, true);
|
|
726
|
+
const elapsed = Math.max(0, Math.round((this.now() - elsewhere.startedAt) / 1000));
|
|
727
|
+
return textResult([
|
|
728
|
+
`job: ${jobId}`,
|
|
729
|
+
`lane: ${elsewhere.laneId}${elsewhere.spec ? ` (${elsewhere.spec})` : ""}`,
|
|
730
|
+
"status: running",
|
|
731
|
+
`elapsed: ${elapsed}s`,
|
|
732
|
+
`record: running in another llm-relay MCP server process (pid ${elsewhere.pid})`,
|
|
733
|
+
].join("\n") +
|
|
734
|
+
"\n\nThis process cannot report its progress or cancel it. Poll again: its answer appears " +
|
|
735
|
+
"here once it ends.");
|
|
736
|
+
}
|
|
626
737
|
toolCancel(args) {
|
|
627
738
|
const jobId = readString(args, "jobId");
|
|
628
739
|
if (!jobId)
|
|
629
740
|
return textResult("dispatch_cancel requires jobId", true);
|
|
630
|
-
const job = this.jobs.
|
|
741
|
+
const job = this.jobs.find(jobId);
|
|
631
742
|
if (!job)
|
|
632
|
-
return
|
|
743
|
+
return this.unknownJob(jobId);
|
|
633
744
|
const cancelled = this.jobs.cancel(jobId);
|
|
745
|
+
if (cancelled)
|
|
746
|
+
void this.recordTreeDelta(jobId);
|
|
634
747
|
return textResult(cancelled ? `cancelled ${jobId}` : `${jobId} was already ${job.status}`);
|
|
635
748
|
}
|
|
636
|
-
|
|
749
|
+
blockingWaitFor(ctx, maxWaitMs) {
|
|
750
|
+
if (ctx.progressToken === undefined)
|
|
751
|
+
return null;
|
|
752
|
+
if (this.clientName === undefined || !BLOCKING_WAIT_CLIENTS.includes(this.clientName))
|
|
753
|
+
return null;
|
|
754
|
+
const cap = this.deps.config.routing?.mcp?.blockingWaitMs ?? DEFAULT_MCP_BLOCKING_WAIT_MS;
|
|
755
|
+
return cap > 0 ? Math.max(cap, maxWaitMs) : null;
|
|
756
|
+
}
|
|
757
|
+
async toolDispatch(args, ctx) {
|
|
637
758
|
const task = readString(args, "task");
|
|
638
759
|
if (!task)
|
|
639
760
|
return textResult("dispatch requires a non-empty task", true);
|
|
@@ -661,6 +782,7 @@ export class McpDispatchServer {
|
|
|
661
782
|
cwd: readString(args, "cwd") ?? this.cwd(),
|
|
662
783
|
depth,
|
|
663
784
|
readOnly: args["readOnly"] === true,
|
|
785
|
+
scope: readStringArray(args, "scope"),
|
|
664
786
|
tier: view.tier ?? undefined,
|
|
665
787
|
timeoutMs: readNumber(args, "timeoutMs") ?? DEFAULT_LANE_TIMEOUT_MS,
|
|
666
788
|
attemptMs: walk !== null ? walk.attemptMs : null,
|
|
@@ -678,17 +800,25 @@ export class McpDispatchServer {
|
|
|
678
800
|
if (!readOnlyVerdictResult.ok)
|
|
679
801
|
return textResult(readOnlyVerdictResult.refusal, true);
|
|
680
802
|
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);
|
|
803
|
+
const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource, taskLabel(task));
|
|
682
804
|
this.jobs.noteWalkScope(job.id, { enabled: walk !== null, lanesNotTried: notTried, forced });
|
|
683
|
-
const
|
|
805
|
+
const maxWaitMs = this.deps.config.routing?.mcp?.maxWaitMs ?? DEFAULT_MCP_MAX_WAIT_MS;
|
|
806
|
+
const blocking = this.blockingWaitFor(ctx, maxWaitMs);
|
|
807
|
+
const ceiling = blocking ?? maxWaitMs;
|
|
808
|
+
const ceilingKey = blocking === null ? "routing.mcp.maxWaitMs" : "routing.mcp.blockingWaitMs";
|
|
684
809
|
const waitMs = resolveWaitMs(args["waitMs"], ceiling);
|
|
685
810
|
if ("refusal" in waitMs)
|
|
686
811
|
return textResult(waitMs.refusal, true);
|
|
687
|
-
const settled = this.runWalk(job.id, view, ordered, task, opts).catch((e) => {
|
|
812
|
+
const settled = this.runWalk(job.id, view, ordered, task, opts).catch(async (e) => {
|
|
813
|
+
await this.recordTreeDelta(job.id);
|
|
688
814
|
this.jobs.fail(job.id, `dispatch walk failed: ${e.message}`);
|
|
689
815
|
return "done";
|
|
690
816
|
});
|
|
691
|
-
return this.awaitOrPoll(job.id, settled, waitMs.waitMs,
|
|
817
|
+
return this.awaitOrPoll(job.id, settled, waitMs.waitMs, {
|
|
818
|
+
clamp: waitMs.clamped ? { requested: waitMs.requested, ceiling, key: ceilingKey } : undefined,
|
|
819
|
+
progressToken: blocking === null ? undefined : ctx.progressToken,
|
|
820
|
+
signal: ctx.signal,
|
|
821
|
+
});
|
|
692
822
|
}
|
|
693
823
|
walkOrder(view, walk, forced, nextId) {
|
|
694
824
|
const runnable = (id) => forced || !this.isPassThroughLane(view.ladder.find((l) => l.id === id));
|
|
@@ -707,6 +837,7 @@ export class McpDispatchServer {
|
|
|
707
837
|
}
|
|
708
838
|
}
|
|
709
839
|
async runWalk(jobId, view, laneIds, task, opts) {
|
|
840
|
+
await this.startTreeDelta(jobId, opts);
|
|
710
841
|
for (let i = 0; i < laneIds.length; i++) {
|
|
711
842
|
if (this.jobs.get(jobId)?.status !== "running")
|
|
712
843
|
return "done";
|
|
@@ -714,7 +845,7 @@ export class McpDispatchServer {
|
|
|
714
845
|
const lane = laneId === undefined ? undefined : view.ladder.find((l) => l.id === laneId);
|
|
715
846
|
if (lane === undefined)
|
|
716
847
|
continue;
|
|
717
|
-
if (this.
|
|
848
|
+
if (this.skipBeforeStart(jobId, lane, opts))
|
|
718
849
|
continue;
|
|
719
850
|
const readOnly = this.bindReadOnlyOrSkip(jobId, lane, opts);
|
|
720
851
|
if (readOnly.skipped)
|
|
@@ -731,6 +862,7 @@ export class McpDispatchServer {
|
|
|
731
862
|
if (this.jobs.get(jobId)?.status === "cancelled")
|
|
732
863
|
return "done";
|
|
733
864
|
if (outcome.refusal !== undefined) {
|
|
865
|
+
await this.recordTreeDelta(jobId);
|
|
734
866
|
this.jobs.fail(jobId, outcome.refusal);
|
|
735
867
|
return "done";
|
|
736
868
|
}
|
|
@@ -756,6 +888,7 @@ export class McpDispatchServer {
|
|
|
756
888
|
adHoc: lane.adHoc === true,
|
|
757
889
|
}, outcome.run.stdout.length, { status, wallClockMs: elapsedMs, exitCode: outcome.run.code });
|
|
758
890
|
if (status === "completed" || isLast) {
|
|
891
|
+
await this.recordTreeDelta(jobId);
|
|
759
892
|
this.jobs.complete(jobId, outcome.run, status === "completed" ? undefined : (outcome.semanticFailure ?? reason), outcome.relay);
|
|
760
893
|
return "done";
|
|
761
894
|
}
|
|
@@ -764,6 +897,7 @@ export class McpDispatchServer {
|
|
|
764
897
|
const ran = attempts.filter((a) => attemptWasTried(a.status)).length;
|
|
765
898
|
const allSkipped = attempts.length > 0 && ran === 0;
|
|
766
899
|
const skipReasons = [...new Set(attempts.filter((a) => !attemptWasTried(a.status)).map((a) => a.reason ?? "skipped"))];
|
|
900
|
+
await this.recordTreeDelta(jobId);
|
|
767
901
|
this.jobs.fail(jobId, allSkipped
|
|
768
902
|
? `every lane in the selection order was skipped before it ran: ${skipReasons.join("; ")}`
|
|
769
903
|
: ran === 0
|
|
@@ -771,6 +905,29 @@ export class McpDispatchServer {
|
|
|
771
905
|
: `no lane answered, and the ladder no longer holds the remaining lanes in the selection order (${ran} tried)`);
|
|
772
906
|
return "done";
|
|
773
907
|
}
|
|
908
|
+
async startTreeDelta(jobId, opts) {
|
|
909
|
+
if (opts.mode !== "agent")
|
|
910
|
+
return;
|
|
911
|
+
const before = await this.readTree(opts.cwd);
|
|
912
|
+
if (before !== null)
|
|
913
|
+
this.trees.set(jobId, { cwd: opts.cwd, before, scope: opts.scope });
|
|
914
|
+
}
|
|
915
|
+
async recordTreeDelta(jobId) {
|
|
916
|
+
const tree = this.trees.get(jobId);
|
|
917
|
+
if (tree === undefined)
|
|
918
|
+
return;
|
|
919
|
+
this.trees.delete(jobId);
|
|
920
|
+
const after = await this.readTree(tree.cwd);
|
|
921
|
+
this.jobs.noteTreeDelta(jobId, renderTreeDelta(tree.cwd, tree.before, after, tree.scope));
|
|
922
|
+
}
|
|
923
|
+
async readTree(cwd) {
|
|
924
|
+
try {
|
|
925
|
+
return await (this.deps.treeSnapshot ?? defaultTreeSnapshot)(cwd);
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
774
931
|
budgetWithheld(view, laneIds, i) {
|
|
775
932
|
return !laneIds.slice(i + 1).some((id) => {
|
|
776
933
|
const later = view.ladder.find((l) => l.id === id);
|
|
@@ -793,6 +950,50 @@ export class McpDispatchServer {
|
|
|
793
950
|
this.jobs.noteSkippedLane(jobId);
|
|
794
951
|
return true;
|
|
795
952
|
}
|
|
953
|
+
skipBeforeStart(jobId, lane, opts) {
|
|
954
|
+
return this.skipIfAtConcurrencyCap(jobId, lane) || this.skipIfBelowTier(jobId, lane, opts);
|
|
955
|
+
}
|
|
956
|
+
skipIfBelowTier(jobId, lane, opts) {
|
|
957
|
+
if (lane.capability === undefined || opts.tier === undefined)
|
|
958
|
+
return false;
|
|
959
|
+
if (this.jobs.get(jobId)?.forcedLane === true)
|
|
960
|
+
return false;
|
|
961
|
+
const packet = EFFORT_LEVELS.indexOf(opts.tier);
|
|
962
|
+
const rung = EFFORT_LEVELS.indexOf(lane.capability);
|
|
963
|
+
if (packet < 0 || rung < 0 || rung >= packet)
|
|
964
|
+
return false;
|
|
965
|
+
this.jobs.recordAttempt(jobId, {
|
|
966
|
+
laneId: lane.id,
|
|
967
|
+
spec: lane.spec,
|
|
968
|
+
status: SKIPPED_LANE_STATUS,
|
|
969
|
+
elapsedMs: 0,
|
|
970
|
+
reason: `lane "${lane.id}" declares capability ${lane.capability}, below this ${opts.tier} dispatch`,
|
|
971
|
+
});
|
|
972
|
+
this.jobs.noteSkippedLane(jobId);
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
async laneStillWorking(jobId) {
|
|
976
|
+
const now = this.now();
|
|
977
|
+
const lastOutput = this.jobs.get(jobId)?.activity?.lastOutputAt;
|
|
978
|
+
if (typeof lastOutput === "number" && now - lastOutput <= LANE_ACTIVITY_WINDOW_MS) {
|
|
979
|
+
return `it wrote output ${Math.round((now - lastOutput) / 1000)}s ago`;
|
|
980
|
+
}
|
|
981
|
+
const tree = this.trees.get(jobId);
|
|
982
|
+
if (tree === undefined)
|
|
983
|
+
return null;
|
|
984
|
+
const reading = await this.readTree(tree.cwd);
|
|
985
|
+
if (reading === null)
|
|
986
|
+
return null;
|
|
987
|
+
const previous = tree.lastSeen ?? tree.before;
|
|
988
|
+
tree.lastSeen = reading;
|
|
989
|
+
if (!sameTree(previous, reading))
|
|
990
|
+
return "its working tree changed";
|
|
991
|
+
const newest = newestChangeMs(tree.cwd, reading);
|
|
992
|
+
if (newest !== null && now - newest <= LANE_ACTIVITY_WINDOW_MS) {
|
|
993
|
+
return `a file in its working tree changed ${Math.max(0, Math.round((now - newest) / 1000))}s ago`;
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
796
997
|
bindReadOnlyOrSkip(jobId, lane, opts) {
|
|
797
998
|
if (!opts.readOnly)
|
|
798
999
|
return { skipped: false };
|
|
@@ -825,16 +1026,20 @@ export class McpDispatchServer {
|
|
|
825
1026
|
}));
|
|
826
1027
|
if (budgetMs === null)
|
|
827
1028
|
return guarded;
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1029
|
+
const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
|
|
1030
|
+
let waitMs = budgetMs;
|
|
1031
|
+
for (;;) {
|
|
1032
|
+
const raced = await Promise.race([settled, budgetTimer(waitMs)]);
|
|
1033
|
+
if (raced.kind === "settled")
|
|
1034
|
+
return raced.outcome;
|
|
1035
|
+
if (this.jobs.get(jobId)?.status !== "running")
|
|
1036
|
+
break;
|
|
1037
|
+
const working = await this.laneStillWorking(jobId);
|
|
1038
|
+
if (working === null)
|
|
1039
|
+
break;
|
|
1040
|
+
this.jobs.noteExtension(jobId, lane.id, `${lane.id} kept past its ${Math.round(budgetMs / 1000)}s budget because ${working}`);
|
|
1041
|
+
waitMs = LANE_ACTIVITY_WINDOW_MS;
|
|
1042
|
+
}
|
|
838
1043
|
started.kill();
|
|
839
1044
|
return { run: emptyRun(), abandoned: true };
|
|
840
1045
|
}
|
|
@@ -864,8 +1069,8 @@ export class McpDispatchServer {
|
|
|
864
1069
|
const cwdCheck = checkCwd(opts.cwd, this.deps.allowedRoots);
|
|
865
1070
|
if (!cwdCheck.ok)
|
|
866
1071
|
return { refusal: `dispatch refused: ${cwdCheck.reason}` };
|
|
867
|
-
const
|
|
868
|
-
if (!
|
|
1072
|
+
const declared = invokeOverride ?? lane.invoke;
|
|
1073
|
+
if (!declared) {
|
|
869
1074
|
const why = lane.unreachable ??
|
|
870
1075
|
(lane.spec !== undefined && this.isPassThroughLane(lane)
|
|
871
1076
|
? mcpPassThroughReason(lane.spec)
|
|
@@ -875,8 +1080,12 @@ export class McpDispatchServer {
|
|
|
875
1080
|
kill: () => { },
|
|
876
1081
|
};
|
|
877
1082
|
}
|
|
878
|
-
const
|
|
1083
|
+
const agy = agyWorkingDirInvoke(declared, opts.cwd);
|
|
1084
|
+
const invoke = agy?.invoke ?? declared;
|
|
1085
|
+
const expansion = expandEnvReferences(applyLaneEnv(process.env, invoke.env), this.deps.platform);
|
|
1086
|
+
const env = expansion.env;
|
|
879
1087
|
env[DEPTH_ENV] = String(opts.depth + 1);
|
|
1088
|
+
this.jobs.noteLaunch(jobId, [...expansion.notes, ...(agy === null ? [] : [agy.note])]);
|
|
880
1089
|
const startedAt = this.now();
|
|
881
1090
|
this.jobs.beginAttemptActivity(jobId, startedAt);
|
|
882
1091
|
let run;
|
|
@@ -1029,15 +1238,33 @@ export class McpDispatchServer {
|
|
|
1029
1238
|
const text = extractAnswerText(parsed, opts.schema !== undefined);
|
|
1030
1239
|
return { run: { code: 0, stdout: text, stderr: "", timedOut: false }, relay };
|
|
1031
1240
|
}
|
|
1032
|
-
async awaitOrPoll(jobId, settled, waitMs,
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1241
|
+
async awaitOrPoll(jobId, settled, waitMs, opts = {}) {
|
|
1242
|
+
const { clamp, progressToken, signal } = opts;
|
|
1243
|
+
const timers = [];
|
|
1244
|
+
const startedAt = this.now();
|
|
1245
|
+
if (progressToken !== undefined) {
|
|
1246
|
+
const tick = setInterval(() => this.sendProgress(jobId, progressToken, startedAt), PROGRESS_INTERVAL_MS);
|
|
1247
|
+
if (typeof tick.unref === "function")
|
|
1248
|
+
tick.unref();
|
|
1249
|
+
timers.push(tick);
|
|
1250
|
+
}
|
|
1251
|
+
let raced;
|
|
1252
|
+
try {
|
|
1253
|
+
raced = await Promise.race([
|
|
1254
|
+
settled,
|
|
1255
|
+
new Promise((resolve) => {
|
|
1256
|
+
const t = setTimeout(() => resolve("pending"), waitMs);
|
|
1257
|
+
if (typeof t.unref === "function")
|
|
1258
|
+
t.unref();
|
|
1259
|
+
timers.push(t);
|
|
1260
|
+
signal?.addEventListener("abort", () => resolve("pending"), { once: true });
|
|
1261
|
+
}),
|
|
1262
|
+
]);
|
|
1263
|
+
}
|
|
1264
|
+
finally {
|
|
1265
|
+
for (const t of timers)
|
|
1266
|
+
clearTimeout(t);
|
|
1267
|
+
}
|
|
1041
1268
|
const current = this.jobs.get(jobId);
|
|
1042
1269
|
if (!current)
|
|
1043
1270
|
return textResult(`unknown jobId: ${jobId}`, true);
|
|
@@ -1045,12 +1272,32 @@ export class McpDispatchServer {
|
|
|
1045
1272
|
const waitedS = Math.round(waitMs / 1000);
|
|
1046
1273
|
const announcement = clamp === undefined
|
|
1047
1274
|
? ""
|
|
1048
|
-
: `waited ${waitedS} s (waitMs ${clamp.requested} clamped to
|
|
1275
|
+
: `waited ${waitedS} s (waitMs ${clamp.requested} clamped to ${clamp.key} ${clamp.ceiling})\n`;
|
|
1049
1276
|
return textResult(`${describeJob(current, this.now())}\n\n${announcement}Still running after ${waitedS}s. ` +
|
|
1050
1277
|
`Poll dispatch_status with jobId "${jobId}", then call dispatch_result.`);
|
|
1051
1278
|
}
|
|
1052
1279
|
return textResult(jobAnswer(current, this.now()), isFailureStatus(current.status));
|
|
1053
1280
|
}
|
|
1281
|
+
sendProgress(jobId, progressToken, startedAt) {
|
|
1282
|
+
const job = this.jobs.get(jobId);
|
|
1283
|
+
if (!job || job.status !== "running")
|
|
1284
|
+
return;
|
|
1285
|
+
const elapsed = Math.max(1, Math.round((this.now() - startedAt) / 1000));
|
|
1286
|
+
try {
|
|
1287
|
+
this.deps.write(encodeMessage({
|
|
1288
|
+
jsonrpc: "2.0",
|
|
1289
|
+
method: "notifications/progress",
|
|
1290
|
+
params: { progressToken, progress: elapsed, message: `${jobId} running on ${job.laneId} for ${elapsed}s` },
|
|
1291
|
+
}));
|
|
1292
|
+
}
|
|
1293
|
+
catch (e) {
|
|
1294
|
+
logStderr(`progress write failed: ${e.message}`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
export function taskLabel(task) {
|
|
1299
|
+
const line = task.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
1300
|
+
return line.length > 80 ? `${line.slice(0, 77)}...` : line;
|
|
1054
1301
|
}
|
|
1055
1302
|
function extractAnswerText(parsed, hasSchema) {
|
|
1056
1303
|
const message = parsed;
|