agent-relay-orchestrator 0.112.0 → 0.114.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 +2 -2
- package/src/command-poller.ts +43 -0
- package/src/index.ts +3 -13
- package/src/shared-callmux.ts +107 -14
- package/src/spawn/spawn-agent.ts +75 -9
- package/src/workspace-probe/probe.ts +74 -2
- package/src/workspace-probe/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.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.
|
|
20
|
+
"agent-relay-sdk": "0.2.96",
|
|
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
|
-
|
|
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
|
|
package/src/shared-callmux.ts
CHANGED
|
@@ -15,7 +15,6 @@ export const SHARED_CALLMUX_ENABLE_ENV = "AGENT_RELAY_SHARED_CALLMUX_ENABLE";
|
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_SHARED_CALLMUX_HOST = "127.0.0.1";
|
|
17
17
|
export const DEFAULT_SHARED_CALLMUX_PORT = 4861;
|
|
18
|
-
export const SHARED_CALLMUX_SERVER_NAMES = ["tokenlean", "github"] as const;
|
|
19
18
|
|
|
20
19
|
const CONFIG_SCHEMA = "callmux/schema.json";
|
|
21
20
|
const GITHUB_TOOLS = [
|
|
@@ -44,6 +43,7 @@ export interface SharedCallmuxSupervisorDeps {
|
|
|
44
43
|
clearInterval(timer: Timer): void;
|
|
45
44
|
setTimeout(fn: () => void, ms: number): Timer;
|
|
46
45
|
clearTimeout(timer: Timer): void;
|
|
46
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
47
47
|
log(message: string): void;
|
|
48
48
|
report(snapshot: SharedCallmuxHealthSnapshot): void;
|
|
49
49
|
}
|
|
@@ -67,7 +67,7 @@ export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefin
|
|
|
67
67
|
url,
|
|
68
68
|
configPath: env[SHARED_CALLMUX_CONFIG_ENV] || join(agentRelayHome(), "callmux", "shared-listener.json"),
|
|
69
69
|
sourceConfigPath: env[SHARED_CALLMUX_SOURCE_CONFIG_ENV] || env.CALLMUX_CONFIG || join(homedir(), ".config", "callmux", "config.json"),
|
|
70
|
-
|
|
70
|
+
enabled: !envOff(env[SHARED_CALLMUX_ENABLE_ENV]),
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -75,13 +75,12 @@ export function sharedMcpListenerUrl(): string {
|
|
|
75
75
|
return sharedCallmuxOptionsFromEnv().url;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "configPath" | "sourceConfigPath">): CallmuxConfig {
|
|
78
|
+
export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "configPath" | "sourceConfigPath"> & { registryServers?: CallmuxConfig["servers"] }): CallmuxConfig {
|
|
79
79
|
const source = readJsonObject(opts.sourceConfigPath);
|
|
80
80
|
const sourceServers = isRecord(source.servers) ? source.servers : {};
|
|
81
|
-
const servers
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
};
|
|
81
|
+
const servers = opts.registryServers && Object.keys(opts.registryServers).length > 0
|
|
82
|
+
? cloneServers(opts.registryServers)
|
|
83
|
+
: fallbackSharedServers(sourceServers);
|
|
85
84
|
const generated: CallmuxConfig = {
|
|
86
85
|
servers,
|
|
87
86
|
cacheTtlSeconds: numberFromRecord(source, "cacheTtlSeconds") ?? 10,
|
|
@@ -97,6 +96,36 @@ export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "confi
|
|
|
97
96
|
return generated;
|
|
98
97
|
}
|
|
99
98
|
|
|
99
|
+
export async function fetchSharedCallmuxRegistryServers(
|
|
100
|
+
config: Pick<OrchestratorConfig, "relayUrl" | "token">,
|
|
101
|
+
deps: Pick<SharedCallmuxSupervisorDeps, "fetch" | "log"> = { fetch: globalThis.fetch.bind(globalThis), log: () => {} },
|
|
102
|
+
): Promise<CallmuxConfig["servers"] | undefined> {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL("/api/provisioning/capabilities?kind=mcp", config.relayUrl);
|
|
105
|
+
const headers: Record<string, string> = {};
|
|
106
|
+
if (config.token) headers.Authorization = `Bearer ${config.token}`;
|
|
107
|
+
const res = await deps.fetch(url, { headers });
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
deps.log(`[orchestrator] Shared callmux registry fetch failed: ${res.status}`);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const payload = await res.json().catch(() => null) as unknown;
|
|
113
|
+
const capabilities = isRecord(payload) && Array.isArray(payload.capabilities) ? payload.capabilities : [];
|
|
114
|
+
const servers: CallmuxConfig["servers"] = {};
|
|
115
|
+
for (const capability of capabilities) {
|
|
116
|
+
if (!isRecord(capability) || !Array.isArray(capability.variants)) continue;
|
|
117
|
+
for (const variant of capability.variants) {
|
|
118
|
+
const entry = callmuxServerFromProvisioningVariant(variant);
|
|
119
|
+
if (entry) servers[entry.name] = entry.server;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return Object.keys(servers).length > 0 ? servers : undefined;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
deps.log(`[orchestrator] Shared callmux registry fetch failed: ${errMessage(err)}`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
export class SharedCallmuxSupervisor {
|
|
101
130
|
private listener: ProgrammaticListener | null = null;
|
|
102
131
|
private readonly onStatus = (snapshot: ListenerHealthSnapshot) => this.reportCallmuxHealth(snapshot);
|
|
@@ -159,8 +188,10 @@ export class SharedCallmuxSupervisor {
|
|
|
159
188
|
private async open(): Promise<void> {
|
|
160
189
|
if (this.stopping || this.listener) return;
|
|
161
190
|
try {
|
|
162
|
-
const
|
|
191
|
+
const registryServers = await fetchSharedCallmuxRegistryServers(this.config, this.deps);
|
|
192
|
+
const persistedConfig = writeSharedCallmuxConfig({ ...this.opts, ...(registryServers ? { registryServers } : {}) });
|
|
163
193
|
const config = withRuntimeEnv(persistedConfig, {
|
|
194
|
+
...process.env,
|
|
164
195
|
...this.config.env,
|
|
165
196
|
[SHARED_MCP_URL_ENV]: this.opts.url,
|
|
166
197
|
});
|
|
@@ -234,6 +265,7 @@ function defaultDeps(): SharedCallmuxSupervisorDeps {
|
|
|
234
265
|
clearInterval: (timer) => clearInterval(timer),
|
|
235
266
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
236
267
|
clearTimeout: (timer) => clearTimeout(timer),
|
|
268
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
237
269
|
log: (message) => console.error(message),
|
|
238
270
|
report: (snapshot) => console.error(`[orchestrator] shared callmux status ${snapshot.state}${snapshot.reason ? `: ${snapshot.reason}` : ""}`),
|
|
239
271
|
};
|
|
@@ -262,20 +294,81 @@ function cloneServer(value: unknown): CallmuxConfig["servers"][string] | undefin
|
|
|
262
294
|
return JSON.parse(JSON.stringify(value)) as CallmuxConfig["servers"][string];
|
|
263
295
|
}
|
|
264
296
|
|
|
265
|
-
function
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
297
|
+
function cloneServers(value: CallmuxConfig["servers"]): CallmuxConfig["servers"] {
|
|
298
|
+
return Object.fromEntries(Object.entries(value).map(([name, server]) => [name, cloneServer(server) ?? server]));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function fallbackSharedServers(sourceServers: Record<string, unknown>): CallmuxConfig["servers"] {
|
|
302
|
+
return {
|
|
303
|
+
tokenlean: cloneServer(sourceServers.tokenlean) ?? defaultTokenleanServer(),
|
|
304
|
+
github: cloneServer(sourceServers.github) ?? defaultGithubServer(),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function callmuxServerFromProvisioningVariant(value: unknown): { name: string; server: CallmuxConfig["servers"][string] } | null {
|
|
309
|
+
if (!isRecord(value) || value.enabled === false || value.approvalStatus === "pending" || value.validationStatus === "invalid") return null;
|
|
310
|
+
if (typeof value.name !== "string" || !value.name) return null;
|
|
311
|
+
const definition = value.definition;
|
|
312
|
+
if (!isRecord(definition) || definition.kind !== "mcp" || !isRecord(definition.server)) return null;
|
|
313
|
+
const metadata = isRecord(definition.metadata) ? definition.metadata : {};
|
|
314
|
+
const provenance = isRecord(value.provenance) ? value.provenance : {};
|
|
315
|
+
if (metadata.sharedListenerEligible !== true && provenance.sharedListenerEligible !== true) return null;
|
|
316
|
+
const rawCallmuxServer = isRecord(metadata.callmuxServer) ? metadata.callmuxServer : callmuxServerFromMcpServer(definition.server);
|
|
317
|
+
const server = cloneServer(mergeConfigEnv(rawCallmuxServer));
|
|
318
|
+
return server ? { name: value.name, server } : null;
|
|
319
|
+
}
|
|
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
|
+
|
|
330
|
+
function callmuxServerFromMcpServer(server: Record<string, unknown>): Record<string, unknown> {
|
|
331
|
+
const out: Record<string, unknown> = {};
|
|
332
|
+
if (typeof server.command === "string") {
|
|
333
|
+
out.command = server.command;
|
|
334
|
+
if (Array.isArray(server.args)) out.args = server.args.filter((arg): arg is string => typeof arg === "string");
|
|
335
|
+
if (isRecord(server.env)) out.env = server.env;
|
|
336
|
+
} else if (typeof server.url === "string") {
|
|
337
|
+
out.url = server.url;
|
|
338
|
+
if (server.type === "sse") out.transport = "sse";
|
|
339
|
+
if (isRecord(server.headers)) out.headers = server.headers;
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function withRuntimeEnv(config: CallmuxConfig, env: Record<string, string | undefined>): CallmuxConfig {
|
|
345
|
+
const inheritedEnv: Record<string, string> = {};
|
|
346
|
+
for (const [key, value] of Object.entries(env)) {
|
|
347
|
+
if (typeof value === "string") inheritedEnv[key] = value;
|
|
348
|
+
}
|
|
349
|
+
if (Object.keys(inheritedEnv).length === 0) return config;
|
|
269
350
|
const servers = Object.fromEntries(Object.entries(config.servers).map(([name, server]) => {
|
|
270
|
-
|
|
351
|
+
const rawHeaders = "headers" in server && isRecord(server.headers) ? server.headers as Record<string, string> : undefined;
|
|
352
|
+
const headers = rawHeaders ? expandStringRecordPlaceholders(rawHeaders, inheritedEnv) : undefined;
|
|
353
|
+
if (!("command" in server)) return [name, { ...server, ...(headers ? { headers } : {}) }];
|
|
354
|
+
const serverEnv = isRecord(server.env) ? expandStringRecordPlaceholders(server.env as Record<string, string>, inheritedEnv) : {};
|
|
271
355
|
return [name, {
|
|
272
356
|
...server,
|
|
273
|
-
env: { ...inheritedEnv, ...
|
|
357
|
+
env: { ...inheritedEnv, ...serverEnv },
|
|
358
|
+
...(headers ? { headers } : {}),
|
|
274
359
|
}];
|
|
275
360
|
}));
|
|
276
361
|
return { ...config, servers };
|
|
277
362
|
}
|
|
278
363
|
|
|
364
|
+
function expandStringRecordPlaceholders(value: Record<string, string>, env: Record<string, string>): Record<string, string> {
|
|
365
|
+
return Object.fromEntries(Object.entries(value).map(([key, raw]) => [key, expandEnvPlaceholders(raw, env)]));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function expandEnvPlaceholders(value: string, env: Record<string, string>): string {
|
|
369
|
+
return value.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (match, key: string) => env[key] ?? match);
|
|
370
|
+
}
|
|
371
|
+
|
|
279
372
|
function defaultTokenleanServer(): CallmuxConfig["servers"][string] {
|
|
280
373
|
return {
|
|
281
374
|
command: "tl-mcp",
|
package/src/spawn/spawn-agent.ts
CHANGED
|
@@ -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
|
+
}
|