agent-relay-orchestrator 0.118.1 → 0.118.2

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.118.1",
3
+ "version": "0.118.2",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
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
- const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
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
- const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
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
- console.error(`[orchestrator] Reconnecting in ${Math.round(delayMs / 1000)}s...`);
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
+ }
@@ -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
- const proc = Bun.spawnSync(["git", ...args], {
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 {
@@ -88,7 +88,7 @@ export async function spawnAgent(
88
88
 
89
89
  closeSync(logFd);
90
90
 
91
- const runner = d.spawnRunner(name, command, spawnOpts.cwd, env, logFile);
91
+ const runner = await d.spawnRunner(name, command, spawnOpts.cwd, env, logFile);
92
92
 
93
93
  d.addSessionRecord({
94
94
  name,
@@ -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 { systemdMainPid, systemdUnitDiagnostics, systemdUnitName } from "./systemd";
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 = Bun.spawnSync(["systemctl", "--user", "show-environment"], {
52
- stdin: "ignore",
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
- Bun.spawnSync(["systemctl", "--user", "stop", `${unit}.service`], {
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 = Bun.spawnSync([
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
- stdin: "ignore",
83
- stdout: "pipe",
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 = systemdMainPid(unit);
107
+ const pid = await systemdMainPidAsync(unit);
120
108
  if (pid > 0 && isPidAlive(pid)) return pid;
121
- Bun.sleepSync(50);
109
+ await Bun.sleep(50);
122
110
  }
123
111
  return 0;
124
112
  }
@@ -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
+ }