agent-relay-orchestrator 0.90.0 → 0.91.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.90.0",
3
+ "version": "0.91.1",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.69"
19
+ "agent-relay-sdk": "0.2.70"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { errMessage } from "agent-relay-sdk";
2
3
  import { loadConfig, initConfigFile } from "./config";
3
4
  import { createRelayClient } from "./relay";
4
5
  import type { ManagedSessionExitDiagnostics } from "./relay";
@@ -10,6 +11,7 @@ import { ProviderProbeCache } from "./provider-probe";
10
11
  import { sweepEmptyWorkspaceContainers, workspacesRoot } from "./workspace-probe";
11
12
  import { startOrchestratorMaintenanceScheduler } from "./maintenance";
12
13
  import { OrchestratorQuotaPoller } from "./quota-poller";
14
+ import { SharedCallmuxSupervisor } from "./shared-callmux";
13
15
 
14
16
  const args = process.argv.slice(2);
15
17
 
@@ -52,6 +54,7 @@ const probeCache = new ProviderProbeCache(config);
52
54
  const relay = createRelayClient(config, probeCache);
53
55
  const control = createControlHandler(config, relay);
54
56
  const quotaPoller = new OrchestratorQuotaPoller(config, relay);
57
+ const sharedCallmux = new SharedCallmuxSupervisor(config);
55
58
 
56
59
  const POLL_INTERVAL_MS = 3_000;
57
60
  const REGISTER_RETRY_MS = 5_000;
@@ -84,6 +87,11 @@ async function startup(): Promise<void> {
84
87
  // Host-local maintenance must run where the agent processes and tmux sockets live.
85
88
  startOrchestratorMaintenanceScheduler();
86
89
  quotaPoller.start();
90
+ try {
91
+ sharedCallmux.start();
92
+ } catch (err) {
93
+ console.error(`[orchestrator] Shared callmux supervisor failed to start: ${errMessage(err)}`);
94
+ }
87
95
 
88
96
  // Sweep empty workspace container dirs left behind by prior cleanups (#280).
89
97
  const swept = sweepEmptyWorkspaceContainers(workspacesRoot(config.baseDir));
@@ -211,6 +219,7 @@ async function shutdown(): Promise<void> {
211
219
  if (guestReaperTimer) clearInterval(guestReaperTimer);
212
220
  if (apiServer) apiServer.stop();
213
221
  quotaPoller.stop();
222
+ sharedCallmux.stop();
214
223
  relay.stopHeartbeatLoop();
215
224
  process.exit(0);
216
225
  }
@@ -0,0 +1,300 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { errMessage, isRecord } from "agent-relay-sdk";
5
+ import type { OrchestratorConfig } from "./config";
6
+ import { agentRelayHome } from "./config";
7
+
8
+ export const SHARED_MCP_URL_ENV = "AGENT_RELAY_SHARED_MCP_URL";
9
+ export const SHARED_CALLMUX_HOST_ENV = "AGENT_RELAY_SHARED_CALLMUX_HOST";
10
+ export const SHARED_CALLMUX_PORT_ENV = "AGENT_RELAY_SHARED_CALLMUX_PORT";
11
+ export const SHARED_CALLMUX_CONFIG_ENV = "AGENT_RELAY_SHARED_CALLMUX_CONFIG";
12
+ export const SHARED_CALLMUX_SOURCE_CONFIG_ENV = "AGENT_RELAY_SHARED_CALLMUX_SOURCE_CONFIG";
13
+ export const SHARED_CALLMUX_COMMAND_ENV = "AGENT_RELAY_SHARED_CALLMUX_COMMAND";
14
+ export const SHARED_CALLMUX_ENABLE_ENV = "AGENT_RELAY_SHARED_CALLMUX_ENABLE";
15
+
16
+ export const DEFAULT_SHARED_CALLMUX_HOST = "127.0.0.1";
17
+ export const DEFAULT_SHARED_CALLMUX_PORT = 4861;
18
+ export const SHARED_CALLMUX_SERVER_NAMES = ["tokenlean", "github"] as const;
19
+
20
+ const CONFIG_SCHEMA = "https://raw.githubusercontent.com/edimuj/callmux/main/schema.json";
21
+ const GITHUB_TOOLS = [
22
+ "issue_read",
23
+ "issue_write",
24
+ "list_issues",
25
+ "add_issue_comment",
26
+ "search_issues",
27
+ "search_code",
28
+ "get_file_contents",
29
+ "sub_issue_write",
30
+ ];
31
+
32
+ export interface SharedCallmuxOptions {
33
+ command: string;
34
+ host: string;
35
+ port: number;
36
+ url: string;
37
+ configPath: string;
38
+ sourceConfigPath: string;
39
+ enabled: boolean;
40
+ }
41
+
42
+ export interface SharedCallmuxProcess {
43
+ pid?: number;
44
+ exited: Promise<number | null>;
45
+ kill(signal?: NodeJS.Signals): void;
46
+ }
47
+
48
+ export interface SharedCallmuxSupervisorDeps {
49
+ which(command: string): string | null | undefined;
50
+ spawn(command: string, args: string[], opts: { env: Record<string, string>; cwd: string }): SharedCallmuxProcess;
51
+ fetch(url: string, init?: RequestInit): Promise<Response>;
52
+ setInterval(fn: () => void, ms: number): Timer;
53
+ clearInterval(timer: Timer): void;
54
+ setTimeout(fn: () => void, ms: number): Timer;
55
+ clearTimeout(timer: Timer): void;
56
+ log(message: string): void;
57
+ }
58
+
59
+ export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefined> = process.env): SharedCallmuxOptions {
60
+ const explicitUrl = env[SHARED_MCP_URL_ENV];
61
+ const parsed = explicitUrl ? parseListenerUrl(explicitUrl) : undefined;
62
+ const host = env[SHARED_CALLMUX_HOST_ENV] ?? parsed?.hostname ?? DEFAULT_SHARED_CALLMUX_HOST;
63
+ const port = numberEnv(env[SHARED_CALLMUX_PORT_ENV]) ?? parsed?.port ?? DEFAULT_SHARED_CALLMUX_PORT;
64
+ const url = explicitUrl ?? `http://${host}:${port}/mcp`;
65
+ return {
66
+ command: env[SHARED_CALLMUX_COMMAND_ENV] || "callmux",
67
+ host,
68
+ port,
69
+ url,
70
+ configPath: env[SHARED_CALLMUX_CONFIG_ENV] || join(agentRelayHome(), "callmux", "shared-listener.json"),
71
+ sourceConfigPath: env[SHARED_CALLMUX_SOURCE_CONFIG_ENV] || env.CALLMUX_CONFIG || join(homedir(), ".config", "callmux", "config.json"),
72
+ enabled: env[SHARED_CALLMUX_ENABLE_ENV] === "1",
73
+ };
74
+ }
75
+
76
+ export function sharedMcpListenerUrl(): string {
77
+ return sharedCallmuxOptionsFromEnv().url;
78
+ }
79
+
80
+ export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "configPath" | "sourceConfigPath">): Record<string, unknown> {
81
+ const source = readJsonObject(opts.sourceConfigPath);
82
+ const sourceServers = isRecord(source.servers) ? source.servers : {};
83
+ const servers = {
84
+ tokenlean: cloneServer(sourceServers.tokenlean) ?? defaultTokenleanServer(),
85
+ github: cloneServer(sourceServers.github) ?? defaultGithubServer(),
86
+ };
87
+ const generated: Record<string, unknown> = {
88
+ $schema: CONFIG_SCHEMA,
89
+ servers,
90
+ cacheTtlSeconds: numberFromRecord(source, "cacheTtlSeconds") ?? 10,
91
+ maxConcurrency: numberFromRecord(source, "maxConcurrency") ?? 20,
92
+ callTimeoutMs: numberFromRecord(source, "callTimeoutMs") ?? 180_000,
93
+ outputFormat: stringFromRecord(source, "outputFormat") ?? "auto",
94
+ };
95
+ mkdirSync(dirname(opts.configPath), { recursive: true });
96
+ writeFileSync(opts.configPath, JSON.stringify(generated, null, 2) + "\n", { mode: 0o600 });
97
+ return generated;
98
+ }
99
+
100
+ export class SharedCallmuxSupervisor {
101
+ private proc: SharedCallmuxProcess | null = null;
102
+ private healthTimer: Timer | null = null;
103
+ private restartTimer: Timer | null = null;
104
+ private stopping = false;
105
+ private backoffMs: number;
106
+
107
+ constructor(
108
+ private readonly config: OrchestratorConfig,
109
+ private readonly opts = sharedCallmuxOptionsFromEnv(),
110
+ private readonly deps: SharedCallmuxSupervisorDeps = defaultDeps(),
111
+ private readonly timing: { healthIntervalMs?: number; restartBaseMs?: number; restartMaxMs?: number } = {},
112
+ ) {
113
+ this.backoffMs = timing.restartBaseMs ?? 1_000;
114
+ }
115
+
116
+ start(): void {
117
+ if (!this.opts.enabled) {
118
+ this.deps.log("[orchestrator] shared callmux listener disabled (set AGENT_RELAY_SHARED_CALLMUX_ENABLE=1 to enable)");
119
+ return;
120
+ }
121
+ this.deps.log(`[orchestrator] Shared callmux listener: ${this.opts.url}`);
122
+ if (!this.deps.which(this.opts.command)) {
123
+ this.deps.log("[orchestrator] shared callmux not found — shared listener dormant");
124
+ return;
125
+ }
126
+ this.spawn();
127
+ this.healthTimer = this.deps.setInterval(() => {
128
+ void this.checkHealth();
129
+ }, this.timing.healthIntervalMs ?? 10_000);
130
+ }
131
+
132
+ stop(): void {
133
+ this.stopping = true;
134
+ if (this.healthTimer) this.deps.clearInterval(this.healthTimer);
135
+ if (this.restartTimer) this.deps.clearTimeout(this.restartTimer);
136
+ this.healthTimer = null;
137
+ this.restartTimer = null;
138
+ if (this.proc) {
139
+ this.proc.kill("SIGTERM");
140
+ this.proc = null;
141
+ }
142
+ }
143
+
144
+ async checkHealth(): Promise<boolean> {
145
+ if (!this.opts.enabled) return true;
146
+ const readyUrl = new URL("/ready", this.opts.url).toString();
147
+ let ok = false;
148
+ try {
149
+ const response = await this.deps.fetch(readyUrl, { signal: AbortSignal.timeout(2_000) });
150
+ ok = response.ok;
151
+ } catch {
152
+ ok = false;
153
+ }
154
+ if (ok) {
155
+ this.backoffMs = this.timing.restartBaseMs ?? 1_000;
156
+ return true;
157
+ }
158
+ this.deps.log(`[orchestrator] Shared callmux readiness failed at ${readyUrl}; restarting`);
159
+ this.restart("readiness failed");
160
+ return false;
161
+ }
162
+
163
+ private spawn(): void {
164
+ if (this.stopping || this.proc) return;
165
+ let proc: SharedCallmuxProcess;
166
+ try {
167
+ writeSharedCallmuxConfig(this.opts);
168
+ const args = ["--listen", String(this.opts.port), "--host", this.opts.host, "--config", this.opts.configPath];
169
+ const env = {
170
+ ...process.env as Record<string, string>,
171
+ ...this.config.env,
172
+ [SHARED_MCP_URL_ENV]: this.opts.url,
173
+ };
174
+ proc = this.deps.spawn(this.opts.command, args, { env, cwd: homedir() });
175
+ } catch (err) {
176
+ this.deps.log(`[orchestrator] Shared callmux listener failed to start: ${errMessage(err)}; scheduling restart`);
177
+ this.scheduleRestart();
178
+ return;
179
+ }
180
+ this.proc = proc;
181
+ this.deps.log(`[orchestrator] Started shared callmux listener pid=${proc.pid ?? "unknown"}`);
182
+ proc.exited.then((code) => {
183
+ if (this.proc !== proc || this.stopping) return;
184
+ this.proc = null;
185
+ this.deps.log(`[orchestrator] Shared callmux listener exited (${code ?? "signal"}); scheduling restart`);
186
+ this.scheduleRestart();
187
+ }).catch((err) => {
188
+ if (this.proc !== proc || this.stopping) return;
189
+ this.proc = null;
190
+ this.deps.log(`[orchestrator] Shared callmux listener exit watcher failed: ${err}`);
191
+ this.scheduleRestart();
192
+ });
193
+ }
194
+
195
+ private restart(reason: string): void {
196
+ if (this.proc) {
197
+ this.deps.log(`[orchestrator] Stopping shared callmux listener: ${reason}`);
198
+ this.proc.kill("SIGTERM");
199
+ this.proc = null;
200
+ }
201
+ this.scheduleRestart();
202
+ }
203
+
204
+ private scheduleRestart(): void {
205
+ if (this.stopping || this.restartTimer) return;
206
+ const delay = this.backoffMs;
207
+ this.backoffMs = Math.min(this.backoffMs * 2, this.timing.restartMaxMs ?? 30_000);
208
+ this.restartTimer = this.deps.setTimeout(() => {
209
+ this.restartTimer = null;
210
+ this.spawn();
211
+ }, delay);
212
+ }
213
+ }
214
+
215
+ function defaultDeps(): SharedCallmuxSupervisorDeps {
216
+ return {
217
+ which: resolveCommand,
218
+ spawn(command, args, opts) {
219
+ const proc = Bun.spawn([command, ...args], {
220
+ cwd: opts.cwd,
221
+ env: opts.env,
222
+ stdin: "ignore",
223
+ stdout: "inherit",
224
+ stderr: "inherit",
225
+ });
226
+ return {
227
+ pid: proc.pid,
228
+ exited: proc.exited,
229
+ kill: (signal?: NodeJS.Signals) => proc.kill(signal),
230
+ };
231
+ },
232
+ fetch,
233
+ setInterval: (fn, ms) => setInterval(fn, ms),
234
+ clearInterval: (timer) => clearInterval(timer),
235
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
236
+ clearTimeout: (timer) => clearTimeout(timer),
237
+ log: (message) => console.error(message),
238
+ };
239
+ }
240
+
241
+ function resolveCommand(command: string): string | null {
242
+ if (isAbsolute(command)) return existsSync(command) ? command : null;
243
+ if (command.includes("/")) return existsSync(command) ? command : null;
244
+ return Bun.which(command);
245
+ }
246
+
247
+ function readJsonObject(path: string): Record<string, unknown> {
248
+ if (!existsSync(path)) return {};
249
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
250
+ return isRecord(parsed) ? parsed : {};
251
+ }
252
+
253
+ function cloneServer(value: unknown): Record<string, unknown> | undefined {
254
+ if (!isRecord(value)) return undefined;
255
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
256
+ }
257
+
258
+ function defaultTokenleanServer(): Record<string, unknown> {
259
+ return {
260
+ command: "tl-mcp",
261
+ prefix: "",
262
+ alwaysLoad: ["tl_symbols", "tl_snippet", "tl_pack", "tl_run", "tl_guard", "tl_lookup"],
263
+ requireSessionCwd: true,
264
+ };
265
+ }
266
+
267
+ function defaultGithubServer(): Record<string, unknown> {
268
+ return {
269
+ command: "github-mcp-server",
270
+ args: ["stdio"],
271
+ prefix: "gh",
272
+ callTimeoutMs: 60_000,
273
+ tools: GITHUB_TOOLS,
274
+ cachePolicy: { allowTools: ["issue_read", "list_issues", "search_issues", "search_code", "get_file_contents"] },
275
+ };
276
+ }
277
+
278
+ function numberEnv(value: string | undefined): number | undefined {
279
+ if (!value) return undefined;
280
+ const parsed = Number(value);
281
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
282
+ }
283
+
284
+ function numberFromRecord(value: Record<string, unknown>, key: string): number | undefined {
285
+ return typeof value[key] === "number" ? value[key] : undefined;
286
+ }
287
+
288
+ function stringFromRecord(value: Record<string, unknown>, key: string): string | undefined {
289
+ return typeof value[key] === "string" ? value[key] : undefined;
290
+ }
291
+
292
+ function parseListenerUrl(value: string): { hostname: string; port: number } | undefined {
293
+ try {
294
+ const url = new URL(value);
295
+ const port = Number(url.port) || (url.protocol === "https:" ? 443 : 80);
296
+ return { hostname: url.hostname, port };
297
+ } catch {
298
+ return undefined;
299
+ }
300
+ }
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { artifactProxyBaseUrl } from "../artifact-proxy";
5
5
  import { bunBinFromEnv, type OrchestratorConfig } from "../config";
6
+ import { SHARED_MCP_URL_ENV, sharedMcpListenerUrl } from "../shared-callmux";
6
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
7
8
  import type { SpawnOptions } from "./types";
8
9
 
@@ -92,6 +93,9 @@ export function buildEnv(opts: SpawnOptions & { label: string; agentId: string }
92
93
  ...(opts.label ? { AGENT_RELAY_LABEL: opts.label } : {}),
93
94
  ...(opts.policyName ? { AGENT_RELAY_POLICY: opts.policyName } : {}),
94
95
  ...(opts.spawnRequestId ? { AGENT_RELAY_SPAWN_REQUEST_ID: opts.spawnRequestId } : {}),
96
+ // #673 — the orchestrator owns the shared host callmux listener; runners consume this
97
+ // authoritative URL through the existing #672 sharedMcpUrl seam.
98
+ [SHARED_MCP_URL_ENV]: sharedMcpListenerUrl(),
95
99
  AGENT_RELAY_LIFECYCLE: opts.lifecycle ?? "persistent", AGENT_RELAY_WORKSPACE_MODE: opts.workspaceMode ?? "inherit",
96
100
  ...(opts.workspace ? { AGENT_RELAY_WORKSPACE_JSON: JSON.stringify(opts.workspace) } : {}),
97
101
  ...(opts.automationId ? { AGENT_RELAY_AUTOMATION_ID: opts.automationId } : {}),