llm-relay 0.85.0 → 0.86.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.
Files changed (185) hide show
  1. package/README.md +39 -62
  2. package/THIRD_PARTY_NOTICES.md +5 -4
  3. package/dist/accounting-state.js.map +1 -1
  4. package/dist/accounting-store-schema.js.map +1 -1
  5. package/dist/accounting-store.js.map +1 -1
  6. package/dist/accounting.js.map +1 -1
  7. package/dist/authEnv.js.map +1 -1
  8. package/dist/availability-snapshot.js.map +1 -1
  9. package/dist/availability.js.map +1 -1
  10. package/dist/backend.js.map +1 -1
  11. package/dist/benchmarks.js.map +1 -1
  12. package/dist/breaker-persistence.js.map +1 -1
  13. package/dist/candidate-runner.d.ts +6 -12
  14. package/dist/candidate-runner.js +16 -4
  15. package/dist/candidate-runner.js.map +1 -1
  16. package/dist/candidates.js.map +1 -1
  17. package/dist/catalog.js.map +1 -1
  18. package/dist/circuit-breaker.d.ts +14 -17
  19. package/dist/circuit-breaker.js +46 -6
  20. package/dist/circuit-breaker.js.map +1 -1
  21. package/dist/claude-hook.js.map +1 -1
  22. package/dist/cli.d.ts +4 -1
  23. package/dist/cli.js +127 -12
  24. package/dist/cli.js.map +1 -1
  25. package/dist/config/routing-parser.d.ts +3 -13
  26. package/dist/config/routing-parser.js +16 -10
  27. package/dist/config/routing-parser.js.map +1 -1
  28. package/dist/config-reload.d.ts +34 -0
  29. package/dist/config-reload.js +180 -0
  30. package/dist/config-reload.js.map +1 -0
  31. package/dist/config-types.d.ts +26 -65
  32. package/dist/config-types.js.map +1 -1
  33. package/dist/config.d.ts +1 -1
  34. package/dist/config.js +1 -1
  35. package/dist/config.js.map +1 -1
  36. package/dist/configured-lane-execution-launcher.d.ts +36 -0
  37. package/dist/configured-lane-execution-launcher.js +133 -0
  38. package/dist/configured-lane-execution-launcher.js.map +1 -0
  39. package/dist/context-limits.js.map +1 -1
  40. package/dist/control-authorization.js.map +1 -1
  41. package/dist/cooldown-clear.js.map +1 -1
  42. package/dist/credential-fleet.js.map +1 -1
  43. package/dist/credential-select.js.map +1 -1
  44. package/dist/daemon-dispatch-view.d.ts +21 -0
  45. package/dist/daemon-dispatch-view.js +16 -0
  46. package/dist/daemon-dispatch-view.js.map +1 -0
  47. package/dist/dashboard/.vite/dashboard-bundle-graph.json +2 -1
  48. package/dist/dashboard/.vite/manifest.json +2 -2
  49. package/dist/dashboard/assets/index-BoSmrF3q.js +123 -0
  50. package/dist/dashboard/assets/index-DKPeE07T.css +1 -0
  51. package/dist/dashboard/index.html +2 -2
  52. package/dist/dashboard-auth.js.map +1 -1
  53. package/dist/dashboard-contract.d.ts +2 -2
  54. package/dist/dashboard-routes.js.map +1 -1
  55. package/dist/dashboard-snapshot.js.map +1 -1
  56. package/dist/dashboard-static.js.map +1 -1
  57. package/dist/delegate-gate/cli.js.map +1 -1
  58. package/dist/dispatch-exhaustion-persistence.d.ts +1 -0
  59. package/dist/dispatch-exhaustion-persistence.js +7 -2
  60. package/dist/dispatch-exhaustion-persistence.js.map +1 -1
  61. package/dist/dispatch-lane-stats.d.ts +10 -17
  62. package/dist/dispatch-lane-stats.js +0 -5
  63. package/dist/dispatch-lane-stats.js.map +1 -1
  64. package/dist/dispatch.d.ts +32 -78
  65. package/dist/dispatch.js +51 -54
  66. package/dist/dispatch.js.map +1 -1
  67. package/dist/documents.js.map +1 -1
  68. package/dist/dotenv.js.map +1 -1
  69. package/dist/dynamic-pools.js.map +1 -1
  70. package/dist/executable-lookup.d.ts +2 -0
  71. package/dist/executable-lookup.js +7 -4
  72. package/dist/executable-lookup.js.map +1 -1
  73. package/dist/host-routing.js.map +1 -1
  74. package/dist/installed-hosts.js.map +1 -1
  75. package/dist/json-shape.js.map +1 -1
  76. package/dist/key-checker.js.map +1 -1
  77. package/dist/key-import.js.map +1 -1
  78. package/dist/keys-cli.js.map +1 -1
  79. package/dist/keystore.js.map +1 -1
  80. package/dist/lane-activity.d.ts +8 -0
  81. package/dist/lane-activity.js +27 -2
  82. package/dist/lane-activity.js.map +1 -1
  83. package/dist/lane-affinity.d.ts +4 -4
  84. package/dist/lane-affinity.js.map +1 -1
  85. package/dist/lane-cadence.js.map +1 -1
  86. package/dist/lane-execution-broker.d.ts +124 -0
  87. package/dist/lane-execution-broker.js +479 -0
  88. package/dist/lane-execution-broker.js.map +1 -0
  89. package/dist/lane-launch-env.d.ts +14 -0
  90. package/dist/lane-launch-env.js +52 -0
  91. package/dist/lane-launch-env.js.map +1 -0
  92. package/dist/lane-manifest.js.map +1 -1
  93. package/dist/lane-probe.js.map +1 -1
  94. package/dist/lane-quota-probe.js.map +1 -1
  95. package/dist/log.js.map +1 -1
  96. package/dist/mcp/job-archive.d.ts +12 -9
  97. package/dist/mcp/job-archive.js +44 -37
  98. package/dist/mcp/job-archive.js.map +1 -1
  99. package/dist/mcp/job-journal.d.ts +42 -2
  100. package/dist/mcp/job-journal.js +212 -32
  101. package/dist/mcp/job-journal.js.map +1 -1
  102. package/dist/mcp/lane-execution-client.d.ts +26 -0
  103. package/dist/mcp/lane-execution-client.js +117 -0
  104. package/dist/mcp/lane-execution-client.js.map +1 -0
  105. package/dist/mcp/lane-runner.d.ts +140 -25
  106. package/dist/mcp/lane-runner.js +361 -49
  107. package/dist/mcp/lane-runner.js.map +1 -1
  108. package/dist/mcp/persistence-lock.d.ts +13 -0
  109. package/dist/mcp/persistence-lock.js +4 -0
  110. package/dist/mcp/persistence-lock.js.map +1 -0
  111. package/dist/mcp/process-cpu.d.ts +18 -0
  112. package/dist/mcp/process-cpu.js +133 -0
  113. package/dist/mcp/process-cpu.js.map +1 -0
  114. package/dist/mcp/readonly-boundary.js.map +1 -1
  115. package/dist/mcp/server.d.ts +97 -3
  116. package/dist/mcp/server.js +547 -61
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/tree-delta.js.map +1 -1
  119. package/dist/mcp/windows-npm-shim.d.ts +32 -0
  120. package/dist/mcp/windows-npm-shim.js +92 -0
  121. package/dist/mcp/windows-npm-shim.js.map +1 -0
  122. package/dist/metadata.js.map +1 -1
  123. package/dist/openai-dialect.js.map +1 -1
  124. package/dist/openai-request.js.map +1 -1
  125. package/dist/os-keyring.js.map +1 -1
  126. package/dist/ping/cadence.js.map +1 -1
  127. package/dist/ping/metrics.js.map +1 -1
  128. package/dist/ping/ping.js.map +1 -1
  129. package/dist/ping/probe-cache.js.map +1 -1
  130. package/dist/ping/quota.js.map +1 -1
  131. package/dist/ping/runtime-telemetry.js.map +1 -1
  132. package/dist/pool-health.js.map +1 -1
  133. package/dist/process-safety-net.js +18 -10
  134. package/dist/process-safety-net.js.map +1 -1
  135. package/dist/quota-observation.js.map +1 -1
  136. package/dist/rate-limits.js.map +1 -1
  137. package/dist/refusal-interpretation.js.map +1 -1
  138. package/dist/registry.js.map +1 -1
  139. package/dist/request-log.js.map +1 -1
  140. package/dist/reshaper.js.map +1 -1
  141. package/dist/resolved-attempt.js.map +1 -1
  142. package/dist/responses-request.js.map +1 -1
  143. package/dist/routes/admin.d.ts +8 -0
  144. package/dist/routes/admin.js +54 -8
  145. package/dist/routes/admin.js.map +1 -1
  146. package/dist/routes/messages.js +10 -10
  147. package/dist/routes/messages.js.map +1 -1
  148. package/dist/routes/openai-front.js +4 -4
  149. package/dist/routes/openai-front.js.map +1 -1
  150. package/dist/secret-file-acl.js.map +1 -1
  151. package/dist/self-update.js.map +1 -1
  152. package/dist/server.d.ts +19 -0
  153. package/dist/server.js +70 -4
  154. package/dist/server.js.map +1 -1
  155. package/dist/session-pin.js.map +1 -1
  156. package/dist/setup-claude.d.ts +6 -1
  157. package/dist/setup-claude.js +3 -3
  158. package/dist/setup-claude.js.map +1 -1
  159. package/dist/spend-headroom.js.map +1 -1
  160. package/dist/sse.js.map +1 -1
  161. package/dist/state-paths.js.map +1 -1
  162. package/dist/storage/file-lock.d.ts +26 -0
  163. package/dist/storage/file-lock.js +127 -0
  164. package/dist/storage/file-lock.js.map +1 -0
  165. package/dist/storage/json-store.d.ts +33 -0
  166. package/dist/storage/json-store.js +68 -1
  167. package/dist/storage/json-store.js.map +1 -1
  168. package/dist/stream-commit.js.map +1 -1
  169. package/dist/stream-pipeline.js.map +1 -1
  170. package/dist/target-facts.js.map +1 -1
  171. package/dist/telemetry.d.ts +3 -1
  172. package/dist/telemetry.js +2 -1
  173. package/dist/telemetry.js.map +1 -1
  174. package/dist/tier-data.js.map +1 -1
  175. package/dist/tool-dialects.js.map +1 -1
  176. package/dist/usage-observer.js.map +1 -1
  177. package/dist/winenv.js.map +1 -1
  178. package/docs/README.md +4 -2
  179. package/docs/dashboard-bundle-inventory.json +5 -4
  180. package/package.json +15 -14
  181. package/scripts/install-skill.mjs +4 -2
  182. package/skills/llm-relay/SKILL.md +10 -8
  183. package/skills/llm-relay/references/dispatch-lanes.md +18 -1
  184. package/dist/dashboard/assets/index-C-I6hPeY.js +0 -403
  185. package/dist/dashboard/assets/index-CrSNlLMD.css +0 -1
@@ -1,5 +1,6 @@
1
- import { randomUUID } from "node:crypto";
2
- import { LANE_ACTIVITY_HEADER } from "../lane-activity.js";
1
+ import { buildLaneEnv } from "../lane-launch-env.js";
2
+ export { buildLaneEnv, laneCredentialEnvNames } from "../lane-launch-env.js";
3
+ import { createLaneActivityTag, LANE_ACTIVITY_HEADER, withLaneActivityHeader, } from "../lane-activity.js";
3
4
  import { DEFAULT_MCP_BLOCKING_WAIT_MS, DEFAULT_MCP_MAX_WAIT_MS, EFFORT_LEVELS } from "../config-types.js";
4
5
  import { isToolUseBlock } from "../anthropic.js";
5
6
  import { formatLaneStats, isPassThroughSpec, LANE_UNRELIABLE_STREAK, mcpPassThroughReason } from "../dispatch.js";
@@ -11,6 +12,8 @@ import { readOnlyInvoke, readOnlyVerdict } from "./readonly-boundary.js";
11
12
  import { agyQuotaStatement } from "./agy-quota-log.js";
12
13
  import { nullJobJournal } from "./job-journal.js";
13
14
  import { nullJobArchive } from "./job-archive.js";
15
+ import { UNKNOWN_LANE_EXECUTION_MESSAGE } from "../lane-execution-broker.js";
16
+ import { createLaneExecutionId, } from "./lane-execution-client.js";
14
17
  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";
15
18
  export const MCP_SERVER_NAME = "llm-relay";
16
19
  export const DEFAULT_ANSWER_MAX_TOKENS = 4096;
@@ -32,7 +35,11 @@ export const MCP_INSTRUCTIONS = "Use dispatch from every MCP host — Claude, Co
32
35
  "Call dispatch instead. If this MCP server is unavailable, run `llm-relay dispatch " +
33
36
  "--next-command -t <task>` and follow its returned command or target; do not guess from the host.\n\n" +
34
37
  "Let dispatch choose the lane. If a lane stops showing activity, dispatch moves to the next one " +
35
- "itself and prefers the lane that answered on your next call. Follow the advice at the end of a " +
38
+ "itself and prefers the lane that answered on your next call. For a running job, follow " +
39
+ "`walk-verdict`: `keep-running` means continue polling, `no-idle-stop` means the walk will " +
40
+ "not stop this attempt for idleness, and `unavailable` means another MCP process owns the live " +
41
+ "job so you should keep polling rather than infer. Do not infer liveness from elapsed time, output silence, " +
42
+ "historical duration, or activity diagnostics. Follow the advice at the end of a " +
36
43
  "reply: when it says a lane was stopped while it was still working, dispatch again with that " +
37
44
  "lane named so it can finish; when it says every lane ran and failed, do the work here — " +
38
45
  "re-dispatching the same task picks the same lanes. To run one specific model instead of the " +
@@ -153,8 +160,12 @@ const TOOLS = [
153
160
  {
154
161
  name: "dispatch_status",
155
162
  title: "Check a dispatched job",
156
- description: "Report a dispatched job. While it runs: its lane, elapsed time and output so far. Once it " +
157
- "has ended: its full answer, exactly as dispatch_result returns it. Wait at least a few " +
163
+ description: "Report a dispatched job. While it runs, `walk-verdict` is the authoritative liveness " +
164
+ "decision: keep-running means continue polling; no-idle-stop means this attempt is exempt " +
165
+ "from idle stopping; unavailable means another MCP process owns the live job, so keep polling. " +
166
+ "Activity may read advancing while the walk changes lanes; keep-running still means poll. " +
167
+ "Elapsed time, output silence, historical duration and activity diagnostics are diagnostics only. " +
168
+ "Once it has ended: its full answer, exactly as dispatch_result returns it. Wait at least a few " +
158
169
  "seconds between polls. Without jobId: list the recent jobs of every llm-relay MCP server " +
159
170
  "on this machine, so a lost jobId can be found again.",
160
171
  inputSchema: {
@@ -314,6 +325,31 @@ function relayProvenanceLines(job) {
314
325
  lines.push(`degraded: ${relay.degraded}`);
315
326
  return lines;
316
327
  }
328
+ function describeLiveness(job, now) {
329
+ if (job.status !== "running")
330
+ return [];
331
+ const liveness = job.liveness;
332
+ if (liveness === undefined) {
333
+ return ["activity: starting", "walk-verdict: keep-running", "activity-checked: pending"];
334
+ }
335
+ const checkedAgo = Math.max(0, Math.round((now - liveness.checkedAt) / 1000));
336
+ const lines = [
337
+ `activity: ${liveness.activity}`,
338
+ `walk-verdict: ${liveness.verdict}`,
339
+ `activity-checked: ${checkedAgo}s ago`,
340
+ ];
341
+ lines.push(`activity-basis: ${liveness.source}`);
342
+ if (liveness.lastActivityAt !== null) {
343
+ const activityAgeAtCheck = Math.max(0, liveness.checkedAt - liveness.lastActivityAt);
344
+ lines.push(`last-activity-at-check: ${Math.round(activityAgeAtCheck / 1000)}s ago`);
345
+ }
346
+ if (liveness.verdict === "keep-running" && liveness.idleMs !== null && liveness.activity !== "advancing") {
347
+ const baselineAgeAtCheck = Math.max(0, liveness.checkedAt - liveness.idleBaselineAt);
348
+ const remainingAtCheck = Math.max(0, liveness.idleMs - baselineAgeAtCheck);
349
+ lines.push(`idle-stop-in-at-check: ${Math.ceil(remainingAtCheck / 1000)}s`);
350
+ }
351
+ return lines;
352
+ }
317
353
  function describeJob(job, now) {
318
354
  const elapsed = Math.round(((job.endedAt ?? now) - job.startedAt) / 1000);
319
355
  const head = [
@@ -331,17 +367,20 @@ function describeJob(job, now) {
331
367
  head.push(...relayProvenanceLines(job));
332
368
  if (job.dispatchSource === "fallback")
333
369
  head.push("dispatch-source: local-fallback (daemon unreachable)");
370
+ if (job.executionOwner === "relay-daemon")
371
+ head.push("execution-owner: relay-daemon (survives MCP host restart)");
372
+ if (job.executionOwner === "local-fallback")
373
+ head.push("execution-owner: local-fallback (does NOT survive MCP host restart)");
334
374
  if (job.restored === true) {
335
- head.push("record: restored from disk — this job ended in an earlier llm-relay MCP server process or in another host's one");
375
+ head.push(job.status === "running"
376
+ ? "record: recovered after MCP restart — this job's process tree is owned by the relay daemon"
377
+ : "record: restored from disk — this job ended in an earlier llm-relay MCP server process or in another host's one");
336
378
  }
337
379
  if (job.readOnly)
338
380
  head.push(`read-only: ${job.readOnly.binding}`);
339
381
  if (job.launch?.length)
340
382
  head.push(`launch: ${job.launch.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
- }
383
+ head.push(...describeLiveness(job, now));
345
384
  const usually = runningTimeToAnswer(job);
346
385
  if (usually !== null)
347
386
  head.push(usually);
@@ -409,7 +448,9 @@ function jobAnswerBody(job, now) {
409
448
  return `${header}\n\nStill running. Poll dispatch_status, then call dispatch_result.`;
410
449
  }
411
450
  if (job.status === "killed") {
412
- return `${header}\n\nThe lane was KILLED when the llm-relay MCP server restarted — it did not fail and it did not time out. Its process is gone. Re-dispatch from scratch, and check the working directory first: only files the lane wrote before the restart survive.`;
451
+ return job.restored === true
452
+ ? `${header}\n\nThe daemon-owned lane execution could no longer be recovered. No terminal result was collectable, so re-dispatch from scratch and check the working directory for partial files first.`
453
+ : `${header}\n\nThe lane was KILLED when the llm-relay MCP server restarted — it did not fail and it did not time out. Its process is gone. Re-dispatch from scratch, and check the working directory first: only files the lane wrote before the restart survive.`;
413
454
  }
414
455
  if (job.status === "timed_out") {
415
456
  const partial = isContentEmpty(body) ? "" : `\n\nPartial output before the timeout:\n\n${body}`;
@@ -422,6 +463,7 @@ function jobAnswerBody(job, now) {
422
463
  }
423
464
  return `${header}\n\n${body}${exhausted}`;
424
465
  }
466
+ export const BROKER_STATUS_POLL_MS = 1_000;
425
467
  function emptyRun() {
426
468
  return { code: null, stdout: "", stderr: "", timedOut: false };
427
469
  }
@@ -437,7 +479,7 @@ function attemptReason(status, outcome, idleMs) {
437
479
  return undefined;
438
480
  if (status === "abandoned") {
439
481
  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`;
482
+ return `no activity for ${quiet} (no relay traffic, output, process CPU or file change), so the next lane was started`;
441
483
  }
442
484
  if (outcome.semanticFailure !== undefined)
443
485
  return outcome.semanticFailure;
@@ -456,10 +498,7 @@ function flagValue(args, flag) {
456
498
  return joined === undefined ? null : joined.slice(flag.length + 1);
457
499
  }
458
500
  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
- }
501
+ const PROCESS_CPU_ACTIVITY_MS = 1_000;
463
502
  function newestActivity(seen) {
464
503
  let best = null;
465
504
  for (const s of seen) {
@@ -469,9 +508,6 @@ function newestActivity(seen) {
469
508
  }
470
509
  return best;
471
510
  }
472
- function newActivityTag() {
473
- return randomUUID().replaceAll("-", "");
474
- }
475
511
  function pollTimer(ms) {
476
512
  return new Promise((resolve) => {
477
513
  const t = setTimeout(() => resolve({ kind: "poll" }), ms);
@@ -482,16 +518,6 @@ function pollTimer(ms) {
482
518
  function laneAnswered(r) {
483
519
  return r.code === 0 && !r.timedOut && !isContentEmpty(r.stdout);
484
520
  }
485
- function applyLaneEnv(base, deltas) {
486
- const env = { ...base };
487
- for (const [name, value] of Object.entries(deltas ?? {})) {
488
- if (value === null)
489
- delete env[name];
490
- else
491
- env[name] = value;
492
- }
493
- return env;
494
- }
495
521
  function laneSummary(lane, inFlight) {
496
522
  const bits = [`${lane.position}. ${lane.id}`, `[${lane.state}]`];
497
523
  if (lane.spec)
@@ -504,6 +530,11 @@ function laneSummary(lane, inFlight) {
504
530
  bits.push(`unreachable: ${lane.unreachable}`);
505
531
  if (lane.note)
506
532
  bits.push(lane.note);
533
+ if (lane.capabilityBasis !== undefined) {
534
+ bits.push(lane.capability === undefined
535
+ ? "capability: unknown (no evidence-qualified limit)"
536
+ : `capability: ${lane.capability} (${lane.capabilityBasis})`);
537
+ }
507
538
  bits.push(...laneEvidenceBits(lane));
508
539
  if (lane.maxConcurrent !== null && lane.maxConcurrent !== undefined) {
509
540
  bits.push(`in flight: ${inFlight} of ${lane.maxConcurrent}`);
@@ -535,6 +566,11 @@ export class McpDispatchServer {
535
566
  cancellable = new Map();
536
567
  trees = new Map();
537
568
  activityTags = new Map();
569
+ processCpu = new Map();
570
+ brokerCpu = new Map();
571
+ liveBrokerSnapshots = new Map();
572
+ startup;
573
+ brokerStartup;
538
574
  constructor(deps) {
539
575
  this.deps = deps;
540
576
  this.jobs = new LaneJobStore(deps.journal ?? nullJobJournal, deps.archive ?? nullJobArchive);
@@ -543,6 +579,9 @@ export class McpDispatchServer {
543
579
  this.now = deps.now ?? Date.now;
544
580
  this.cwd = deps.cwd ?? (() => process.cwd());
545
581
  this.maxDepth = deps.maxDepth ?? DEFAULT_MAX_DEPTH;
582
+ const killedTreeStartup = this.restoreKilledTreeDeltas();
583
+ this.brokerStartup = this.restoreBrokerOrphans();
584
+ this.startup = Promise.all([killedTreeStartup, this.brokerStartup]).then(() => undefined);
546
585
  }
547
586
  ingest(chunk) {
548
587
  this.buffer += chunk;
@@ -661,10 +700,13 @@ export class McpDispatchServer {
661
700
  case "dispatch":
662
701
  return this.toolDispatch(args, ctx);
663
702
  case "dispatch_status":
703
+ await this.startup;
664
704
  return this.toolStatus(args);
665
705
  case "dispatch_result":
706
+ await this.startup;
666
707
  return this.toolResult(args);
667
708
  case "dispatch_cancel":
709
+ await this.brokerStartup;
668
710
  return this.toolCancel(args);
669
711
  case "dispatch_lanes":
670
712
  return this.toolLanes(args);
@@ -706,14 +748,17 @@ export class McpDispatchServer {
706
748
  const next = view.next ? `\n\nnext: ${view.next.id} — ${view.reason}` : `\n\nnext: none — ${view.reason}`;
707
749
  const walk = this.deps.config.routing?.dispatchWalk;
708
750
  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`
751
+ ? `\nwalk: a lane is stopped after ${Math.round(walk.idleMs / 1000)}s with no activity (relay traffic, output, owned process CPU or file change); the last lane is never stopped`
710
752
  : "\nwalk: off — only the first lane runs";
711
753
  return textResult(`tier: ${view.tier ?? "default"}${rule}\n\n${lines.join("\n")}${next}`);
712
754
  }
713
- toolStatus(args) {
755
+ async toolStatus(args) {
714
756
  const jobId = readString(args, "jobId");
715
- if (!jobId)
757
+ if (!jobId) {
758
+ await this.refreshAllBrokerJobs();
716
759
  return textResult(this.recentJobs());
760
+ }
761
+ await this.refreshBrokerJob(jobId);
717
762
  const job = this.jobs.find(jobId);
718
763
  if (!job)
719
764
  return this.unknownJob(jobId);
@@ -721,10 +766,11 @@ export class McpDispatchServer {
721
766
  return textResult(describeJob(job, this.now()));
722
767
  return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
723
768
  }
724
- toolResult(args) {
769
+ async toolResult(args) {
725
770
  const jobId = readString(args, "jobId");
726
771
  if (!jobId)
727
772
  return textResult("dispatch_result requires jobId", true);
773
+ await this.refreshBrokerJob(jobId);
728
774
  const job = this.jobs.find(jobId);
729
775
  if (!job)
730
776
  return this.unknownJob(jobId);
@@ -753,18 +799,55 @@ export class McpDispatchServer {
753
799
  `lane: ${elsewhere.laneId}${elsewhere.spec ? ` (${elsewhere.spec})` : ""}`,
754
800
  "status: running",
755
801
  `elapsed: ${elapsed}s`,
802
+ "activity: unavailable",
803
+ "walk-verdict: unavailable",
756
804
  `record: running in another llm-relay MCP server process (pid ${elsewhere.pid})`,
757
805
  ].join("\n") +
758
- "\n\nThis process cannot report its progress or cancel it. Poll again: its answer appears " +
759
- "here once it ends.");
806
+ "\n\nThis process cannot observe that process's live activity or cancel it. " +
807
+ "`walk-verdict: unavailable` means keep polling rather than infer from elapsed time or silence; " +
808
+ "its answer appears here once it ends.");
760
809
  }
761
- toolCancel(args) {
810
+ async toolCancel(args) {
762
811
  const jobId = readString(args, "jobId");
763
812
  if (!jobId)
764
813
  return textResult("dispatch_cancel requires jobId", true);
765
814
  const job = this.jobs.find(jobId);
766
815
  if (!job)
767
816
  return this.unknownJob(jobId);
817
+ if (job.status !== "running") {
818
+ return textResult(`${jobId} was already ${job.status}`);
819
+ }
820
+ const executionId = this.jobs.brokerExecution(jobId);
821
+ if (executionId !== undefined) {
822
+ const client = this.deps.laneExecutionClient;
823
+ if (client === undefined) {
824
+ return textResult(`could not cancel ${jobId}: the daemon execution client is unavailable; the job was left running`, true);
825
+ }
826
+ let result;
827
+ try {
828
+ result = await client.request({ action: "cancel", executionId });
829
+ }
830
+ catch {
831
+ return textResult(`could not cancel ${jobId}: the daemon execution client failed; the job was left running`, true);
832
+ }
833
+ if (!result.ok) {
834
+ if (result.kind === "rejected" && result.status === 404 && result.message === UNKNOWN_LANE_EXECUTION_MESSAGE) {
835
+ this.jobs.markBrokerKilled(jobId, "the relay daemon is reachable but no longer knows this recovered lane execution");
836
+ await this.recordTreeDelta(jobId);
837
+ return textResult(`${jobId} was already killed`, true);
838
+ }
839
+ return textResult(`could not cancel ${jobId}: ${result.message}; the daemon-owned execution was left untouched`, true);
840
+ }
841
+ if (!this.jobs.applyBrokerSnapshot(jobId, result.execution)) {
842
+ return textResult(`could not confirm cancellation of ${jobId}: the daemon returned a mismatched execution snapshot`, true);
843
+ }
844
+ if (result.execution.status !== "running")
845
+ await this.recordTreeDelta(jobId);
846
+ const current = this.jobs.find(jobId);
847
+ return textResult(current?.status === "cancelled"
848
+ ? `cancelled ${jobId}`
849
+ : `${jobId} was already ${current?.status ?? result.execution.status}`);
850
+ }
768
851
  const cancelled = this.jobs.cancel(jobId);
769
852
  if (cancelled)
770
853
  void this.recordTreeDelta(jobId);
@@ -815,6 +898,7 @@ export class McpDispatchServer {
815
898
  readOnly: args["readOnly"] === true,
816
899
  scope: readStringArray(args, "scope"),
817
900
  tier: view.tier ?? undefined,
901
+ host: view.host,
818
902
  timeoutMs: readNumber(args, "timeoutMs") ?? DEFAULT_LANE_TIMEOUT_MS,
819
903
  idleMs: walk !== null ? walk.idleMs : null,
820
904
  system: readString(args, "system"),
@@ -830,6 +914,7 @@ export class McpDispatchServer {
830
914
  });
831
915
  if (!readOnlyVerdictResult.ok)
832
916
  return textResult(readOnlyVerdictResult.refusal, true);
917
+ await this.brokerStartup;
833
918
  const first = view.ladder.find((l) => l.id === ordered[0]) ?? view.next;
834
919
  const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource, taskLabel(task));
835
920
  this.jobs.noteWalkScope(job.id, { enabled: walk !== null, lanesNotTried: notTried, forced });
@@ -846,7 +931,10 @@ export class McpDispatchServer {
846
931
  this.jobs.fail(job.id, `dispatch walk failed: ${e.message}`);
847
932
  return "done";
848
933
  })
849
- .finally(() => this.activityTags.delete(job.id));
934
+ .finally(() => {
935
+ this.activityTags.delete(job.id);
936
+ this.processCpu.delete(job.id);
937
+ });
850
938
  return this.awaitOrPoll(job.id, settled, waitMs.waitMs, {
851
939
  clamp: waitMs.clamped ? { requested: waitMs.requested, ceiling, key: ceilingKey } : undefined,
852
940
  progressToken: blocking?.progress === true ? ctx.progressToken : undefined,
@@ -871,6 +959,7 @@ export class McpDispatchServer {
871
959
  }
872
960
  async runWalk(jobId, view, laneIds, task, opts) {
873
961
  await this.startTreeDelta(jobId, opts);
962
+ let ranAttempt = false;
874
963
  for (let i = 0; i < laneIds.length; i++) {
875
964
  if (this.jobs.get(jobId)?.status !== "running")
876
965
  return "done";
@@ -883,6 +972,12 @@ export class McpDispatchServer {
883
972
  const readOnly = this.bindReadOnlyOrSkip(jobId, lane, opts);
884
973
  if (readOnly.skipped)
885
974
  continue;
975
+ if (ranAttempt) {
976
+ await this.resetLaneTreeActivity(jobId);
977
+ if (this.jobs.get(jobId)?.status !== "running")
978
+ return "done";
979
+ }
980
+ ranAttempt = true;
886
981
  this.jobs.setCurrentLane(jobId, lane.id, lane.spec, lane.timeToAnswer);
887
982
  if (readOnly.bound !== undefined)
888
983
  this.jobs.noteReadOnly(jobId, lane.id, readOnly.bound.binding);
@@ -919,6 +1014,11 @@ export class McpDispatchServer {
919
1014
  requestedMode: opts.mode,
920
1015
  adHoc: lane.adHoc === true,
921
1016
  }, outcome.run.stdout.length, { status, wallClockMs: elapsedMs, exitCode: outcome.run.code });
1017
+ if (status !== "completed" && !isLast && this.jobs.brokerExecution(jobId) !== undefined) {
1018
+ this.jobs.clearBrokerExecution(jobId);
1019
+ this.liveBrokerSnapshots.delete(jobId);
1020
+ this.brokerCpu.delete(jobId);
1021
+ }
922
1022
  if (status === "completed" || isLast) {
923
1023
  await this.recordTreeDelta(jobId);
924
1024
  this.jobs.complete(jobId, outcome.run, status === "completed" ? undefined : (outcome.semanticFailure ?? reason), outcome.relay);
@@ -941,8 +1041,168 @@ export class McpDispatchServer {
941
1041
  if (opts.mode !== "agent")
942
1042
  return;
943
1043
  const before = await this.readTree(opts.cwd);
944
- if (before !== null)
945
- this.trees.set(jobId, { cwd: opts.cwd, before, scope: opts.scope });
1044
+ if (before !== null) {
1045
+ this.trees.set(jobId, { cwd: opts.cwd, before, scope: opts.scope, activityLastSeen: before });
1046
+ this.jobs.noteStartingTree(jobId, before, opts.scope);
1047
+ }
1048
+ }
1049
+ async restoreBrokerOrphans() {
1050
+ const rows = this.jobs.takeBrokerOrphans();
1051
+ await Promise.all(rows.map(async (row) => {
1052
+ const job = this.jobs.adoptBrokerRunning(row);
1053
+ if (job === undefined)
1054
+ return;
1055
+ if (row.startingTree !== undefined) {
1056
+ const before = {
1057
+ prefix: row.startingTree.prefix,
1058
+ entries: new Map(row.startingTree.entries),
1059
+ };
1060
+ this.trees.set(row.jobId, {
1061
+ cwd: row.cwd,
1062
+ before,
1063
+ scope: row.startingTree.scope,
1064
+ });
1065
+ }
1066
+ this.noteBrokerRecoveryUnavailable(row.jobId, "recovered-broker-pending");
1067
+ }));
1068
+ }
1069
+ async refreshAllBrokerJobs() {
1070
+ const pending = this.jobs
1071
+ .list()
1072
+ .filter((job) => job.status === "running" && this.jobs.brokerExecution(job.id) !== undefined)
1073
+ .map((job) => this.refreshBrokerJob(job.id));
1074
+ await Promise.all(pending);
1075
+ }
1076
+ async refreshBrokerJob(jobId) {
1077
+ const job = this.jobs.get(jobId);
1078
+ if (job !== undefined && job.restored !== true)
1079
+ return false;
1080
+ const executionId = this.jobs.brokerExecution(jobId);
1081
+ if (executionId === undefined)
1082
+ return false;
1083
+ const client = this.deps.laneExecutionClient;
1084
+ if (client === undefined) {
1085
+ this.noteBrokerRecoveryUnavailable(jobId, "broker-client-unavailable");
1086
+ return true;
1087
+ }
1088
+ let result;
1089
+ try {
1090
+ result = await client.request({ action: "status", executionId });
1091
+ }
1092
+ catch {
1093
+ this.noteBrokerRecoveryUnavailable(jobId, "broker-client-threw");
1094
+ return true;
1095
+ }
1096
+ if (!result.ok) {
1097
+ if (result.kind === "rejected" && result.status === 404 && result.message === UNKNOWN_LANE_EXECUTION_MESSAGE) {
1098
+ const killed = this.jobs.markBrokerKilled(jobId, "the relay daemon is reachable but no longer knows this recovered lane execution; " +
1099
+ "the execution ended outside the retained broker window or the daemon restarted");
1100
+ if (killed) {
1101
+ this.brokerCpu.delete(jobId);
1102
+ await this.recordTreeDelta(jobId);
1103
+ }
1104
+ }
1105
+ else {
1106
+ this.noteBrokerRecoveryUnavailable(jobId, result.kind === "invalid-response" ? "broker-response-invalid" : "broker-unavailable");
1107
+ }
1108
+ return true;
1109
+ }
1110
+ const snapshot = result.execution;
1111
+ if (!this.jobs.applyBrokerSnapshot(jobId, snapshot)) {
1112
+ this.noteBrokerRecoveryUnavailable(jobId, "broker-response-mismatch");
1113
+ return true;
1114
+ }
1115
+ if (snapshot.status === "running") {
1116
+ await this.noteRecoveredBrokerLiveness(jobId, snapshot);
1117
+ }
1118
+ else {
1119
+ this.brokerCpu.delete(jobId);
1120
+ await this.recordTreeDelta(jobId);
1121
+ }
1122
+ return true;
1123
+ }
1124
+ noteBrokerRecoveryUnavailable(jobId, source) {
1125
+ const job = this.jobs.get(jobId);
1126
+ if (job === undefined || job.status !== "running")
1127
+ return;
1128
+ this.jobs.noteLiveness(jobId, {
1129
+ activity: "unmonitored",
1130
+ verdict: "no-idle-stop",
1131
+ checkedAt: this.now(),
1132
+ idleBaselineAt: job.activity?.attemptStartedAt ?? job.startedAt,
1133
+ lastActivityAt: job.liveness?.lastActivityAt ?? null,
1134
+ source,
1135
+ idleMs: null,
1136
+ });
1137
+ }
1138
+ async noteRecoveredBrokerLiveness(jobId, snapshot) {
1139
+ const now = this.now();
1140
+ const seen = [
1141
+ { at: snapshot.lastOutputAt, source: "lane-output" },
1142
+ snapshot.relayInFlight !== undefined && snapshot.relayInFlight > 0
1143
+ ? { at: now, source: "relay-in-flight" }
1144
+ : { at: snapshot.relayLastActivityAt, source: "relay-traffic" },
1145
+ ];
1146
+ if (snapshot.cpuMs !== undefined) {
1147
+ const previous = this.brokerCpu.get(jobId);
1148
+ this.brokerCpu.set(jobId, snapshot.cpuMs);
1149
+ if (previous !== undefined && snapshot.cpuMs - previous >= PROCESS_CPU_ACTIVITY_MS) {
1150
+ seen.push({ at: now, source: "process-cpu" });
1151
+ }
1152
+ }
1153
+ const tree = this.trees.get(jobId);
1154
+ const reading = tree === undefined ? null : await this.readTree(tree.cwd);
1155
+ if (tree !== undefined && reading !== null) {
1156
+ const previous = tree.activityLastSeen;
1157
+ tree.activityLastSeen = reading;
1158
+ if (previous !== undefined) {
1159
+ seen.push(sameTree(previous, reading)
1160
+ ? { at: newestChangeMs(tree.cwd, reading), source: "file-change" }
1161
+ : { at: now, source: "working-tree" });
1162
+ }
1163
+ }
1164
+ const previous = this.jobs.get(jobId)?.liveness;
1165
+ if (previous?.lastActivityAt !== null && previous?.lastActivityAt !== undefined) {
1166
+ seen.push({ at: previous.lastActivityAt, source: previous.source });
1167
+ }
1168
+ const latest = newestActivity(seen);
1169
+ const activity = snapshot.relayInFlight !== undefined && snapshot.relayInFlight > 0
1170
+ ? "active"
1171
+ : latest === null
1172
+ ? "unmonitored"
1173
+ : now - latest.at < IDLE_POLL_MS
1174
+ ? "active"
1175
+ : "quiet";
1176
+ this.jobs.noteLiveness(jobId, {
1177
+ activity,
1178
+ verdict: "no-idle-stop",
1179
+ checkedAt: now,
1180
+ idleBaselineAt: snapshot.startedAt,
1181
+ lastActivityAt: latest?.at ?? null,
1182
+ source: latest?.source ?? "recovered-broker",
1183
+ idleMs: null,
1184
+ });
1185
+ }
1186
+ async restoreKilledTreeDeltas() {
1187
+ const adopted = this.jobs.takeAdoptedStartingTrees();
1188
+ await Promise.all(adopted.map(async ({ jobId, cwd, startingTree }) => {
1189
+ const after = await this.readTree(cwd);
1190
+ if (after === null)
1191
+ return;
1192
+ const before = { prefix: startingTree.prefix, entries: new Map(startingTree.entries) };
1193
+ const delta = renderTreeDelta(cwd, before, after, startingTree.scope);
1194
+ this.jobs.noteTreeDelta(jobId, `${delta}\n note: measured at restart adoption time, not at the time the job was killed`);
1195
+ }));
1196
+ }
1197
+ async resetLaneTreeActivity(jobId) {
1198
+ const tree = this.trees.get(jobId);
1199
+ if (tree === undefined)
1200
+ return;
1201
+ const reading = await this.readTree(tree.cwd);
1202
+ if (reading === null)
1203
+ delete tree.activityLastSeen;
1204
+ else
1205
+ tree.activityLastSeen = reading;
946
1206
  }
947
1207
  async recordTreeDelta(jobId) {
948
1208
  const tree = this.trees.get(jobId);
@@ -999,7 +1259,8 @@ export class McpDispatchServer {
999
1259
  spec: lane.spec,
1000
1260
  status: SKIPPED_LANE_STATUS,
1001
1261
  elapsedMs: 0,
1002
- reason: `lane "${lane.id}" declares capability ${lane.capability}, below this ${opts.tier} dispatch`,
1262
+ reason: `lane "${lane.id}" has derived capability ${lane.capability}` +
1263
+ `${lane.capabilityBasis ? ` (${lane.capabilityBasis})` : ""}, below this ${opts.tier} dispatch`,
1003
1264
  });
1004
1265
  this.jobs.noteSkippedLane(jobId);
1005
1266
  return true;
@@ -1007,26 +1268,56 @@ export class McpDispatchServer {
1007
1268
  async latestActivity(jobId) {
1008
1269
  const now = this.now();
1009
1270
  const seen = [
1010
- { at: this.jobs.get(jobId)?.activity?.lastOutputAt, source: "lane output" },
1271
+ { at: this.jobs.get(jobId)?.activity?.lastOutputAt, source: "lane-output" },
1011
1272
  ];
1273
+ const broker = this.liveBrokerSnapshots.get(jobId);
1274
+ if (broker !== undefined) {
1275
+ seen.push(broker.relayInFlight !== undefined && broker.relayInFlight > 0
1276
+ ? { at: now, source: "relay-in-flight" }
1277
+ : { at: broker.relayLastActivityAt, source: "relay-traffic" });
1278
+ if (broker.cpuMs !== undefined) {
1279
+ const previous = this.brokerCpu.get(jobId);
1280
+ this.brokerCpu.set(jobId, broker.cpuMs);
1281
+ if (previous !== undefined && broker.cpuMs - previous >= PROCESS_CPU_ACTIVITY_MS) {
1282
+ seen.push({ at: now, source: "process-cpu" });
1283
+ }
1284
+ }
1285
+ }
1012
1286
  const tag = this.activityTags.get(jobId);
1013
- if (tag !== undefined && this.deps.readLaneActivity !== undefined) {
1287
+ if (broker === undefined && tag !== undefined && this.deps.readLaneActivity !== undefined) {
1014
1288
  const traffic = await this.deps.readLaneActivity(tag).catch(() => null);
1015
1289
  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" });
1290
+ ? { at: now, source: "relay-in-flight" }
1291
+ : { at: traffic?.lastActivityAt, source: "relay-traffic" });
1018
1292
  }
1019
1293
  const early = newestActivity(seen);
1020
1294
  if (early !== null && now - early.at < IDLE_POLL_MS)
1021
1295
  return early;
1296
+ const pids = broker === undefined ? this.jobs.runningPids(jobId) : [];
1297
+ if (pids.length > 0 && this.deps.readProcessCpu !== undefined) {
1298
+ const cpuMs = await this.deps.readProcessCpu(pids).catch(() => null);
1299
+ if (typeof cpuMs === "number" && Number.isFinite(cpuMs) && cpuMs >= 0) {
1300
+ const previous = this.processCpu.get(jobId);
1301
+ this.processCpu.set(jobId, cpuMs);
1302
+ if (previous !== undefined && cpuMs - previous >= PROCESS_CPU_ACTIVITY_MS) {
1303
+ seen.push({ at: this.now(), source: "process-cpu" });
1304
+ }
1305
+ }
1306
+ }
1307
+ const afterCpu = newestActivity(seen);
1308
+ const afterCpuNow = this.now();
1309
+ if (afterCpu !== null && afterCpuNow - afterCpu.at < IDLE_POLL_MS)
1310
+ return afterCpu;
1022
1311
  const tree = this.trees.get(jobId);
1023
1312
  const reading = tree === undefined ? null : await this.readTree(tree.cwd);
1024
1313
  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" });
1314
+ const previous = tree.activityLastSeen;
1315
+ tree.activityLastSeen = reading;
1316
+ if (previous !== undefined) {
1317
+ seen.push(sameTree(previous, reading)
1318
+ ? { at: newestChangeMs(tree.cwd, reading), source: "file-change" }
1319
+ : { at: now, source: "working-tree" });
1320
+ }
1030
1321
  }
1031
1322
  return newestActivity(seen);
1032
1323
  }
@@ -1052,19 +1343,48 @@ export class McpDispatchServer {
1052
1343
  }
1053
1344
  async runOneLane(jobId, lane, task, opts, idleMs, invoke) {
1054
1345
  const attemptStart = this.now();
1055
- const started = this.startLane(jobId, lane, task, opts, invoke);
1346
+ const started = await this.startLane(jobId, lane, task, opts, invoke);
1056
1347
  if ("refusal" in started)
1057
1348
  return { run: emptyRun(), abandoned: false, refusal: started.refusal };
1058
- this.jobs.registerProcess(jobId, started);
1349
+ if (started.brokerExecutionId === undefined) {
1350
+ this.jobs.registerProcess(jobId, started);
1351
+ }
1352
+ this.processCpu.delete(jobId);
1353
+ this.brokerCpu.delete(jobId);
1059
1354
  const guarded = started.result.catch((e) => ({
1060
1355
  run: { code: null, stdout: "", stderr: e.message, timedOut: false },
1061
1356
  abandoned: false,
1062
1357
  semanticFailure: e.message,
1063
1358
  }));
1064
- if (idleMs === null)
1359
+ if (idleMs === null) {
1360
+ this.jobs.noteLiveness(jobId, {
1361
+ activity: "unmonitored",
1362
+ verdict: "no-idle-stop",
1363
+ checkedAt: attemptStart,
1364
+ idleBaselineAt: attemptStart,
1365
+ lastActivityAt: null,
1366
+ source: "attempt-start",
1367
+ idleMs: null,
1368
+ });
1065
1369
  return guarded;
1066
- const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
1370
+ }
1371
+ this.jobs.noteLiveness(jobId, {
1372
+ activity: "starting",
1373
+ verdict: "keep-running",
1374
+ checkedAt: attemptStart,
1375
+ idleBaselineAt: attemptStart,
1376
+ lastActivityAt: null,
1377
+ source: "attempt-start",
1378
+ idleMs,
1379
+ });
1380
+ let settledOutcome = null;
1381
+ const settled = guarded.then((o) => {
1382
+ settledOutcome = o;
1383
+ return { kind: "settled", outcome: o };
1384
+ });
1067
1385
  let lastActive = attemptStart;
1386
+ let lastRealActivity = null;
1387
+ let lastSource = "attempt-start";
1068
1388
  for (;;) {
1069
1389
  const raced = await Promise.race([settled, pollTimer(Math.min(IDLE_POLL_MS, idleMs))]);
1070
1390
  if (raced.kind === "settled")
@@ -1072,17 +1392,177 @@ export class McpDispatchServer {
1072
1392
  if (this.jobs.get(jobId)?.status !== "running")
1073
1393
  break;
1074
1394
  const seen = await this.latestActivity(jobId);
1075
- if (seen !== null && seen.at > lastActive) {
1395
+ const afterProbe = await Promise.race([settled, pollTimer(0)]);
1396
+ if (afterProbe.kind === "settled")
1397
+ return afterProbe.outcome;
1398
+ if (settledOutcome !== null)
1399
+ return settledOutcome;
1400
+ if (this.jobs.get(jobId)?.status !== "running")
1401
+ break;
1402
+ const fresh = seen !== null && seen.at > lastActive;
1403
+ if (fresh && seen !== null) {
1076
1404
  lastActive = seen.at;
1077
- this.jobs.noteLastActivity(jobId, seen.at, seen.source);
1405
+ lastRealActivity = seen.at;
1406
+ lastSource = seen.source;
1078
1407
  }
1079
- if (this.now() - lastActive >= idleMs)
1408
+ const checkedAt = this.now();
1409
+ const idle = checkedAt - lastActive >= idleMs;
1410
+ if (idle) {
1411
+ this.jobs.noteLiveness(jobId, {
1412
+ activity: "advancing",
1413
+ verdict: "keep-running",
1414
+ checkedAt,
1415
+ idleBaselineAt: lastActive,
1416
+ lastActivityAt: lastRealActivity,
1417
+ source: lastSource,
1418
+ idleMs,
1419
+ });
1080
1420
  break;
1421
+ }
1422
+ this.jobs.noteLiveness(jobId, {
1423
+ activity: fresh ? "active" : "quiet",
1424
+ verdict: "keep-running",
1425
+ checkedAt,
1426
+ idleBaselineAt: lastActive,
1427
+ lastActivityAt: lastRealActivity,
1428
+ source: lastSource,
1429
+ idleMs,
1430
+ });
1081
1431
  }
1082
1432
  started.kill();
1433
+ if (started.waitForKill !== undefined)
1434
+ await started.waitForKill();
1083
1435
  return { run: emptyRun(), abandoned: true };
1084
1436
  }
1085
- startLane(jobId, lane, task, opts, invokeOverride) {
1437
+ async brokerRequest(request) {
1438
+ const client = this.deps.laneExecutionClient;
1439
+ if (client === undefined) {
1440
+ return { ok: false, kind: "unavailable", status: null, message: "lane execution broker client is unavailable" };
1441
+ }
1442
+ try {
1443
+ return await client.request(request);
1444
+ }
1445
+ catch {
1446
+ return { ok: false, kind: "unavailable", status: null, message: "lane execution broker client failed" };
1447
+ }
1448
+ }
1449
+ brokerSnapshotMatches(snapshot, jobId, laneId, executionId) {
1450
+ return snapshot.executionId === executionId && snapshot.jobId === jobId && snapshot.laneId === laneId;
1451
+ }
1452
+ brokerRun(snapshot) {
1453
+ return {
1454
+ code: snapshot.code ?? null,
1455
+ stdout: snapshot.stdout ?? "",
1456
+ stderr: snapshot.stderr ?? "",
1457
+ timedOut: snapshot.timedOut ?? snapshot.status === "timed_out",
1458
+ };
1459
+ }
1460
+ async waitBrokerLane(jobId, lane, invoke, opts, startedAt, startRequest, initial) {
1461
+ let response = initial;
1462
+ let startConfirmed = initial.ok;
1463
+ for (;;) {
1464
+ if (response.ok) {
1465
+ const snapshot = response.execution;
1466
+ if (!this.brokerSnapshotMatches(snapshot, jobId, lane.id, startRequest.executionId)) {
1467
+ await pollTimer(BROKER_STATUS_POLL_MS);
1468
+ response = await this.brokerRequest({ action: "status", executionId: startRequest.executionId });
1469
+ continue;
1470
+ }
1471
+ startConfirmed = true;
1472
+ if (snapshot.status === "running") {
1473
+ this.liveBrokerSnapshots.set(jobId, snapshot);
1474
+ this.jobs.applyBrokerSnapshot(jobId, snapshot);
1475
+ await pollTimer(BROKER_STATUS_POLL_MS);
1476
+ response = await this.brokerRequest({ action: "status", executionId: startRequest.executionId });
1477
+ continue;
1478
+ }
1479
+ this.liveBrokerSnapshots.delete(jobId);
1480
+ this.brokerCpu.delete(jobId);
1481
+ return this.settleSpawnedRun(jobId, lane, invoke, opts, startedAt, this.brokerRun(snapshot));
1482
+ }
1483
+ if (!startConfirmed) {
1484
+ if (response.kind === "rejected" && response.status !== null) {
1485
+ this.jobs.clearBrokerExecution(jobId);
1486
+ return failedOutcome(response.message);
1487
+ }
1488
+ await pollTimer(BROKER_STATUS_POLL_MS);
1489
+ response = await this.brokerRequest(startRequest);
1490
+ continue;
1491
+ }
1492
+ if (response.kind === "rejected") {
1493
+ this.jobs.clearBrokerExecution(jobId);
1494
+ this.liveBrokerSnapshots.delete(jobId);
1495
+ this.brokerCpu.delete(jobId);
1496
+ return failedOutcome(response.status === 404
1497
+ ? "the relay daemon lost the brokered lane execution before it returned a terminal result"
1498
+ : response.message);
1499
+ }
1500
+ await pollTimer(BROKER_STATUS_POLL_MS);
1501
+ response = await this.brokerRequest({ action: "status", executionId: startRequest.executionId });
1502
+ }
1503
+ }
1504
+ async stopBrokerLane(executionId) {
1505
+ for (;;) {
1506
+ const response = await this.brokerRequest({ action: "cancel", executionId });
1507
+ if (response.ok)
1508
+ return;
1509
+ if (response.kind === "rejected" && ((response.status === 404 && response.message === UNKNOWN_LANE_EXECUTION_MESSAGE) || response.status === 503))
1510
+ return;
1511
+ await pollTimer(BROKER_STATUS_POLL_MS);
1512
+ }
1513
+ }
1514
+ async tryStartBrokerLane(jobId, lane, task, opts, invoke) {
1515
+ if (this.deps.laneExecutionClient === undefined)
1516
+ return null;
1517
+ let executionId = createLaneExecutionId();
1518
+ for (let collision = 0; collision < 3; collision += 1) {
1519
+ const probe = await this.brokerRequest({ action: "status", executionId });
1520
+ if (!probe.ok && probe.kind === "rejected" && probe.status === 404 && probe.message === UNKNOWN_LANE_EXECUTION_MESSAGE)
1521
+ break;
1522
+ if (!probe.ok)
1523
+ return { fallback: probe.message };
1524
+ executionId = createLaneExecutionId();
1525
+ if (collision === 2)
1526
+ return { fallback: "could not allocate a unique daemon execution id" };
1527
+ }
1528
+ const startRequest = {
1529
+ action: "start",
1530
+ executionId,
1531
+ jobId,
1532
+ laneId: lane.id,
1533
+ task,
1534
+ cwd: opts.cwd,
1535
+ timeoutMs: opts.timeoutMs,
1536
+ depth: opts.depth,
1537
+ ...(opts.tier === undefined ? {} : { tier: opts.tier }),
1538
+ ...(opts.readOnly ? { readOnly: true, callerRoot: this.cwd() } : {}),
1539
+ host: opts.host,
1540
+ };
1541
+ this.jobs.noteBrokerExecution(jobId, executionId);
1542
+ this.jobs.noteExecutionOwner(jobId, "relay-daemon");
1543
+ const startedAt = this.now();
1544
+ const initial = await this.brokerRequest(startRequest);
1545
+ if (!initial.ok && initial.kind === "rejected" && initial.status !== null) {
1546
+ this.jobs.clearBrokerExecution(jobId);
1547
+ if (initial.status === 503)
1548
+ return { fallback: initial.message };
1549
+ return {
1550
+ result: Promise.resolve(failedOutcome(initial.message)),
1551
+ kill: () => { },
1552
+ };
1553
+ }
1554
+ let cancelPromise;
1555
+ const result = this.waitBrokerLane(jobId, lane, invoke, opts, startedAt, startRequest, initial);
1556
+ return {
1557
+ result,
1558
+ brokerExecutionId: executionId,
1559
+ kill: () => {
1560
+ cancelPromise ??= this.stopBrokerLane(executionId);
1561
+ },
1562
+ waitForKill: () => cancelPromise ?? Promise.resolve(),
1563
+ };
1564
+ }
1565
+ async startLane(jobId, lane, task, opts, invokeOverride) {
1086
1566
  if (opts.mode === "answer" && lane.kind === "relay") {
1087
1567
  if (!lane.spec) {
1088
1568
  return {
@@ -1092,7 +1572,7 @@ export class McpDispatchServer {
1092
1572
  }
1093
1573
  const controller = new AbortController();
1094
1574
  const signal = AbortSignal.any([AbortSignal.timeout(opts.timeoutMs), controller.signal]);
1095
- const tag = newActivityTag();
1575
+ const tag = createLaneActivityTag();
1096
1576
  this.activityTags.set(jobId, tag);
1097
1577
  const result = this.runAnswerFetch(lane.spec, task, opts, signal, tag).then((o) => {
1098
1578
  const semanticFailure = o.semanticFailure === undefined && !o.run.timedOut && isContentEmpty(o.run.stdout)
@@ -1121,14 +1601,20 @@ export class McpDispatchServer {
1121
1601
  kill: () => { },
1122
1602
  };
1123
1603
  }
1604
+ const broker = await this.tryStartBrokerLane(jobId, lane, task, opts, declared);
1605
+ if (broker !== null && !("fallback" in broker))
1606
+ return broker;
1607
+ if (broker !== null && "fallback" in broker) {
1608
+ this.jobs.noteExecutionOwner(jobId, "local-fallback");
1609
+ }
1124
1610
  const agy = agyWorkingDirInvoke(declared, opts.cwd);
1125
1611
  const invoke = agy?.invoke ?? declared;
1126
- const expansion = expandEnvReferences(applyLaneEnv(process.env, invoke.env), this.deps.platform);
1612
+ const expansion = expandEnvReferences(buildLaneEnv(process.env, invoke.env, this.deps.config, this.deps.platform), this.deps.platform);
1127
1613
  const env = expansion.env;
1128
1614
  env[DEPTH_ENV] = String(opts.depth + 1);
1129
- const tag = newActivityTag();
1615
+ const tag = createLaneActivityTag();
1130
1616
  this.activityTags.set(jobId, tag);
1131
- env["ANTHROPIC_CUSTOM_HEADERS"] = withActivityHeader(env["ANTHROPIC_CUSTOM_HEADERS"], tag);
1617
+ env["ANTHROPIC_CUSTOM_HEADERS"] = withLaneActivityHeader(env["ANTHROPIC_CUSTOM_HEADERS"], tag);
1132
1618
  this.jobs.noteLaunch(jobId, [...expansion.notes, ...(agy === null ? [] : [agy.note])]);
1133
1619
  const startedAt = this.now();
1134
1620
  this.jobs.beginAttemptActivity(jobId, startedAt);