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.
@@ -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 budgets measured in the SAME mode
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 a lane at its time budget and nothing answered after.
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: it was still working when the walk moved on. So "every dispatch
186
- * lane has now been tried" was false there, and it ended the caller's use of dispatch for a task the
187
- * stopped lane would have finished (`docs/dispatch-giveup-diagnosis-2026-09-10.md` §5). A NAMED lane
188
- * runs with no budget, so this names the call that lets it finish.
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
- * How recent a lane's output or file change must be for the walk to keep it past its budget, and
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, with no attempt budget.
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
- * Does the lane at position `i` run with NO attempt budget? The last lane does — there is nowhere
368
- * to move to. So does a lane whose later lanes are all unlikely to answer: each is on a streak of
369
- * `LANE_UNRELIABLE_STREAK` own failures or more, or marked `failing`. Stopping a lane that may
370
- * still answer in order to reach those trades an answer for a near-certain failure — measured
371
- * 2026-09-10, when the walk stopped `free-pool` at 90 s to try lanes that had answered 0 of 12,
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 budgetWithheld;
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
- * Is the running lane still WORKING? A reason string when it is, else null. Evidence, in order: the
401
- * lane wrote output inside `LANE_ACTIVITY_WINDOW_MS`; its git tree changed since the last reading;
402
- * a dirty file changed inside the window. `claude -p` buffers its whole answer until exit, so for
403
- * the free pool the tree is the only evidence there is. No evidence (answer mode, a cwd outside a
404
- * git tree) means not working, and the budget applies as before.
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 laneStillWorking;
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, optionally bounded by an attempt budget. Returns what happened; never throws.
411
+ * Run ONE lane. Returns what happened; never throws.
421
412
  *
422
- * With `budgetMs === null` the lane is simply awaited — its own `timeoutMs` is the only bound.
423
- * With a budget, the lane races a timer: if the timer wins, the lane is KILLED and reported as
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
@@ -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 { formatAttemptBudget, formatLaneStats, isPassThroughSpec, LANE_UNRELIABLE_STREAK, mcpPassThroughReason } from "../dispatch.js";
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 is slow or silent, dispatch moves to the next one " +
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 does " +
55
- "not answer inside its budget is stopped and the next one is started, and the lane that " +
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.extended?.length)
340
- head.push(`extended: ${job.extended.join("; ")}`);
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}" at its time budget while it was still working — it did not ` +
362
- `fail. To let it finish, call dispatch again with lane: "${laneId}" and the same tier and mode (a ` +
363
- "named lane runs with no budget, only its own timeout), or do the work in this session.");
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, elapsedMs, budgetMs) {
435
+ function attemptReason(status, outcome, idleMs) {
432
436
  if (status === "completed")
433
437
  return undefined;
434
438
  if (status === "abandoned") {
435
- const budget = budgetMs === null ? `${Math.round(elapsedMs / 1000)}s` : `${Math.round(budgetMs / 1000)}s`;
436
- return `no answer within the ${budget} walk budget, so the next lane was started`;
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 LANE_ACTIVITY_WINDOW_MS = 60_000;
455
- function budgetTimer(ms) {
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: "budget" }), ms);
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
- return textResult(`tier: ${view.tier ?? "default"}\n\n${lines.join("\n")}${next}`);
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
- attemptMs: walk !== null ? walk.attemptMs : null,
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).catch(async (e) => {
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 noBudget = this.budgetWithheld(view, laneIds, i);
890
+ const idleMs = this.stopWithheld(view, laneIds, i) ? null : opts.idleMs;
866
891
  const startedAt = this.now();
867
- const budgetMs = lane.attemptBudget?.ms ?? opts.attemptMs;
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, elapsedMs, noBudget ? null : budgetMs);
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
- budgetWithheld(view, laneIds, i) {
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 laneStillWorking(jobId) {
1007
+ async latestActivity(jobId) {
984
1008
  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`;
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
- 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`;
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 null;
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, budgetMs, invoke) {
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 (budgetMs === null)
1064
+ if (idleMs === null)
1036
1065
  return guarded;
1037
1066
  const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
1038
- let waitMs = budgetMs;
1067
+ let lastActive = attemptStart;
1039
1068
  for (;;) {
1040
- const raced = await Promise.race([settled, budgetTimer(waitMs)]);
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 working = await this.laneStillWorking(jobId);
1046
- if (working === null)
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 result = this.runAnswerFetch(lane.spec, task, opts, signal).then((o) => {
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,