agent-relay-orchestrator 0.113.0 → 0.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.113.0",
3
+ "version": "0.115.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "agent-relay-providers": "0.104.1",
20
- "agent-relay-sdk": "0.2.96",
20
+ "agent-relay-sdk": "0.2.97",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -0,0 +1,43 @@
1
+ import type { RelayClient, RelayCommand } from "./relay";
2
+
3
+ interface CommandPollerControl {
4
+ handleCommand(command: RelayCommand): Promise<boolean>;
5
+ }
6
+
7
+ interface CommandPollerOptions {
8
+ relay: Pick<RelayClient, "connected" | "pollCommands">;
9
+ control: CommandPollerControl;
10
+ log?: (message: string) => void;
11
+ }
12
+
13
+ export function createCommandPoller({ relay, control, log = console.error }: CommandPollerOptions) {
14
+ let inFlight = false;
15
+
16
+ async function tick(): Promise<boolean> {
17
+ if (!relay.connected || inFlight) return false;
18
+ inFlight = true;
19
+ try {
20
+ const commands = await relay.pollCommands();
21
+ if (commands.length > 0) {
22
+ log(`[orchestrator] Received ${commands.length} command(s)`);
23
+ }
24
+ for (const command of commands) {
25
+ log(`[orchestrator] Handling command: ${command.type} ${command.id}`);
26
+ await control.handleCommand(command);
27
+ }
28
+ return true;
29
+ } catch (err) {
30
+ log(`[orchestrator] Poll error: ${err}`);
31
+ return false;
32
+ } finally {
33
+ inFlight = false;
34
+ }
35
+ }
36
+
37
+ return {
38
+ tick,
39
+ get inFlight() {
40
+ return inFlight;
41
+ },
42
+ };
43
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { sweepEmptyWorkspaceContainers, workspacesRoot } from "./workspace-probe
12
12
  import { startOrchestratorMaintenanceScheduler } from "./maintenance";
13
13
  import { OrchestratorQuotaPoller } from "./quota-poller";
14
14
  import { SharedCallmuxSupervisor } from "./shared-callmux";
15
+ import { createCommandPoller } from "./command-poller";
15
16
 
16
17
  const args = process.argv.slice(2);
17
18
 
@@ -123,20 +124,9 @@ async function startup(): Promise<void> {
123
124
  }
124
125
 
125
126
  function startPolling(): void {
127
+ const commandPoller = createCommandPoller({ relay, control });
126
128
  pollTimer = setInterval(async () => {
127
- if (!relay.connected) return;
128
- try {
129
- const commands = await relay.pollCommands();
130
- if (commands.length > 0) {
131
- console.error(`[orchestrator] Received ${commands.length} command(s)`);
132
- }
133
- for (const command of commands) {
134
- console.error(`[orchestrator] Handling command: ${command.type} ${command.id}`);
135
- await control.handleCommand(command);
136
- }
137
- } catch (err) {
138
- console.error(`[orchestrator] Poll error: ${err}`);
139
- }
129
+ await commandPoller.tick();
140
130
  }, POLL_INTERVAL_MS);
141
131
  }
142
132
 
@@ -313,11 +313,20 @@ function callmuxServerFromProvisioningVariant(value: unknown): { name: string; s
313
313
  const metadata = isRecord(definition.metadata) ? definition.metadata : {};
314
314
  const provenance = isRecord(value.provenance) ? value.provenance : {};
315
315
  if (metadata.sharedListenerEligible !== true && provenance.sharedListenerEligible !== true) return null;
316
- const callmuxServer = isRecord(metadata.callmuxServer) ? metadata.callmuxServer : callmuxServerFromMcpServer(definition.server);
317
- const server = cloneServer(callmuxServer);
316
+ const rawCallmuxServer = isRecord(metadata.callmuxServer) ? metadata.callmuxServer : callmuxServerFromMcpServer(definition.server);
317
+ const server = cloneServer(mergeConfigEnv(rawCallmuxServer));
318
318
  return server ? { name: value.name, server } : null;
319
319
  }
320
320
 
321
+ function mergeConfigEnv(server: Record<string, unknown>): Record<string, unknown> {
322
+ if (!isRecord(server.configEnv)) return server;
323
+ const { configEnv, ...rest } = server;
324
+ return {
325
+ ...rest,
326
+ env: { ...(configEnv as Record<string, string>), ...(isRecord(rest.env) ? (rest.env as Record<string, string>) : {}) },
327
+ };
328
+ }
329
+
321
330
  function callmuxServerFromMcpServer(server: Record<string, unknown>): Record<string, unknown> {
322
331
  const out: Record<string, unknown> = {};
323
332
  if (typeof server.command === "string") {
@@ -3,15 +3,45 @@ import type { OrchestratorConfig } from "../config";
3
3
  import { resolveSpawnWorkspace, workspacesRoot } from "../workspace-probe";
4
4
  import type { ManagedAgentReport } from "../relay";
5
5
  import { buildEnv, buildRunnerCommand, defaultSpawnLabel, isWithinBaseDir, sessionName } from "./command";
6
- import { addSessionRecord, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, sessionReportFields } from "./runtime";
6
+ import { addSessionRecord, currentSessionPid, findSessionRecord, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, sessionRecordLiveness, sessionReportFields } from "./runtime";
7
7
  import { managedAgentId } from "./sessions";
8
8
  import { spawnRunner } from "./supervisor";
9
9
  import type { SpawnOptions } from "./types";
10
10
 
11
+ interface SpawnAgentDeps {
12
+ resolveSpawnWorkspace: typeof resolveSpawnWorkspace;
13
+ spawnRunner: typeof spawnRunner;
14
+ addSessionRecord: typeof addSessionRecord;
15
+ findSessionRecord: typeof findSessionRecord;
16
+ sessionRecordLiveness: typeof sessionRecordLiveness;
17
+ currentSessionPid: typeof currentSessionPid;
18
+ sessionReportFields: typeof sessionReportFields;
19
+ ensureLogDir: typeof ensureLogDir;
20
+ ensureRunnerInfoDir: typeof ensureRunnerInfoDir;
21
+ logFilePath: typeof logFilePath;
22
+ runnerInfoPath: typeof runnerInfoPath;
23
+ }
24
+
25
+ const defaultSpawnAgentDeps: SpawnAgentDeps = {
26
+ resolveSpawnWorkspace,
27
+ spawnRunner,
28
+ addSessionRecord,
29
+ findSessionRecord,
30
+ sessionRecordLiveness,
31
+ currentSessionPid,
32
+ sessionReportFields,
33
+ ensureLogDir,
34
+ ensureRunnerInfoDir,
35
+ logFilePath,
36
+ runnerInfoPath,
37
+ };
38
+
11
39
  export async function spawnAgent(
12
40
  opts: SpawnOptions,
13
41
  config: OrchestratorConfig,
42
+ deps: Partial<SpawnAgentDeps> = {},
14
43
  ): Promise<ManagedAgentReport> {
44
+ const d = { ...defaultSpawnAgentDeps, ...deps };
15
45
  const label = opts.label || defaultSpawnLabel();
16
46
  const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
17
47
  const name = sessionName(config, opts.provider, label, opts.spawnRequestId ?? agentId);
@@ -22,21 +52,27 @@ export async function spawnAgent(
22
52
  if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
23
53
  throw new Error(`cwd must be within base directory: ${config.baseDir}`);
24
54
  }
55
+ const existing = existingSpawnSession(opts, d);
56
+ if (existing) return existing;
25
57
 
26
- const resolvedWorkspace = await resolveSpawnWorkspace({
58
+ const resolvedWorkspace = await d.resolveSpawnWorkspace({
27
59
  ...opts,
28
60
  label,
29
61
  workspaceSymlinks: opts.workspaceSymlinks,
30
62
  workspaceRoot: workspacesRoot(config.baseDir),
31
63
  });
64
+ if (resolvedWorkspace.reusedExisting) {
65
+ const existingAfterPrep = existingSpawnSession(opts, d);
66
+ if (existingAfterPrep) return existingAfterPrep;
67
+ }
32
68
  const spawnOpts = { ...opts, label, agentId, cwd: resolvedWorkspace.cwd, workspace: resolvedWorkspace.workspace };
33
69
 
34
70
  const command = buildRunnerCommand(spawnOpts, config);
35
71
 
36
- ensureLogDir();
37
- ensureRunnerInfoDir();
38
- const logFile = logFilePath(name);
39
- const runnerInfoFile = runnerInfoPath(name);
72
+ d.ensureLogDir();
73
+ d.ensureRunnerInfoDir();
74
+ const logFile = d.logFilePath(name);
75
+ const runnerInfoFile = d.runnerInfoPath(name);
40
76
  rmSync(runnerInfoFile, { force: true });
41
77
  const env = buildEnv({ ...spawnOpts, env: { ...(spawnOpts.env ?? {}), AGENT_RELAY_RUNNER_INFO_FILE: runnerInfoFile } }, config, logFile, name);
42
78
  const logFd = openSync(logFile, "w");
@@ -48,9 +84,9 @@ export async function spawnAgent(
48
84
 
49
85
  closeSync(logFd);
50
86
 
51
- const runner = spawnRunner(name, command, spawnOpts.cwd, env, logFile);
87
+ const runner = d.spawnRunner(name, command, spawnOpts.cwd, env, logFile);
52
88
 
53
- addSessionRecord({
89
+ d.addSessionRecord({
54
90
  name,
55
91
  pid: runner.pid,
56
92
  supervisor: runner.supervisor,
@@ -81,7 +117,7 @@ export async function spawnAgent(
81
117
  profile: spawnOpts.profile,
82
118
  workspaceMode: spawnOpts.workspaceMode, lifecycle: spawnOpts.lifecycle ?? "persistent",
83
119
  workspace: spawnOpts.workspace,
84
- ...sessionReportFields({ name, supervisor: runner.supervisor, runnerInfoFile, agentId, provider: spawnOpts.provider }),
120
+ ...d.sessionReportFields({ name, supervisor: runner.supervisor, runnerInfoFile, agentId, provider: spawnOpts.provider }),
85
121
  cwd: spawnOpts.cwd,
86
122
  label,
87
123
  approvalMode: spawnOpts.approvalMode || "guarded",
@@ -92,3 +128,33 @@ export async function spawnAgent(
92
128
  startedAt: Date.now(),
93
129
  };
94
130
  }
131
+
132
+ function existingSpawnSession(opts: SpawnOptions, deps: Pick<SpawnAgentDeps, "findSessionRecord" | "sessionRecordLiveness" | "currentSessionPid" | "sessionReportFields">): ManagedAgentReport | undefined {
133
+ if (!opts.spawnRequestId) return undefined;
134
+ const record = deps.findSessionRecord({ spawnRequestId: opts.spawnRequestId, policyName: opts.policyName });
135
+ if (!record) return undefined;
136
+ const liveness = deps.sessionRecordLiveness(record);
137
+ if (liveness === "dead") return undefined;
138
+ const pid = deps.currentSessionPid(record);
139
+ return {
140
+ agentId: record.agentId,
141
+ provider: record.provider as ManagedAgentReport["provider"],
142
+ model: record.model,
143
+ effort: record.effort,
144
+ profile: record.profile,
145
+ workspaceMode: record.workspaceMode,
146
+ lifecycle: record.lifecycle ?? "persistent",
147
+ workspace: record.workspace,
148
+ ...deps.sessionReportFields(record),
149
+ cwd: record.cwd,
150
+ label: record.label,
151
+ approvalMode: record.approvalMode || "guarded",
152
+ policyName: record.policyName,
153
+ spawnRequestId: record.spawnRequestId,
154
+ automationRunId: record.automationRunId,
155
+ pid,
156
+ startedAt: record.startedAt,
157
+ };
158
+ }
159
+
160
+ export type { SpawnAgentDeps };
@@ -91,9 +91,24 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
91
91
  const repoRoot = probe.repoRoot;
92
92
  const baseSha = requireGit(["rev-parse", resume.branch], repoRoot);
93
93
  const id = workspaceId(input);
94
- const branch = await availableBranch(repoRoot, branchName(input, id));
95
94
  const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
96
95
  const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
96
+ const existing = existingRegisteredWorktree(repoRoot, worktreePath);
97
+ if (existing) {
98
+ return existingWorkspaceResolution({
99
+ id,
100
+ repoRoot,
101
+ sourceCwd,
102
+ worktreePath,
103
+ branch: existing.branch,
104
+ baseRef: resume.branch,
105
+ baseSha,
106
+ requestedMode,
107
+ probe,
108
+ });
109
+ }
110
+
111
+ const branch = await availableBranch(repoRoot, branchName(input, id));
97
112
  mkdirSync(join(worktreePath, ".."), { recursive: true });
98
113
  requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
99
114
  const deps = provisionWorkspaceDeps(repoRoot, worktreePath);
@@ -122,9 +137,24 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
122
137
  const repoRoot = probe.repoRoot;
123
138
  const baseRef = terminalBaseRef(repoRoot, probe.branch);
124
139
  const baseSha = probe.headSha ?? requireGit(["rev-parse", "HEAD"], repoRoot);
125
- const branch = await availableBranch(repoRoot, branchName(input, id));
126
140
  const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
127
141
  const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
142
+ const existing = existingRegisteredWorktree(repoRoot, worktreePath);
143
+ if (existing) {
144
+ return existingWorkspaceResolution({
145
+ id,
146
+ repoRoot,
147
+ sourceCwd,
148
+ worktreePath,
149
+ branch: existing.branch,
150
+ baseRef,
151
+ baseSha,
152
+ requestedMode,
153
+ probe,
154
+ });
155
+ }
156
+
157
+ const branch = await availableBranch(repoRoot, branchName(input, id));
128
158
  mkdirSync(join(worktreePath, ".."), { recursive: true });
129
159
  requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
130
160
 
@@ -156,3 +186,45 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
156
186
  },
157
187
  };
158
188
  }
189
+
190
+ function existingRegisteredWorktree(repoRoot: string, worktreePath: string): { branch?: string } | undefined {
191
+ const resolvedPath = resolve(worktreePath);
192
+ if (!existsSync(resolvedPath)) return undefined;
193
+ const listed = git(["worktree", "list", "--porcelain"], repoRoot);
194
+ if (!listed.ok) return undefined;
195
+ const match = parseWorktrees(listed.stdout).find((worktree) => resolve(worktree.path) === resolvedPath);
196
+ if (!match) return undefined;
197
+ const topLevel = git(["rev-parse", "--show-toplevel"], resolvedPath);
198
+ if (!topLevel.ok || resolve(topLevel.stdout) !== resolvedPath) return undefined;
199
+ return { branch: match.branch };
200
+ }
201
+
202
+ function existingWorkspaceResolution(input: {
203
+ id: string;
204
+ repoRoot: string;
205
+ sourceCwd: string;
206
+ worktreePath: string;
207
+ branch?: string;
208
+ baseRef?: string;
209
+ baseSha?: string;
210
+ requestedMode: WorkspaceMode | "inherit";
211
+ probe: WorkspaceProbe;
212
+ }): WorkspaceResolution {
213
+ return {
214
+ cwd: input.worktreePath,
215
+ reusedExisting: true,
216
+ workspace: {
217
+ id: input.id,
218
+ mode: "isolated",
219
+ requestedMode: input.requestedMode,
220
+ repoRoot: input.repoRoot,
221
+ sourceCwd: input.sourceCwd,
222
+ worktreePath: input.worktreePath,
223
+ ...(input.branch ? { branch: input.branch } : {}),
224
+ ...(input.baseRef ? { baseRef: input.baseRef } : {}),
225
+ ...(input.baseSha ? { baseSha: input.baseSha } : {}),
226
+ status: "active",
227
+ probe: input.probe,
228
+ },
229
+ };
230
+ }
@@ -33,6 +33,7 @@ export interface WorkspaceResolutionInput {
33
33
  export interface WorkspaceResolution {
34
34
  cwd: string;
35
35
  workspace: WorkspaceMetadata;
36
+ reusedExisting?: boolean;
36
37
  }
37
38
 
38
39
  export interface WorkspaceMergeInput {