agent-relay-orchestrator 0.118.1 → 0.118.3
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 +1 -1
- package/src/api.ts +5 -5
- package/src/control.ts +9 -9
- package/src/git.ts +8 -24
- package/src/process.ts +41 -0
- package/src/relay.ts +26 -3
- package/src/spawn/acquisition.ts +34 -43
- package/src/spawn/spawn-agent.ts +1 -1
- package/src/spawn/supervisor.ts +18 -30
- package/src/spawn/systemd.ts +39 -0
- package/src/workspace-pr.ts +20 -31
- package/src/workspace-probe/cleanup.ts +23 -23
- package/src/workspace-probe/deps.ts +11 -10
- package/src/workspace-probe/git-state.ts +31 -31
- package/src/workspace-probe/idle-refresh.ts +14 -14
- package/src/workspace-probe/merge.ts +125 -130
- package/src/workspace-probe/names.ts +9 -9
- package/src/workspace-probe/probe.ts +19 -17
- package/src/workspace-probe/recovery-branches.ts +19 -19
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -479,7 +479,7 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
479
479
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
480
480
|
try {
|
|
481
481
|
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
482
|
-
return json(workspaceGitState({
|
|
482
|
+
return json(await workspaceGitState({
|
|
483
483
|
worktreePath: target,
|
|
484
484
|
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
485
485
|
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
@@ -493,7 +493,7 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
493
493
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
494
494
|
try {
|
|
495
495
|
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
496
|
-
return json(workspaceDiff({
|
|
496
|
+
return json(await workspaceDiff({
|
|
497
497
|
worktreePath: target,
|
|
498
498
|
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
499
499
|
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
@@ -506,17 +506,17 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
506
506
|
|
|
507
507
|
if (req.method === "GET" && url.pathname === "/api/workspace/merge-preview") {
|
|
508
508
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
509
|
-
return mergePreviewResponse(url, config.baseDir);
|
|
509
|
+
return await mergePreviewResponse(url, config.baseDir);
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
if (req.method === "GET" && url.pathname === "/api/workspace/branch-merge-preview") {
|
|
513
513
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
514
|
-
return branchMergePreviewResponse(url, config.baseDir);
|
|
514
|
+
return await branchMergePreviewResponse(url, config.baseDir);
|
|
515
515
|
}
|
|
516
516
|
|
|
517
517
|
if (req.method === "GET" && url.pathname === "/api/workspace/recovery-branches") {
|
|
518
518
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
519
|
-
return recoveryBranchesResponse(url, config.baseDir);
|
|
519
|
+
return await recoveryBranchesResponse(url, config.baseDir);
|
|
520
520
|
}
|
|
521
521
|
|
|
522
522
|
if (req.method === "GET" && url.pathname === "/api/providers") {
|
package/src/control.ts
CHANGED
|
@@ -96,7 +96,7 @@ export function createControlHandler(
|
|
|
96
96
|
const result = await handleShutdown(command.params, command.type === "agent.restart");
|
|
97
97
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
98
98
|
} else if (command.type === "workspace.cleanup") {
|
|
99
|
-
const result = cleanupWorkspace({
|
|
99
|
+
const result = await cleanupWorkspace({
|
|
100
100
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
101
101
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
102
102
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -108,7 +108,7 @@ export function createControlHandler(
|
|
|
108
108
|
});
|
|
109
109
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
110
110
|
} else if (command.type === "workspace.reconcile") {
|
|
111
|
-
const result = reconcileWorkspace({
|
|
111
|
+
const result = await reconcileWorkspace({
|
|
112
112
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
113
113
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
114
114
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -143,7 +143,7 @@ export function createControlHandler(
|
|
|
143
143
|
await relay.updateCommand(command.id, mergeCommandStatus(result), result as unknown as Record<string, unknown>, result.error);
|
|
144
144
|
} else if (command.type === "workspace.pr-arm-auto-merge") {
|
|
145
145
|
const rawPrNumber = command.params.prNumber;
|
|
146
|
-
const result = armWorkspacePrAutoMerge({
|
|
146
|
+
const result = await armWorkspacePrAutoMerge({
|
|
147
147
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
148
148
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
149
149
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -154,7 +154,7 @@ export function createControlHandler(
|
|
|
154
154
|
await relay.updateCommand(command.id, result.autoMergeArmed ? "succeeded" : "failed", result as unknown as Record<string, unknown>, result.error);
|
|
155
155
|
} else if (command.type === "workspace.pr-merge") {
|
|
156
156
|
const rawPrNumber = command.params.prNumber;
|
|
157
|
-
const result = mergeWorkspacePr({
|
|
157
|
+
const result = await mergeWorkspacePr({
|
|
158
158
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
159
159
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
160
160
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -165,7 +165,7 @@ export function createControlHandler(
|
|
|
165
165
|
await relay.updateCommand(command.id, result.relayMerged ? "succeeded" : "failed", result as unknown as Record<string, unknown>, result.error);
|
|
166
166
|
} else if (command.type === "workspace.pr-refresh") {
|
|
167
167
|
const rawPrNumber = command.params.prNumber;
|
|
168
|
-
const result = refreshWorkspacePrBranch({
|
|
168
|
+
const result = await refreshWorkspacePrBranch({
|
|
169
169
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
170
170
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
171
171
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -175,14 +175,14 @@ export function createControlHandler(
|
|
|
175
175
|
});
|
|
176
176
|
await relay.updateCommand(command.id, result.prRefreshed ? "succeeded" : "failed", result as unknown as Record<string, unknown>, result.error);
|
|
177
177
|
} else if (command.type === "workspace.deps-refresh") {
|
|
178
|
-
const result = refreshWorkspaceDeps(
|
|
178
|
+
const result = await refreshWorkspaceDeps(
|
|
179
179
|
typeof command.params.repoRoot === "string" ? command.params.repoRoot : "",
|
|
180
180
|
typeof command.params.worktreePath === "string" ? command.params.worktreePath : "",
|
|
181
181
|
{ checkOnly: command.params.checkOnly === true },
|
|
182
182
|
);
|
|
183
183
|
await relay.updateCommand(command.id, "succeeded", { workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined, ...result });
|
|
184
184
|
} else if (command.type === "workspace.recovery-branch-discard") {
|
|
185
|
-
const result = discardRecoveryBranch({
|
|
185
|
+
const result = await discardRecoveryBranch({
|
|
186
186
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
187
187
|
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
188
188
|
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
@@ -191,7 +191,7 @@ export function createControlHandler(
|
|
|
191
191
|
});
|
|
192
192
|
await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
|
|
193
193
|
} else if (command.type === "workspace.idle-refresh") {
|
|
194
|
-
const result = idleRefreshWorktree({
|
|
194
|
+
const result = await idleRefreshWorktree({
|
|
195
195
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
196
196
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
197
197
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -201,7 +201,7 @@ export function createControlHandler(
|
|
|
201
201
|
});
|
|
202
202
|
await relay.updateCommand(command.id, result.error ? "failed" : "succeeded", result as unknown as Record<string, unknown>, result.error);
|
|
203
203
|
} else if (command.type === "workspace.prune") {
|
|
204
|
-
const result = pruneWorktrees({
|
|
204
|
+
const result = await pruneWorktrees({
|
|
205
205
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
206
206
|
});
|
|
207
207
|
await relay.updateCommand(command.id, "succeeded", result);
|
package/src/git.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Extracted from workspace-probe.ts so that giant keeps shrinking (epic #291) and the
|
|
3
3
|
// `git -C` invocation lives in one place.
|
|
4
4
|
|
|
5
|
+
import { execProcess } from "./process";
|
|
6
|
+
|
|
5
7
|
interface GitResult {
|
|
6
8
|
ok: boolean;
|
|
7
9
|
stdout: string;
|
|
@@ -9,36 +11,18 @@ interface GitResult {
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
/** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
|
|
12
|
-
export function git(args: string[], cwd: string): GitResult {
|
|
13
|
-
|
|
14
|
-
stdin: "ignore",
|
|
15
|
-
stdout: "pipe",
|
|
16
|
-
stderr: "pipe",
|
|
17
|
-
});
|
|
18
|
-
return {
|
|
19
|
-
ok: proc.exitCode === 0,
|
|
20
|
-
stdout: proc.stdout.toString().trim(),
|
|
21
|
-
stderr: proc.stderr.toString().trim(),
|
|
22
|
-
};
|
|
14
|
+
export async function git(args: string[], cwd: string): Promise<GitResult> {
|
|
15
|
+
return await execProcess(["git", "-C", cwd, ...args]);
|
|
23
16
|
}
|
|
24
17
|
|
|
25
18
|
/** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
|
|
26
|
-
export function gitRaw(args: string[], cwd: string): GitResult {
|
|
27
|
-
|
|
28
|
-
stdin: "ignore",
|
|
29
|
-
stdout: "pipe",
|
|
30
|
-
stderr: "pipe",
|
|
31
|
-
});
|
|
32
|
-
return {
|
|
33
|
-
ok: proc.exitCode === 0,
|
|
34
|
-
stdout: proc.stdout.toString(),
|
|
35
|
-
stderr: proc.stderr.toString().trim(),
|
|
36
|
-
};
|
|
19
|
+
export async function gitRaw(args: string[], cwd: string): Promise<GitResult> {
|
|
20
|
+
return await execProcess(["git", "-C", cwd, ...args], { trimStdout: false });
|
|
37
21
|
}
|
|
38
22
|
|
|
39
23
|
/** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
|
|
40
|
-
export function requireGit(args: string[], cwd: string): string {
|
|
41
|
-
const result = git(args, cwd);
|
|
24
|
+
export async function requireGit(args: string[], cwd: string): Promise<string> {
|
|
25
|
+
const result = await git(args, cwd);
|
|
42
26
|
if (!result.ok) throw new Error(result.stderr || `git ${args.join(" ")} failed`);
|
|
43
27
|
return result.stdout;
|
|
44
28
|
}
|
package/src/process.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface ExecResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
exitCode: number | null;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ExecOptions {
|
|
9
|
+
cwd?: string;
|
|
10
|
+
env?: Record<string, string | undefined>;
|
|
11
|
+
stdout?: "pipe" | "ignore";
|
|
12
|
+
stderr?: "pipe" | "ignore";
|
|
13
|
+
trimStdout?: boolean;
|
|
14
|
+
trimStderr?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readStream(stream: ReadableStream<Uint8Array> | undefined): Promise<string> {
|
|
18
|
+
if (!stream) return "";
|
|
19
|
+
return await new Response(stream).text();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
|
|
23
|
+
const proc = Bun.spawn(cmd, {
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
env: options.env,
|
|
26
|
+
stdin: "ignore",
|
|
27
|
+
stdout: options.stdout ?? "pipe",
|
|
28
|
+
stderr: options.stderr ?? "pipe",
|
|
29
|
+
});
|
|
30
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
31
|
+
proc.exited,
|
|
32
|
+
options.stdout === "ignore" ? Promise.resolve("") : readStream(proc.stdout),
|
|
33
|
+
options.stderr === "ignore" ? Promise.resolve("") : readStream(proc.stderr),
|
|
34
|
+
]);
|
|
35
|
+
return {
|
|
36
|
+
ok: exitCode === 0,
|
|
37
|
+
exitCode,
|
|
38
|
+
stdout: options.trimStdout === false ? stdout : stdout.trim(),
|
|
39
|
+
stderr: options.trimStderr === false ? stderr : stderr.trim(),
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/relay.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface RelayCommand {
|
|
|
68
68
|
const RECONNECT_INITIAL_MS = 30_000;
|
|
69
69
|
const RECONNECT_MAX_MS = 3_600_000; // 1 hour
|
|
70
70
|
const RECONNECT_JITTER_MS = 1_000;
|
|
71
|
+
const TRANSIENT_ABORT_RECONNECT_MS = 250;
|
|
72
|
+
const ABORT_FAILURES_BEFORE_DISCONNECT = 3;
|
|
71
73
|
|
|
72
74
|
export function buildRegistrationMeta(
|
|
73
75
|
config: Pick<OrchestratorConfig, "tmuxPrefix">,
|
|
@@ -94,6 +96,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
94
96
|
const agentId = `orchestrator-${config.id}`;
|
|
95
97
|
let heartbeatTimer: Timer | null = null;
|
|
96
98
|
let connected = false;
|
|
99
|
+
let abortFailures = 0;
|
|
97
100
|
const reconnectMgr = new ReconnectionManager({ initialMs: RECONNECT_INITIAL_MS, maxMs: RECONNECT_MAX_MS, jitterMs: RECONNECT_JITTER_MS });
|
|
98
101
|
let cursorFloor = 0;
|
|
99
102
|
let apiUrl: string | undefined;
|
|
@@ -134,6 +137,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
134
137
|
const registered = await res.json().catch(() => null) as { runtimeToken?: { token?: string } } | null;
|
|
135
138
|
if (registered?.runtimeToken?.token) http.setToken(registered.runtimeToken.token);
|
|
136
139
|
connected = true;
|
|
140
|
+
abortFailures = 0;
|
|
137
141
|
reconnectMgr.reset();
|
|
138
142
|
|
|
139
143
|
// Bootstrap message cursor
|
|
@@ -167,7 +171,20 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
167
171
|
connected = true;
|
|
168
172
|
reconnectMgr.reset();
|
|
169
173
|
}
|
|
174
|
+
abortFailures = 0;
|
|
170
175
|
} catch (err) {
|
|
176
|
+
if (isAbortError(err)) {
|
|
177
|
+
abortFailures += 1;
|
|
178
|
+
if (connected && abortFailures === 1) {
|
|
179
|
+
console.error(`[orchestrator] Relay heartbeat timed out: ${err}; retrying quickly`);
|
|
180
|
+
}
|
|
181
|
+
if (abortFailures < ABORT_FAILURES_BEFORE_DISCONNECT) {
|
|
182
|
+
await reconnect(TRANSIENT_ABORT_RECONNECT_MS);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
abortFailures = 0;
|
|
187
|
+
}
|
|
171
188
|
if (connected) {
|
|
172
189
|
console.error(`[orchestrator] Lost connection to relay: ${err}`);
|
|
173
190
|
connected = false;
|
|
@@ -176,9 +193,10 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
176
193
|
}
|
|
177
194
|
}
|
|
178
195
|
|
|
179
|
-
async function reconnect(): Promise<void> {
|
|
180
|
-
const delayMs = reconnectMgr.nextDelay();
|
|
181
|
-
|
|
196
|
+
async function reconnect(delayOverrideMs?: number): Promise<void> {
|
|
197
|
+
const delayMs = delayOverrideMs ?? reconnectMgr.nextDelay();
|
|
198
|
+
const label = delayMs < 1000 ? `${delayMs}ms` : `${Math.round(delayMs / 1000)}s`;
|
|
199
|
+
console.error(`[orchestrator] Reconnecting in ${label}...`);
|
|
182
200
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
183
201
|
try {
|
|
184
202
|
await register();
|
|
@@ -258,3 +276,8 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
258
276
|
get connected() { return connected; },
|
|
259
277
|
};
|
|
260
278
|
}
|
|
279
|
+
|
|
280
|
+
function isAbortError(error: unknown): boolean {
|
|
281
|
+
return error instanceof DOMException && error.name === "AbortError"
|
|
282
|
+
|| typeof error === "object" && error !== null && (error as { name?: string }).name === "AbortError";
|
|
283
|
+
}
|
package/src/spawn/acquisition.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } fr
|
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { errMessage, isPathWithinBase } from "agent-relay-sdk";
|
|
5
5
|
import { git, requireGit } from "../git";
|
|
6
|
+
import { execProcess } from "../process";
|
|
6
7
|
import type { ProjectAcquisitionManifest } from "./types";
|
|
7
8
|
|
|
8
9
|
export interface ProjectAcquisitionResult {
|
|
@@ -35,7 +36,7 @@ export async function applyProjectAcquisitionManifest(
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
async function withAcquisitionLock(rootPath: string, baseDir: string, fn: () => ProjectAcquisitionResult): Promise<ProjectAcquisitionResult> {
|
|
39
|
+
async function withAcquisitionLock(rootPath: string, baseDir: string, fn: () => Promise<ProjectAcquisitionResult>): Promise<ProjectAcquisitionResult> {
|
|
39
40
|
const lockDir = join(resolve(baseDir), ".agent-relay", "locks", `acquire-${hash(rootPath)}.lock`);
|
|
40
41
|
mkdirSync(dirname(lockDir), { recursive: true });
|
|
41
42
|
const started = Date.now();
|
|
@@ -52,22 +53,22 @@ async function withAcquisitionLock(rootPath: string, baseDir: string, fn: () =>
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
try {
|
|
55
|
-
return fn();
|
|
56
|
+
return await fn();
|
|
56
57
|
} finally {
|
|
57
58
|
rmSync(lockDir, { recursive: true, force: true });
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir: string): ProjectAcquisitionResult {
|
|
62
|
+
async function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir: string): Promise<ProjectAcquisitionResult> {
|
|
62
63
|
validateManifest(manifest, baseDir);
|
|
63
64
|
const rootPath = resolve(manifest.rootPath);
|
|
64
65
|
const remoteUrl = manifest.remoteUrl.trim();
|
|
65
66
|
let action: ProjectAcquisitionResult["action"] = "noop";
|
|
66
67
|
if (!existsSync(rootPath)) {
|
|
67
|
-
cloneRoot(manifest, baseDir);
|
|
68
|
+
await cloneRoot(manifest, baseDir);
|
|
68
69
|
action = "cloned";
|
|
69
70
|
}
|
|
70
|
-
const syncAction = syncRoot(manifest);
|
|
71
|
+
const syncAction = await syncRoot(manifest);
|
|
71
72
|
if (syncAction === "synced" && action !== "cloned") action = "synced";
|
|
72
73
|
return {
|
|
73
74
|
applied: true,
|
|
@@ -75,7 +76,7 @@ function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir: stri
|
|
|
75
76
|
rootPath,
|
|
76
77
|
remoteUrl,
|
|
77
78
|
...(manifest.ref ? { ref: manifest.ref } : {}),
|
|
78
|
-
headSha: git(["rev-parse", "HEAD"], rootPath).stdout || undefined,
|
|
79
|
+
headSha: (await git(["rev-parse", "HEAD"], rootPath)).stdout || undefined,
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -90,7 +91,7 @@ function validateManifest(manifest: ProjectAcquisitionManifest, baseDir: string)
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): void {
|
|
94
|
+
async function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): Promise<void> {
|
|
94
95
|
const rootPath = resolve(manifest.rootPath);
|
|
95
96
|
const parent = dirname(rootPath);
|
|
96
97
|
if (!isPathWithinBase(parent, baseDir)) throw new Error(`project acquisition parent must be within orchestrator baseDir: ${parent}`);
|
|
@@ -100,7 +101,7 @@ function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): void
|
|
|
100
101
|
const args = ["clone", "--origin", "origin"];
|
|
101
102
|
if (manifest.ref) args.push("--branch", manifest.ref);
|
|
102
103
|
args.push(manifest.remoteUrl.trim(), tmp);
|
|
103
|
-
const cloned = runGit(args, parent);
|
|
104
|
+
const cloned = await runGit(args, parent);
|
|
104
105
|
if (!cloned.ok) {
|
|
105
106
|
rmSync(tmp, { recursive: true, force: true });
|
|
106
107
|
throw new Error(`git clone failed for ${rootPath}: ${cloned.stderr || cloned.stdout}`);
|
|
@@ -113,29 +114,29 @@ function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): void
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
function syncRoot(manifest: ProjectAcquisitionManifest): "synced" | "noop" {
|
|
117
|
+
async function syncRoot(manifest: ProjectAcquisitionManifest): Promise<"synced" | "noop"> {
|
|
117
118
|
const rootPath = resolve(manifest.rootPath);
|
|
118
|
-
assertExistingGitRoot(rootPath);
|
|
119
|
-
assertRemote(rootPath, manifest.remoteUrl.trim());
|
|
120
|
-
const status = git(["status", "--porcelain"], rootPath);
|
|
119
|
+
await assertExistingGitRoot(rootPath);
|
|
120
|
+
await assertRemote(rootPath, manifest.remoteUrl.trim());
|
|
121
|
+
const status = await git(["status", "--porcelain"], rootPath);
|
|
121
122
|
if (!status.ok) throw new Error(`git status failed for ${rootPath}: ${status.stderr}`);
|
|
122
123
|
if (status.stdout.trim()) throw new Error(`project root ${rootPath} has local changes; refusing ff-only sync before spawn`);
|
|
123
|
-
const fetch = git(["fetch", "--prune", "origin"], rootPath);
|
|
124
|
+
const fetch = await git(["fetch", "--prune", "origin"], rootPath);
|
|
124
125
|
if (!fetch.ok) throw new Error(`git fetch failed for ${rootPath}: ${fetch.stderr || fetch.stdout}`);
|
|
125
|
-
const target = checkoutSyncTarget(rootPath, manifest.ref);
|
|
126
|
+
const target = await checkoutSyncTarget(rootPath, manifest.ref);
|
|
126
127
|
if (!target) return "noop";
|
|
127
|
-
const head = requireGit(["rev-parse", "HEAD"], rootPath);
|
|
128
|
-
const targetHead = requireGit(["rev-parse", target], rootPath);
|
|
128
|
+
const head = await requireGit(["rev-parse", "HEAD"], rootPath);
|
|
129
|
+
const targetHead = await requireGit(["rev-parse", target], rootPath);
|
|
129
130
|
if (head === targetHead) return "noop";
|
|
130
|
-
if (!git(["merge-base", "--is-ancestor", "HEAD", target], rootPath).ok) {
|
|
131
|
+
if (!(await git(["merge-base", "--is-ancestor", "HEAD", target], rootPath)).ok) {
|
|
131
132
|
throw new Error(`project root ${rootPath} has diverged from ${target}; refusing non-fast-forward sync`);
|
|
132
133
|
}
|
|
133
|
-
const merged = git(["merge", "--ff-only", target], rootPath);
|
|
134
|
+
const merged = await git(["merge", "--ff-only", target], rootPath);
|
|
134
135
|
if (!merged.ok) throw new Error(`git ff-only sync failed for ${rootPath}: ${merged.stderr || merged.stdout}`);
|
|
135
136
|
return "synced";
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
function assertExistingGitRoot(rootPath: string): void {
|
|
139
|
+
async function assertExistingGitRoot(rootPath: string): Promise<void> {
|
|
139
140
|
let stat;
|
|
140
141
|
try {
|
|
141
142
|
stat = statSync(rootPath);
|
|
@@ -143,58 +144,48 @@ function assertExistingGitRoot(rootPath: string): void {
|
|
|
143
144
|
throw new Error(`project root does not exist after acquisition: ${rootPath}: ${errMessage(error)}`);
|
|
144
145
|
}
|
|
145
146
|
if (!stat.isDirectory()) throw new Error(`project root exists but is not a directory: ${rootPath}`);
|
|
146
|
-
const top = git(["rev-parse", "--show-toplevel"], rootPath);
|
|
147
|
+
const top = await git(["rev-parse", "--show-toplevel"], rootPath);
|
|
147
148
|
if (!top.ok || resolve(top.stdout) !== rootPath) throw new Error(`project root exists but is not a git checkout root: ${rootPath}`);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
function assertRemote(rootPath: string, remoteUrl: string): void {
|
|
151
|
-
const current = git(["remote", "get-url", "origin"], rootPath);
|
|
151
|
+
async function assertRemote(rootPath: string, remoteUrl: string): Promise<void> {
|
|
152
|
+
const current = await git(["remote", "get-url", "origin"], rootPath);
|
|
152
153
|
if (!current.ok || !current.stdout) throw new Error(`project root ${rootPath} has no origin remote`);
|
|
153
154
|
if (current.stdout.trim() !== remoteUrl) {
|
|
154
155
|
throw new Error(`project root ${rootPath} origin mismatch: expected ${remoteUrl}, found ${current.stdout.trim()}`);
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
function checkoutSyncTarget(rootPath: string, ref: string | undefined): string | undefined {
|
|
159
|
+
async function checkoutSyncTarget(rootPath: string, ref: string | undefined): Promise<string | undefined> {
|
|
159
160
|
if (ref?.trim()) {
|
|
160
161
|
const name = ref.trim();
|
|
161
162
|
const remoteBranch = `refs/remotes/origin/${name}`;
|
|
162
|
-
if (git(["show-ref", "--verify", "--quiet", remoteBranch], rootPath).ok) {
|
|
163
|
-
if (git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], rootPath).ok) {
|
|
164
|
-
const checked = git(["checkout", name], rootPath);
|
|
163
|
+
if ((await git(["show-ref", "--verify", "--quiet", remoteBranch], rootPath)).ok) {
|
|
164
|
+
if ((await git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], rootPath)).ok) {
|
|
165
|
+
const checked = await git(["checkout", name], rootPath);
|
|
165
166
|
if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
|
|
166
167
|
} else {
|
|
167
|
-
const checked = git(["checkout", "-B", name, `origin/${name}`], rootPath);
|
|
168
|
+
const checked = await git(["checkout", "-B", name, `origin/${name}`], rootPath);
|
|
168
169
|
if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
|
|
169
170
|
}
|
|
170
171
|
return `origin/${name}`;
|
|
171
172
|
}
|
|
172
|
-
if (git(["rev-parse", "--verify", name], rootPath).ok) {
|
|
173
|
-
const checked = git(["checkout", name], rootPath);
|
|
173
|
+
if ((await git(["rev-parse", "--verify", name], rootPath)).ok) {
|
|
174
|
+
const checked = await git(["checkout", name], rootPath);
|
|
174
175
|
if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
|
|
175
176
|
return undefined;
|
|
176
177
|
}
|
|
177
178
|
throw new Error(`project acquisition ref "${name}" not found on origin for ${rootPath}`);
|
|
178
179
|
}
|
|
179
|
-
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], rootPath);
|
|
180
|
+
const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], rootPath);
|
|
180
181
|
if (upstream.ok && upstream.stdout) return upstream.stdout;
|
|
181
|
-
const originHead = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], rootPath);
|
|
182
|
+
const originHead = await git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], rootPath);
|
|
182
183
|
if (originHead.ok && originHead.stdout) return originHead.stdout;
|
|
183
184
|
throw new Error(`project root ${rootPath} has no upstream or origin/HEAD for ff-only sync`);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
function runGit(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
|
|
187
|
-
|
|
188
|
-
cwd,
|
|
189
|
-
stdin: "ignore",
|
|
190
|
-
stdout: "pipe",
|
|
191
|
-
stderr: "pipe",
|
|
192
|
-
});
|
|
193
|
-
return {
|
|
194
|
-
ok: proc.exitCode === 0,
|
|
195
|
-
stdout: proc.stdout.toString().trim(),
|
|
196
|
-
stderr: proc.stderr.toString().trim(),
|
|
197
|
-
};
|
|
187
|
+
async function runGit(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
188
|
+
return await execProcess(["git", ...args], { cwd });
|
|
198
189
|
}
|
|
199
190
|
|
|
200
191
|
function hash(value: string): string {
|
package/src/spawn/spawn-agent.ts
CHANGED
package/src/spawn/supervisor.ts
CHANGED
|
@@ -11,13 +11,14 @@ import { SESSION_DIR } from "./constants";
|
|
|
11
11
|
import { disableSystemdSupervisor, forceSystemdSupervisor } from "../config";
|
|
12
12
|
import { logLines } from "./log-utils";
|
|
13
13
|
import { currentSessionPid, ensureSessionDir, findSessionRecord, isSessionRecordAlive, loadState, logFilePath, readRunnerInfo, removeSessionRecord, sessionSupervisor } from "./runtime";
|
|
14
|
-
import {
|
|
14
|
+
import { systemdMainPidAsync, systemdUnitDiagnostics, systemdUnitName } from "./systemd";
|
|
15
15
|
import type { SessionRecord, SessionSupervisor, SpawnedRunner } from "./types";
|
|
16
|
+
import { execProcess } from "../process";
|
|
16
17
|
|
|
17
|
-
export function spawnRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): SpawnedRunner {
|
|
18
|
-
if (shouldUseSystemdSupervisor()) {
|
|
18
|
+
export async function spawnRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): Promise<SpawnedRunner> {
|
|
19
|
+
if (await shouldUseSystemdSupervisor()) {
|
|
19
20
|
try {
|
|
20
|
-
return spawnSystemdRunner(name, command, cwd, env, logFile);
|
|
21
|
+
return await spawnSystemdRunner(name, command, cwd, env, logFile);
|
|
21
22
|
} catch (error) {
|
|
22
23
|
console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${errMessage(error)}`);
|
|
23
24
|
console.error("[orchestrator] Falling back to process child; this agent will not survive orchestrator service restart.");
|
|
@@ -44,32 +45,24 @@ export function spawnRunner(name: string, command: string[], cwd: string, env: R
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function shouldUseSystemdSupervisor(): boolean {
|
|
48
|
+
async function shouldUseSystemdSupervisor(): Promise<boolean> {
|
|
48
49
|
if (process.platform !== "linux") return false;
|
|
49
50
|
if (disableSystemdSupervisor()) return false;
|
|
50
51
|
if (forceSystemdSupervisor()) return true;
|
|
51
|
-
const result =
|
|
52
|
-
|
|
53
|
-
stdout: "ignore",
|
|
54
|
-
stderr: "ignore",
|
|
55
|
-
});
|
|
56
|
-
return result.exitCode === 0;
|
|
52
|
+
const result = await execProcess(["systemctl", "--user", "show-environment"], { stdout: "ignore", stderr: "ignore" });
|
|
53
|
+
return result.ok;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
function spawnSystemdRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): SpawnedRunner {
|
|
56
|
+
async function spawnSystemdRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): Promise<SpawnedRunner> {
|
|
60
57
|
const unit = systemdUnitName(name);
|
|
61
58
|
const launchScript = launchScriptPath(name);
|
|
62
59
|
ensureSessionDir();
|
|
63
60
|
writeFileSync(launchScript, buildLaunchScript(command, cwd, env), { mode: 0o700 });
|
|
64
61
|
chmodSync(launchScript, 0o700);
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
stdin: "ignore",
|
|
68
|
-
stdout: "ignore",
|
|
69
|
-
stderr: "ignore",
|
|
70
|
-
});
|
|
63
|
+
await execProcess(["systemctl", "--user", "stop", `${unit}.service`], { stdout: "ignore", stderr: "ignore" });
|
|
71
64
|
|
|
72
|
-
const result =
|
|
65
|
+
const result = await execProcess([
|
|
73
66
|
"systemd-run",
|
|
74
67
|
"--user",
|
|
75
68
|
`--unit=${unit}`,
|
|
@@ -78,17 +71,12 @@ function spawnSystemdRunner(name: string, command: string[], cwd: string, env: R
|
|
|
78
71
|
`--property=StandardOutput=append:${logFile}`,
|
|
79
72
|
`--property=StandardError=append:${logFile}`,
|
|
80
73
|
launchScript,
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
stderr: "pipe",
|
|
85
|
-
});
|
|
86
|
-
if (result.exitCode !== 0) {
|
|
87
|
-
const stderr = result.stderr.toString().trim();
|
|
88
|
-
throw new Error(stderr || `systemd-run failed with exit code ${result.exitCode}`);
|
|
74
|
+
]);
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
throw new Error(result.stderr || `systemd-run failed with exit code ${result.exitCode}`);
|
|
89
77
|
}
|
|
90
78
|
|
|
91
|
-
const pid = waitForSystemdMainPid(unit, 2_000);
|
|
79
|
+
const pid = await waitForSystemdMainPid(unit, 2_000);
|
|
92
80
|
if (!pid) throw new Error(`systemd unit ${unit}.service started without a MainPID`);
|
|
93
81
|
return { pid, supervisor: { type: "systemd", unit, launchScript } };
|
|
94
82
|
}
|
|
@@ -113,12 +101,12 @@ export function buildLaunchScript(command: string[], cwd: string, env: Record<st
|
|
|
113
101
|
].join("\n");
|
|
114
102
|
}
|
|
115
103
|
|
|
116
|
-
function waitForSystemdMainPid(unit: string, timeoutMs: number): number {
|
|
104
|
+
async function waitForSystemdMainPid(unit: string, timeoutMs: number): Promise<number> {
|
|
117
105
|
const deadline = Date.now() + timeoutMs;
|
|
118
106
|
while (Date.now() < deadline) {
|
|
119
|
-
const pid =
|
|
107
|
+
const pid = await systemdMainPidAsync(unit);
|
|
120
108
|
if (pid > 0 && isPidAlive(pid)) return pid;
|
|
121
|
-
Bun.
|
|
109
|
+
await Bun.sleep(50);
|
|
122
110
|
}
|
|
123
111
|
return 0;
|
|
124
112
|
}
|
package/src/spawn/systemd.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
2
2
|
import { isPidAlive } from "agent-relay-sdk/process-utils";
|
|
3
|
+
import { execProcess } from "../process";
|
|
3
4
|
|
|
4
5
|
export type SystemdUnitLiveness = "alive" | "dead" | "unknown";
|
|
5
6
|
|
|
@@ -57,6 +58,40 @@ export function systemdUnitDiagnostics(unit: string): SystemdUnitDiagnostics {
|
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
export async function systemdUnitDiagnosticsAsync(unit: string): Promise<SystemdUnitDiagnostics> {
|
|
62
|
+
const result = await execProcess([
|
|
63
|
+
"systemctl", "--user", "show", `${unit}.service`,
|
|
64
|
+
"-p", "ActiveState",
|
|
65
|
+
"-p", "SubState",
|
|
66
|
+
"-p", "Result",
|
|
67
|
+
"-p", "ExecMainCode",
|
|
68
|
+
"-p", "ExecMainStatus",
|
|
69
|
+
"-p", "MainPID",
|
|
70
|
+
]);
|
|
71
|
+
if (!result.ok) {
|
|
72
|
+
return {
|
|
73
|
+
unit,
|
|
74
|
+
unavailable: result.stderr || `systemctl show exited with ${result.exitCode}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const props = new Map<string, string>();
|
|
78
|
+
for (const line of result.stdout.split("\n")) {
|
|
79
|
+
const index = line.indexOf("=");
|
|
80
|
+
if (index <= 0) continue;
|
|
81
|
+
props.set(line.slice(0, index), line.slice(index + 1));
|
|
82
|
+
}
|
|
83
|
+
const mainPid = Number(props.get("MainPID"));
|
|
84
|
+
return {
|
|
85
|
+
unit,
|
|
86
|
+
activeState: props.get("ActiveState") || undefined,
|
|
87
|
+
subState: props.get("SubState") || undefined,
|
|
88
|
+
result: props.get("Result") || undefined,
|
|
89
|
+
execMainCode: props.get("ExecMainCode") || undefined,
|
|
90
|
+
execMainStatus: props.get("ExecMainStatus") || undefined,
|
|
91
|
+
mainPid: Number.isFinite(mainPid) && mainPid > 0 ? mainPid : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
60
95
|
export function systemdUnitLivenessFromDiagnostics(
|
|
61
96
|
diagnostics: SystemdUnitDiagnostics,
|
|
62
97
|
isAlive: (pid: number) => boolean,
|
|
@@ -82,3 +117,7 @@ export function systemdUnitLiveness(unit: string): SystemdUnitLiveness {
|
|
|
82
117
|
export function systemdMainPid(unit: string): number {
|
|
83
118
|
return systemdUnitDiagnostics(unit).mainPid ?? 0;
|
|
84
119
|
}
|
|
120
|
+
|
|
121
|
+
export async function systemdMainPidAsync(unit: string): Promise<number> {
|
|
122
|
+
return (await systemdUnitDiagnosticsAsync(unit)).mainPid ?? 0;
|
|
123
|
+
}
|