llm-relay 0.83.2 → 0.84.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/candidate-runner.js +2 -0
- package/dist/candidate-runner.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +18 -3
- package/dist/cli.js.map +1 -1
- package/dist/config/routing-parser.js +3 -1
- package/dist/config/routing-parser.js.map +1 -1
- package/dist/config-types.d.ts +10 -0
- package/dist/config-types.js.map +1 -1
- package/dist/lane-activity.d.ts +42 -0
- package/dist/lane-activity.js +54 -0
- package/dist/lane-activity.js.map +1 -0
- package/dist/mcp/job-archive.js +0 -1
- package/dist/mcp/job-archive.js.map +1 -1
- package/dist/mcp/lane-runner.d.ts +9 -6
- package/dist/mcp/lane-runner.js +3 -3
- package/dist/mcp/lane-runner.js.map +1 -1
- package/dist/mcp/server.d.ts +42 -50
- package/dist/mcp/server.js +90 -53
- package/dist/mcp/server.js.map +1 -1
- package/dist/routes/admin.js +21 -0
- package/dist/routes/admin.js.map +1 -1
- package/dist/server.js +20 -0
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `llm-relay mcp` server — one dispatch verb, callable from any MCP host.
|
|
3
|
-
*
|
|
4
|
-
* WHAT PROBLEM THIS SOLVES. `llm-relay dispatch --next-command` answers "which lane" correctly and
|
|
5
|
-
* then hands the caller a COMMAND to execute. Two things go wrong with that, and both are measured:
|
|
6
|
-
*
|
|
7
|
-
* 1. The answer's SHAPE depends on the host. A routed Claude Code session gets a `target:` spec to
|
|
8
|
-
* address as a subagent; a bypassed or headless one gets a `run:` command. The caller must
|
|
9
|
-
* branch on mechanism, which is exactly what the owner asked never to be necessary.
|
|
10
|
-
* 2. Executing the command correctly is hard. `lane-runner.ts` lists five distinct measured ways
|
|
11
|
-
* to get it wrong, each of which cost a release or a wasted lane run.
|
|
12
|
-
*
|
|
13
|
-
* This server removes both. Every host makes the same call and receives an ANSWER, not a command.
|
|
14
|
-
*
|
|
15
|
-
* WHERE IT RUNS. As a stdio child of the HOST, launched as `llm-relay mcp`. It is NOT part of the
|
|
16
|
-
* relay daemon, and it answers no HTTP. The daemon's rule that no HTTP turn may spawn a lane is
|
|
17
|
-
* therefore untouched — this process is the host's own spawn mechanism wearing a protocol.
|
|
18
|
-
*
|
|
19
|
-
* SEAMS. Every environment dependency is injected (`buildView`, `spawn`, `now`, `cwd`), so the
|
|
20
|
-
* suite exercises the whole surface without spawning a real lane or spending real quota. Same
|
|
21
|
-
* discipline as `lane-quota-probe.ts` and `availability-snapshot.ts`.
|
|
22
|
-
*/
|
|
23
1
|
import type { Config } from "../config.js";
|
|
24
2
|
import type { DispatchView } from "../dispatch.js";
|
|
25
3
|
import { type TreeSnapshotReader } from "./tree-delta.js";
|
|
@@ -43,13 +21,20 @@ export type DispatchViewBuilder = (opts: {
|
|
|
43
21
|
tier: string | undefined;
|
|
44
22
|
lane: string | undefined;
|
|
45
23
|
/**
|
|
46
|
-
* How this dispatch will run its lanes, so the relay hands back
|
|
24
|
+
* How this dispatch will run its lanes, so the relay hands back run times measured in the SAME mode
|
|
47
25
|
* (`DispatchMode`). Absent from `dispatch_lanes`, which runs nothing.
|
|
48
26
|
*/
|
|
49
27
|
mode?: DispatchMode | undefined;
|
|
50
28
|
/** A routing spec to run as its own one-lane view instead of the ladder (`dispatch`'s `model`). */
|
|
51
29
|
model?: string | undefined;
|
|
52
30
|
}) => Promise<DispatchView>;
|
|
31
|
+
/** A lane's traffic as the relay daemon reports it. */
|
|
32
|
+
export interface LaneTraffic {
|
|
33
|
+
/** Requests with the lane's tag the daemon is serving now. */
|
|
34
|
+
inFlight: number;
|
|
35
|
+
/** Epoch ms of the tag's last request start, response write or request end. */
|
|
36
|
+
lastActivityAt: number;
|
|
37
|
+
}
|
|
53
38
|
export interface McpServerDeps {
|
|
54
39
|
config: Config;
|
|
55
40
|
buildView: DispatchViewBuilder;
|
|
@@ -67,6 +52,11 @@ export interface McpServerDeps {
|
|
|
67
52
|
allowedRoots?: readonly string[];
|
|
68
53
|
/** Reads `git status` for the tree delta (`tree-delta.ts`); defaults to the real git reader. */
|
|
69
54
|
treeSnapshot?: TreeSnapshotReader;
|
|
55
|
+
/**
|
|
56
|
+
* Reads a lane's live traffic from the relay daemon (`GET /dispatch/activity`, `lane-activity.ts`).
|
|
57
|
+
* Absent, or null for a tag, means no signal from the daemon — never "idle".
|
|
58
|
+
*/
|
|
59
|
+
readLaneActivity?: (tag: string) => Promise<LaneTraffic | null>;
|
|
70
60
|
/** The platform whose environment rules the lane launcher applies; defaults to `process.platform`. */
|
|
71
61
|
platform?: NodeJS.Platform;
|
|
72
62
|
maxDepth?: number;
|
|
@@ -180,12 +170,13 @@ export declare const LANE_LADDER_EXHAUSTED_ADVICE: string;
|
|
|
180
170
|
*/
|
|
181
171
|
export declare const LANE_LADDER_PARTIAL_ADVICE: string;
|
|
182
172
|
/**
|
|
183
|
-
* What a caller is told when the walk STOPPED
|
|
173
|
+
* What a caller is told when the walk STOPPED an idle lane and nothing answered after.
|
|
184
174
|
*
|
|
185
|
-
* ⚠ The stopped lane did not fail
|
|
186
|
-
* lane has now been tried"
|
|
187
|
-
*
|
|
188
|
-
*
|
|
175
|
+
* ⚠ The stopped lane did not fail on its own: the walk stopped it because it showed no activity the
|
|
176
|
+
* relay could see. So "every dispatch lane has now been tried" is false there, and would end the
|
|
177
|
+
* caller's use of dispatch for a task the lane might still finish
|
|
178
|
+
* (`docs/dispatch-giveup-diagnosis-2026-09-10.md` §5). A NAMED lane is never stopped for idleness,
|
|
179
|
+
* so this names the call that lets it run to its own timeout.
|
|
189
180
|
*/
|
|
190
181
|
export declare function laneStoppedAdvice(laneId: string): string;
|
|
191
182
|
/**
|
|
@@ -194,11 +185,8 @@ export declare function laneStoppedAdvice(laneId: string): string;
|
|
|
194
185
|
* jobs 0023 and 0024, which each ran one forced lane and were told to stop delegating.
|
|
195
186
|
*/
|
|
196
187
|
export declare const FORCED_LANE_ADVICE: string;
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
* how long each extension lasts.
|
|
200
|
-
*/
|
|
201
|
-
export declare const LANE_ACTIVITY_WINDOW_MS = 60000;
|
|
188
|
+
/** How often the walk checks a running lane for activity. */
|
|
189
|
+
export declare const IDLE_POLL_MS = 15000;
|
|
202
190
|
/**
|
|
203
191
|
* MCP clients (`initialize` `clientInfo.name`) measured to survive a long tool call, so `dispatch`
|
|
204
192
|
* may wait for the answer the way the host's own subagent does, up to `routing.mcp.blockingWaitMs`.
|
|
@@ -236,6 +224,8 @@ export declare class McpDispatchServer {
|
|
|
236
224
|
private readonly cancellable;
|
|
237
225
|
/** The `git status` each running agent-mode job started from, with the caller's scope. */
|
|
238
226
|
private readonly trees;
|
|
227
|
+
/** The activity tag (`lane-activity.ts`) of the lane each running job has started last. */
|
|
228
|
+
private readonly activityTags;
|
|
239
229
|
constructor(deps: McpServerDeps);
|
|
240
230
|
/**
|
|
241
231
|
* Feed raw stdin bytes. Complete messages are handled concurrently; a partial tail is carried
|
|
@@ -321,7 +311,7 @@ export declare class McpDispatchServer {
|
|
|
321
311
|
*
|
|
322
312
|
* `view.order` is the ONE definition of selection order (`dispatch.ts`). With the walk turned off
|
|
323
313
|
* — or with a view that carries no order — this collapses to EXACTLY the pre-walk behaviour: the
|
|
324
|
-
* single lane the view named as `next`, tried once,
|
|
314
|
+
* single lane the view named as `next`, tried once, and never stopped for idleness.
|
|
325
315
|
*
|
|
326
316
|
* ⚠ The `Array.isArray` test is a VERSION SKEW guard, not defensive noise. `buildView` reaches the
|
|
327
317
|
* running daemon over HTTP, and a daemon started before this field existed answers without it — an
|
|
@@ -364,15 +354,15 @@ export declare class McpDispatchServer {
|
|
|
364
354
|
/** The snapshot reader, contained: a throwing injected reader reads as "no git tree". */
|
|
365
355
|
private readTree;
|
|
366
356
|
/**
|
|
367
|
-
*
|
|
368
|
-
* to move to.
|
|
369
|
-
* `LANE_UNRELIABLE_STREAK` own failures or more, or marked `failing`. Stopping a lane
|
|
370
|
-
* still answer in order to reach those trades an answer for a near-certain failure —
|
|
371
|
-
* 2026-09-10, when the walk stopped `free-pool`
|
|
357
|
+
* Is the lane at position `i` never stopped, even when idle? The last lane is not — there is
|
|
358
|
+
* nowhere to move to. Nor is a lane whose later lanes are all unlikely to answer: each is on a
|
|
359
|
+
* streak of `LANE_UNRELIABLE_STREAK` own failures or more, or marked `failing`. Stopping a lane
|
|
360
|
+
* that may still answer in order to reach those trades an answer for a near-certain failure —
|
|
361
|
+
* measured 2026-09-10, when the walk stopped `free-pool` to try lanes that had answered 0 of 12,
|
|
372
362
|
* 0 of 34 and 0 of 21 runs (`docs/dispatch-giveup-diagnosis-2026-09-10.md` §1). A lane with no
|
|
373
363
|
* record counts as reliable: unmeasured is no opinion, never a failure.
|
|
374
364
|
*/
|
|
375
|
-
private
|
|
365
|
+
private stopWithheld;
|
|
376
366
|
/**
|
|
377
367
|
* A `cli` rung whose configured `maxConcurrent` this MCP server process has already reached for
|
|
378
368
|
* — SKIP it for this walk rather than starting a competing process. Records the skip as an
|
|
@@ -397,13 +387,14 @@ export declare class McpDispatchServer {
|
|
|
397
387
|
*/
|
|
398
388
|
private skipIfBelowTier;
|
|
399
389
|
/**
|
|
400
|
-
*
|
|
401
|
-
* lane
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
390
|
+
* The running lane's most recent ACTIVITY, or null when nothing was seen. Evidence: a request with
|
|
391
|
+
* the lane's tag in flight at the relay daemon (that is activity NOW), the daemon's last traffic
|
|
392
|
+
* for the tag, the lane's own last output, and its git working tree (a change since the last
|
|
393
|
+
* reading is activity now; otherwise the newest dirty file's mtime). `claude -p` holds its output
|
|
394
|
+
* until exit, so for a pool lane the daemon's traffic is the signal that matters. The tree is read
|
|
395
|
+
* only when nothing newer than one poll was seen, because each reading runs `git status`.
|
|
405
396
|
*/
|
|
406
|
-
private
|
|
397
|
+
private latestActivity;
|
|
407
398
|
/**
|
|
408
399
|
* For a read-only dispatch, the invocation this lane will be spawned with — its own CLI's
|
|
409
400
|
* read-only tool binding (`readOnlyInvoke`) — or a SKIP when its CLI offers none. Recorded exactly
|
|
@@ -417,11 +408,12 @@ export declare class McpDispatchServer {
|
|
|
417
408
|
*/
|
|
418
409
|
private bindReadOnlyOrSkip;
|
|
419
410
|
/**
|
|
420
|
-
* Run ONE lane
|
|
411
|
+
* Run ONE lane. Returns what happened; never throws.
|
|
421
412
|
*
|
|
422
|
-
* With `
|
|
423
|
-
*
|
|
424
|
-
* `abandoned`.
|
|
413
|
+
* With `idleMs === null` the lane is simply awaited — its own `timeoutMs` is the only bound.
|
|
414
|
+
* Otherwise the walk checks the lane every `IDLE_POLL_MS`, and when it has shown no activity
|
|
415
|
+
* (`latestActivity`) for `idleMs`, the lane is KILLED and reported as `abandoned`. How long the
|
|
416
|
+
* lane has run does not matter: a slow lane that still works is never stopped.
|
|
425
417
|
*
|
|
426
418
|
* ⚠ It kills rather than hedges. The HTTP request path hedges — it starts a second attempt
|
|
427
419
|
* beside the first — and that is deliberate there, bounded to free deployments by an owner
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { LANE_ACTIVITY_HEADER } from "../lane-activity.js";
|
|
1
3
|
import { DEFAULT_MCP_BLOCKING_WAIT_MS, DEFAULT_MCP_MAX_WAIT_MS, EFFORT_LEVELS } from "../config-types.js";
|
|
2
4
|
import { isToolUseBlock } from "../anthropic.js";
|
|
3
|
-
import {
|
|
5
|
+
import { formatLaneStats, isPassThroughSpec, LANE_UNRELIABLE_STREAK, mcpPassThroughReason } from "../dispatch.js";
|
|
4
6
|
import { laneOfRung } from "../lane-manifest.js";
|
|
5
7
|
import { defaultTreeSnapshot, newestChangeMs, renderTreeDelta, sameTree, } from "./tree-delta.js";
|
|
6
8
|
import { estimateTokensFromCharacters } from "../metadata.js";
|
|
@@ -29,7 +31,7 @@ export const MCP_INSTRUCTIONS = "Use dispatch from every MCP host — Claude, Co
|
|
|
29
31
|
"Desktop rejects that child model before reaching the relay and ignores its custom provider. " +
|
|
30
32
|
"Call dispatch instead. If this MCP server is unavailable, run `llm-relay dispatch " +
|
|
31
33
|
"--next-command -t <task>` and follow its returned command or target; do not guess from the host.\n\n" +
|
|
32
|
-
"Let dispatch choose the lane. If a lane
|
|
34
|
+
"Let dispatch choose the lane. If a lane stops showing activity, dispatch moves to the next one " +
|
|
33
35
|
"itself and prefers the lane that answered on your next call. Follow the advice at the end of a " +
|
|
34
36
|
"reply: when it says a lane was stopped while it was still working, dispatch again with that " +
|
|
35
37
|
"lane named so it can finish; when it says every lane ran and failed, do the work here — " +
|
|
@@ -51,8 +53,8 @@ const TOOLS = [
|
|
|
51
53
|
"default lane spends none of this session's subscription quota and saves its context. " +
|
|
52
54
|
"Pass model to run one specific model (a routing spec such as deepseek/deepseek-flash or " +
|
|
53
55
|
"pool/high) as its own lane, with no walk. Otherwise it picks the lane from " +
|
|
54
|
-
"the configured ladder unless you name one, and WALKS that ladder for you: a lane that
|
|
55
|
-
"
|
|
56
|
+
"the configured ladder unless you name one, and WALKS that ladder for you: a lane that shows " +
|
|
57
|
+
"no activity for a while is stopped and the next one is started, and the lane that " +
|
|
56
58
|
"answers is preferred next time. Runs the lane correctly — working directory, " +
|
|
57
59
|
"environment and idle timeouts are handled here, so you never build a command line. If the " +
|
|
58
60
|
"lane is still running, returns a jobId to poll with dispatch_status — it blocks at most " +
|
|
@@ -336,8 +338,10 @@ function describeJob(job, now) {
|
|
|
336
338
|
head.push(`read-only: ${job.readOnly.binding}`);
|
|
337
339
|
if (job.launch?.length)
|
|
338
340
|
head.push(`launch: ${job.launch.join("; ")}`);
|
|
339
|
-
if (job.
|
|
340
|
-
|
|
341
|
+
if (job.status === "running" && job.lastActive) {
|
|
342
|
+
const ago = Math.max(0, Math.round((now - job.lastActive.at) / 1000));
|
|
343
|
+
head.push(`last activity: ${ago}s ago (${job.lastActive.source})`);
|
|
344
|
+
}
|
|
341
345
|
const usually = runningTimeToAnswer(job);
|
|
342
346
|
if (usually !== null)
|
|
343
347
|
head.push(usually);
|
|
@@ -358,9 +362,9 @@ export const LANE_LADDER_PARTIAL_ADVICE = "Lanes remain untried: this dispatch s
|
|
|
358
362
|
+ "reach them — the lanes above are now demoted, so it will pick different ones — or do the "
|
|
359
363
|
+ "work in this session.";
|
|
360
364
|
export function laneStoppedAdvice(laneId) {
|
|
361
|
-
return (`The walk stopped lane "${laneId}"
|
|
362
|
-
`
|
|
363
|
-
"
|
|
365
|
+
return (`The walk stopped lane "${laneId}" because it showed no activity for a while — it did not fail ` +
|
|
366
|
+
`on its own. To let it run to its own timeout, call dispatch again with lane: "${laneId}" and the ` +
|
|
367
|
+
"same tier and mode (a named lane is never stopped for idleness), or do the work in this session.");
|
|
364
368
|
}
|
|
365
369
|
export const FORCED_LANE_ADVICE = "Only the lane you named was tried, and it did not answer. Call dispatch without lane or model to "
|
|
366
370
|
+ "let the walk try the other lanes, or do the work in this session.";
|
|
@@ -428,12 +432,12 @@ function failedOutcome(message) {
|
|
|
428
432
|
semanticFailure: message,
|
|
429
433
|
};
|
|
430
434
|
}
|
|
431
|
-
function attemptReason(status, outcome,
|
|
435
|
+
function attemptReason(status, outcome, idleMs) {
|
|
432
436
|
if (status === "completed")
|
|
433
437
|
return undefined;
|
|
434
438
|
if (status === "abandoned") {
|
|
435
|
-
const
|
|
436
|
-
return `no
|
|
439
|
+
const quiet = idleMs === null ? "a long time" : `${Math.round(idleMs / 1000)}s`;
|
|
440
|
+
return `no activity for ${quiet} (no relay traffic, output or file change), so the next lane was started`;
|
|
437
441
|
}
|
|
438
442
|
if (outcome.semanticFailure !== undefined)
|
|
439
443
|
return outcome.semanticFailure;
|
|
@@ -451,10 +455,26 @@ function flagValue(args, flag) {
|
|
|
451
455
|
const joined = args.find((a) => a.startsWith(`${flag}=`));
|
|
452
456
|
return joined === undefined ? null : joined.slice(flag.length + 1);
|
|
453
457
|
}
|
|
454
|
-
export const
|
|
455
|
-
function
|
|
458
|
+
export const IDLE_POLL_MS = 15_000;
|
|
459
|
+
function withActivityHeader(existing, tag) {
|
|
460
|
+
const line = `${LANE_ACTIVITY_HEADER}: ${tag}`;
|
|
461
|
+
return existing === undefined || existing.trim() === "" ? line : `${existing}\n${line}`;
|
|
462
|
+
}
|
|
463
|
+
function newestActivity(seen) {
|
|
464
|
+
let best = null;
|
|
465
|
+
for (const s of seen) {
|
|
466
|
+
if (typeof s.at === "number" && Number.isFinite(s.at) && (best === null || s.at > best.at)) {
|
|
467
|
+
best = { at: s.at, source: s.source };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return best;
|
|
471
|
+
}
|
|
472
|
+
function newActivityTag() {
|
|
473
|
+
return randomUUID().replaceAll("-", "");
|
|
474
|
+
}
|
|
475
|
+
function pollTimer(ms) {
|
|
456
476
|
return new Promise((resolve) => {
|
|
457
|
-
const t = setTimeout(() => resolve({ kind: "
|
|
477
|
+
const t = setTimeout(() => resolve({ kind: "poll" }), ms);
|
|
458
478
|
if (typeof t.unref === "function")
|
|
459
479
|
t.unref();
|
|
460
480
|
});
|
|
@@ -484,8 +504,6 @@ function laneSummary(lane, inFlight) {
|
|
|
484
504
|
bits.push(`unreachable: ${lane.unreachable}`);
|
|
485
505
|
if (lane.note)
|
|
486
506
|
bits.push(lane.note);
|
|
487
|
-
if (lane.attemptBudget)
|
|
488
|
-
bits.push(formatAttemptBudget(lane.attemptBudget));
|
|
489
507
|
bits.push(...laneEvidenceBits(lane));
|
|
490
508
|
if (lane.maxConcurrent !== null && lane.maxConcurrent !== undefined) {
|
|
491
509
|
bits.push(`in flight: ${inFlight} of ${lane.maxConcurrent}`);
|
|
@@ -516,6 +534,7 @@ export class McpDispatchServer {
|
|
|
516
534
|
clientName;
|
|
517
535
|
cancellable = new Map();
|
|
518
536
|
trees = new Map();
|
|
537
|
+
activityTags = new Map();
|
|
519
538
|
constructor(deps) {
|
|
520
539
|
this.deps = deps;
|
|
521
540
|
this.jobs = new LaneJobStore(deps.journal ?? nullJobJournal, deps.archive ?? nullJobArchive);
|
|
@@ -685,7 +704,11 @@ export class McpDispatchServer {
|
|
|
685
704
|
}
|
|
686
705
|
const lines = view.ladder.map((l) => laneSummary(l, this.jobs.inFlight(l.id)));
|
|
687
706
|
const next = view.next ? `\n\nnext: ${view.next.id} — ${view.reason}` : `\n\nnext: none — ${view.reason}`;
|
|
688
|
-
|
|
707
|
+
const walk = this.deps.config.routing?.dispatchWalk;
|
|
708
|
+
const rule = walk?.enabled === true
|
|
709
|
+
? `\nwalk: a lane is stopped after ${Math.round(walk.idleMs / 1000)}s with no activity (relay traffic, output or file change); the last lane is never stopped`
|
|
710
|
+
: "\nwalk: off — only the first lane runs";
|
|
711
|
+
return textResult(`tier: ${view.tier ?? "default"}${rule}\n\n${lines.join("\n")}${next}`);
|
|
689
712
|
}
|
|
690
713
|
toolStatus(args) {
|
|
691
714
|
const jobId = readString(args, "jobId");
|
|
@@ -793,7 +816,7 @@ export class McpDispatchServer {
|
|
|
793
816
|
scope: readStringArray(args, "scope"),
|
|
794
817
|
tier: view.tier ?? undefined,
|
|
795
818
|
timeoutMs: readNumber(args, "timeoutMs") ?? DEFAULT_LANE_TIMEOUT_MS,
|
|
796
|
-
|
|
819
|
+
idleMs: walk !== null ? walk.idleMs : null,
|
|
797
820
|
system: readString(args, "system"),
|
|
798
821
|
schema: readRecord(args, "schema"),
|
|
799
822
|
maxTokens: readNumber(args, "maxTokens"),
|
|
@@ -817,11 +840,13 @@ export class McpDispatchServer {
|
|
|
817
840
|
const waitMs = resolveWaitMs(args["waitMs"], ceiling);
|
|
818
841
|
if ("refusal" in waitMs)
|
|
819
842
|
return textResult(waitMs.refusal, true);
|
|
820
|
-
const settled = this.runWalk(job.id, view, ordered, task, opts)
|
|
843
|
+
const settled = this.runWalk(job.id, view, ordered, task, opts)
|
|
844
|
+
.catch(async (e) => {
|
|
821
845
|
await this.recordTreeDelta(job.id);
|
|
822
846
|
this.jobs.fail(job.id, `dispatch walk failed: ${e.message}`);
|
|
823
847
|
return "done";
|
|
824
|
-
})
|
|
848
|
+
})
|
|
849
|
+
.finally(() => this.activityTags.delete(job.id));
|
|
825
850
|
return this.awaitOrPoll(job.id, settled, waitMs.waitMs, {
|
|
826
851
|
clamp: waitMs.clamped ? { requested: waitMs.requested, ceiling, key: ceilingKey } : undefined,
|
|
827
852
|
progressToken: blocking?.progress === true ? ctx.progressToken : undefined,
|
|
@@ -862,10 +887,9 @@ export class McpDispatchServer {
|
|
|
862
887
|
if (readOnly.bound !== undefined)
|
|
863
888
|
this.jobs.noteReadOnly(jobId, lane.id, readOnly.bound.binding);
|
|
864
889
|
const isLast = i === laneIds.length - 1;
|
|
865
|
-
const
|
|
890
|
+
const idleMs = this.stopWithheld(view, laneIds, i) ? null : opts.idleMs;
|
|
866
891
|
const startedAt = this.now();
|
|
867
|
-
const
|
|
868
|
-
const outcome = await this.runOneLane(jobId, lane, task, opts, noBudget ? null : budgetMs, readOnly.bound?.invoke);
|
|
892
|
+
const outcome = await this.runOneLane(jobId, lane, task, opts, idleMs, readOnly.bound?.invoke);
|
|
869
893
|
const elapsedMs = Math.max(0, this.now() - startedAt);
|
|
870
894
|
if (this.jobs.get(jobId)?.status === "cancelled")
|
|
871
895
|
return "done";
|
|
@@ -878,7 +902,7 @@ export class McpDispatchServer {
|
|
|
878
902
|
abandoned: outcome.abandoned,
|
|
879
903
|
semanticFailure: outcome.semanticFailure,
|
|
880
904
|
});
|
|
881
|
-
const reason = attemptReason(status, outcome,
|
|
905
|
+
const reason = attemptReason(status, outcome, idleMs);
|
|
882
906
|
this.jobs.recordAttempt(jobId, {
|
|
883
907
|
laneId: lane.id,
|
|
884
908
|
spec: lane.spec,
|
|
@@ -936,7 +960,7 @@ export class McpDispatchServer {
|
|
|
936
960
|
return null;
|
|
937
961
|
}
|
|
938
962
|
}
|
|
939
|
-
|
|
963
|
+
stopWithheld(view, laneIds, i) {
|
|
940
964
|
return !laneIds.slice(i + 1).some((id) => {
|
|
941
965
|
const later = view.ladder.find((l) => l.id === id);
|
|
942
966
|
return later !== undefined && later.failing === undefined && (later.recentFailures ?? 0) < LANE_UNRELIABLE_STREAK;
|
|
@@ -980,27 +1004,31 @@ export class McpDispatchServer {
|
|
|
980
1004
|
this.jobs.noteSkippedLane(jobId);
|
|
981
1005
|
return true;
|
|
982
1006
|
}
|
|
983
|
-
async
|
|
1007
|
+
async latestActivity(jobId) {
|
|
984
1008
|
const now = this.now();
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
1009
|
+
const seen = [
|
|
1010
|
+
{ at: this.jobs.get(jobId)?.activity?.lastOutputAt, source: "lane output" },
|
|
1011
|
+
];
|
|
1012
|
+
const tag = this.activityTags.get(jobId);
|
|
1013
|
+
if (tag !== undefined && this.deps.readLaneActivity !== undefined) {
|
|
1014
|
+
const traffic = await this.deps.readLaneActivity(tag).catch(() => null);
|
|
1015
|
+
seen.push(traffic !== null && traffic.inFlight > 0
|
|
1016
|
+
? { at: now, source: "a request in flight at the relay" }
|
|
1017
|
+
: { at: traffic?.lastActivityAt, source: "relay traffic" });
|
|
988
1018
|
}
|
|
1019
|
+
const early = newestActivity(seen);
|
|
1020
|
+
if (early !== null && now - early.at < IDLE_POLL_MS)
|
|
1021
|
+
return early;
|
|
989
1022
|
const tree = this.trees.get(jobId);
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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`;
|
|
1023
|
+
const reading = tree === undefined ? null : await this.readTree(tree.cwd);
|
|
1024
|
+
if (tree !== undefined && reading !== null) {
|
|
1025
|
+
const previous = tree.lastSeen ?? tree.before;
|
|
1026
|
+
tree.lastSeen = reading;
|
|
1027
|
+
seen.push(sameTree(previous, reading)
|
|
1028
|
+
? { at: newestChangeMs(tree.cwd, reading), source: "a file change" }
|
|
1029
|
+
: { at: now, source: "a working tree change" });
|
|
1002
1030
|
}
|
|
1003
|
-
return
|
|
1031
|
+
return newestActivity(seen);
|
|
1004
1032
|
}
|
|
1005
1033
|
bindReadOnlyOrSkip(jobId, lane, opts) {
|
|
1006
1034
|
if (!opts.readOnly)
|
|
@@ -1022,7 +1050,8 @@ export class McpDispatchServer {
|
|
|
1022
1050
|
this.jobs.noteSkippedLane(jobId);
|
|
1023
1051
|
return { skipped: true };
|
|
1024
1052
|
}
|
|
1025
|
-
async runOneLane(jobId, lane, task, opts,
|
|
1053
|
+
async runOneLane(jobId, lane, task, opts, idleMs, invoke) {
|
|
1054
|
+
const attemptStart = this.now();
|
|
1026
1055
|
const started = this.startLane(jobId, lane, task, opts, invoke);
|
|
1027
1056
|
if ("refusal" in started)
|
|
1028
1057
|
return { run: emptyRun(), abandoned: false, refusal: started.refusal };
|
|
@@ -1032,21 +1061,23 @@ export class McpDispatchServer {
|
|
|
1032
1061
|
abandoned: false,
|
|
1033
1062
|
semanticFailure: e.message,
|
|
1034
1063
|
}));
|
|
1035
|
-
if (
|
|
1064
|
+
if (idleMs === null)
|
|
1036
1065
|
return guarded;
|
|
1037
1066
|
const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
|
|
1038
|
-
let
|
|
1067
|
+
let lastActive = attemptStart;
|
|
1039
1068
|
for (;;) {
|
|
1040
|
-
const raced = await Promise.race([settled,
|
|
1069
|
+
const raced = await Promise.race([settled, pollTimer(Math.min(IDLE_POLL_MS, idleMs))]);
|
|
1041
1070
|
if (raced.kind === "settled")
|
|
1042
1071
|
return raced.outcome;
|
|
1043
1072
|
if (this.jobs.get(jobId)?.status !== "running")
|
|
1044
1073
|
break;
|
|
1045
|
-
const
|
|
1046
|
-
if (
|
|
1074
|
+
const seen = await this.latestActivity(jobId);
|
|
1075
|
+
if (seen !== null && seen.at > lastActive) {
|
|
1076
|
+
lastActive = seen.at;
|
|
1077
|
+
this.jobs.noteLastActivity(jobId, seen.at, seen.source);
|
|
1078
|
+
}
|
|
1079
|
+
if (this.now() - lastActive >= idleMs)
|
|
1047
1080
|
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
1081
|
}
|
|
1051
1082
|
started.kill();
|
|
1052
1083
|
return { run: emptyRun(), abandoned: true };
|
|
@@ -1061,7 +1092,9 @@ export class McpDispatchServer {
|
|
|
1061
1092
|
}
|
|
1062
1093
|
const controller = new AbortController();
|
|
1063
1094
|
const signal = AbortSignal.any([AbortSignal.timeout(opts.timeoutMs), controller.signal]);
|
|
1064
|
-
const
|
|
1095
|
+
const tag = newActivityTag();
|
|
1096
|
+
this.activityTags.set(jobId, tag);
|
|
1097
|
+
const result = this.runAnswerFetch(lane.spec, task, opts, signal, tag).then((o) => {
|
|
1065
1098
|
const semanticFailure = o.semanticFailure === undefined && !o.run.timedOut && isContentEmpty(o.run.stdout)
|
|
1066
1099
|
? EMPTY_OUTPUT_REASON
|
|
1067
1100
|
: o.semanticFailure;
|
|
@@ -1093,6 +1126,9 @@ export class McpDispatchServer {
|
|
|
1093
1126
|
const expansion = expandEnvReferences(applyLaneEnv(process.env, invoke.env), this.deps.platform);
|
|
1094
1127
|
const env = expansion.env;
|
|
1095
1128
|
env[DEPTH_ENV] = String(opts.depth + 1);
|
|
1129
|
+
const tag = newActivityTag();
|
|
1130
|
+
this.activityTags.set(jobId, tag);
|
|
1131
|
+
env["ANTHROPIC_CUSTOM_HEADERS"] = withActivityHeader(env["ANTHROPIC_CUSTOM_HEADERS"], tag);
|
|
1096
1132
|
this.jobs.noteLaunch(jobId, [...expansion.notes, ...(agy === null ? [] : [agy.note])]);
|
|
1097
1133
|
const startedAt = this.now();
|
|
1098
1134
|
this.jobs.beginAttemptActivity(jobId, startedAt);
|
|
@@ -1186,7 +1222,7 @@ export class McpDispatchServer {
|
|
|
1186
1222
|
});
|
|
1187
1223
|
}
|
|
1188
1224
|
}
|
|
1189
|
-
async runAnswerFetch(spec, task, opts, signal) {
|
|
1225
|
+
async runAnswerFetch(spec, task, opts, signal, activityTag) {
|
|
1190
1226
|
const url = relayLoopbackUrl(this.deps.config, "/v1/messages");
|
|
1191
1227
|
const body = {
|
|
1192
1228
|
model: spec,
|
|
@@ -1207,6 +1243,7 @@ export class McpDispatchServer {
|
|
|
1207
1243
|
"anthropic-version": "2023-06-01",
|
|
1208
1244
|
"content-type": "application/json",
|
|
1209
1245
|
"x-api-key": "dummy",
|
|
1246
|
+
[LANE_ACTIVITY_HEADER]: activityTag,
|
|
1210
1247
|
},
|
|
1211
1248
|
body: JSON.stringify(body),
|
|
1212
1249
|
signal,
|