agent-relay-orchestrator 0.118.2 → 0.118.4

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.2",
3
+ "version": "0.118.4",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
package/src/git.ts CHANGED
@@ -3,21 +3,23 @@
3
3
  // `git -C` invocation lives in one place.
4
4
 
5
5
  import { execProcess } from "./process";
6
+ import type { ExecResult } from "./process";
6
7
 
7
- interface GitResult {
8
- ok: boolean;
9
- stdout: string;
10
- stderr: string;
8
+ type GitResult = Pick<ExecResult, "ok" | "stdout" | "stderr" | "exitCode" | "timedOut">;
9
+
10
+ interface GitOptions {
11
+ timeoutMs?: number;
12
+ timeoutLabel?: string;
11
13
  }
12
14
 
13
15
  /** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
14
- export async function git(args: string[], cwd: string): Promise<GitResult> {
15
- return await execProcess(["git", "-C", cwd, ...args]);
16
+ export async function git(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
17
+ return await execProcess(["git", "-C", cwd, ...args], options);
16
18
  }
17
19
 
18
20
  /** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
19
- export async function gitRaw(args: string[], cwd: string): Promise<GitResult> {
20
- return await execProcess(["git", "-C", cwd, ...args], { trimStdout: false });
21
+ export async function gitRaw(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
22
+ return await execProcess(["git", "-C", cwd, ...args], { ...options, trimStdout: false });
21
23
  }
22
24
 
23
25
  /** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
package/src/process.ts CHANGED
@@ -3,6 +3,7 @@ export interface ExecResult {
3
3
  exitCode: number | null;
4
4
  stdout: string;
5
5
  stderr: string;
6
+ timedOut?: boolean;
6
7
  }
7
8
 
8
9
  interface ExecOptions {
@@ -12,11 +13,56 @@ interface ExecOptions {
12
13
  stderr?: "pipe" | "ignore";
13
14
  trimStdout?: boolean;
14
15
  trimStderr?: boolean;
16
+ timeoutMs?: number;
17
+ timeoutLabel?: string;
18
+ streamDrainGraceMs?: number;
15
19
  }
16
20
 
17
- async function readStream(stream: ReadableStream<Uint8Array> | undefined): Promise<string> {
18
- if (!stream) return "";
19
- return await new Response(stream).text();
21
+ const DEFAULT_STREAM_DRAIN_GRACE_MS = 1_000;
22
+
23
+ interface StreamCapture {
24
+ done: Promise<void>;
25
+ text(): string;
26
+ cancel(): void;
27
+ }
28
+
29
+ function captureStream(stream: ReadableStream<Uint8Array> | undefined): StreamCapture {
30
+ if (!stream) return { done: Promise.resolve(), text: () => "", cancel: () => {} };
31
+ const reader = stream.getReader();
32
+ const decoder = new TextDecoder();
33
+ let output = "";
34
+ let canceled = false;
35
+ const done = (async () => {
36
+ try {
37
+ while (!canceled) {
38
+ const chunk = await reader.read();
39
+ if (chunk.done) break;
40
+ output += decoder.decode(chunk.value, { stream: true });
41
+ }
42
+ output += decoder.decode();
43
+ } catch {
44
+ // Intentional cancellation on process timeout or a stuck post-exit pipe.
45
+ } finally {
46
+ try { reader.releaseLock(); } catch {}
47
+ }
48
+ })();
49
+ return {
50
+ done,
51
+ text: () => output,
52
+ cancel: () => {
53
+ canceled = true;
54
+ void reader.cancel().catch(() => {});
55
+ },
56
+ };
57
+ }
58
+
59
+ function sleep(ms: number): Promise<void> {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+
63
+ function timeoutMessage(cmd: string[], options: ExecOptions): string {
64
+ const label = options.timeoutLabel ?? cmd.join(" ");
65
+ return `${label} timed out after ${options.timeoutMs}ms`;
20
66
  }
21
67
 
22
68
  export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
@@ -27,15 +73,50 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
27
73
  stdout: options.stdout ?? "pipe",
28
74
  stderr: options.stderr ?? "pipe",
29
75
  });
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
- ]);
76
+ const stdoutCapture = options.stdout === "ignore" ? captureStream(undefined) : captureStream(proc.stdout);
77
+ const stderrCapture = options.stderr === "ignore" ? captureStream(undefined) : captureStream(proc.stderr);
78
+ let timedOut = false;
79
+ let timeout: ReturnType<typeof setTimeout> | undefined;
80
+ let killTimeout: ReturnType<typeof setTimeout> | undefined;
81
+ let timeoutResolve: ((value: null) => void) | undefined;
82
+ const timeoutPromise = new Promise<null>((resolve) => { timeoutResolve = resolve; });
83
+ if (options.timeoutMs && options.timeoutMs > 0) {
84
+ timeout = setTimeout(() => {
85
+ timedOut = true;
86
+ try { proc.kill("SIGTERM"); } catch {}
87
+ killTimeout = setTimeout(() => {
88
+ try { proc.kill("SIGKILL"); } catch {}
89
+ }, 1_000);
90
+ killTimeout.unref?.();
91
+ timeoutResolve?.(null);
92
+ }, options.timeoutMs);
93
+ timeout.unref?.();
94
+ }
95
+
96
+ const exitCode = await (timeout ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
97
+ if (timeout) clearTimeout(timeout);
98
+ if (!timedOut && killTimeout) clearTimeout(killTimeout);
99
+ if (timedOut) {
100
+ stdoutCapture.cancel();
101
+ stderrCapture.cancel();
102
+ }
103
+
104
+ const drainGraceMs = options.streamDrainGraceMs ?? DEFAULT_STREAM_DRAIN_GRACE_MS;
105
+ await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(drainGraceMs)]);
106
+ stdoutCapture.cancel();
107
+ stderrCapture.cancel();
108
+
109
+ const stdout = stdoutCapture.text();
110
+ let stderr = stderrCapture.text();
111
+ if (timedOut) {
112
+ const msg = timeoutMessage(cmd, options);
113
+ stderr = stderr ? `${stderr}\n${msg}` : msg;
114
+ }
35
115
  return {
36
- ok: exitCode === 0,
116
+ ok: !timedOut && exitCode === 0,
37
117
  exitCode,
38
118
  stdout: options.trimStdout === false ? stdout : stdout.trim(),
39
119
  stderr: options.trimStderr === false ? stderr : stderr.trim(),
120
+ ...(timedOut ? { timedOut } : {}),
40
121
  };
41
122
  }
@@ -0,0 +1,41 @@
1
+ export type MergePhase = "preview" | "fetch" | "synthesize" | "worktree-add" | "cleanup";
2
+
3
+ const MERGE_PHASE_TIMEOUTS_MS: Record<MergePhase, number> = {
4
+ preview: 30_000,
5
+ fetch: 60_000,
6
+ synthesize: 60_000,
7
+ "worktree-add": 60_000,
8
+ cleanup: 30_000,
9
+ };
10
+
11
+ function positiveEnvMs(name: string): number | undefined {
12
+ const raw = process.env[name];
13
+ if (!raw) return undefined;
14
+ const parsed = Number(raw);
15
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
16
+ }
17
+
18
+ export function mergePhaseTimeoutMs(phase: MergePhase): number {
19
+ const key = `AGENT_RELAY_WORKSPACE_MERGE_${phase.toUpperCase().replace(/-/g, "_")}_TIMEOUT_MS`;
20
+ return positiveEnvMs(key)
21
+ ?? positiveEnvMs("AGENT_RELAY_WORKSPACE_MERGE_PHASE_TIMEOUT_MS")
22
+ ?? MERGE_PHASE_TIMEOUTS_MS[phase];
23
+ }
24
+
25
+ export async function withMergePhaseTimeout<T>(phase: MergePhase, run: () => Promise<T>): Promise<T> {
26
+ const timeoutMs = mergePhaseTimeoutMs(phase);
27
+ let timeout: ReturnType<typeof setTimeout> | undefined;
28
+ const work = run();
29
+ work.catch(() => {});
30
+ try {
31
+ return await Promise.race([
32
+ work,
33
+ new Promise<T>((_, reject) => {
34
+ timeout = setTimeout(() => reject(new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`)), timeoutMs);
35
+ timeout.unref?.();
36
+ }),
37
+ ]);
38
+ } finally {
39
+ if (timeout) clearTimeout(timeout);
40
+ }
41
+ }
@@ -1,13 +1,14 @@
1
1
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import type { BaseWorktreeSyncResult, WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
4
+ import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
5
5
  import { git, gitRaw } from "../git";
6
6
  import { execProcess } from "../process";
7
7
  import { prMergedState } from "../workspace-pr";
8
8
  import { refreshWorkspaceDeps } from "./deps";
9
9
  import { type LandGatesResult, runLandGates } from "./land-gates-runner";
10
10
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
11
+ import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
11
12
  import { nextBranchName } from "./names";
12
13
  import { parseWorktrees, shortBranch } from "./parse";
13
14
  import { json, resolveRequestedPath } from "./request";
@@ -357,7 +358,12 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
357
358
  // `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
358
359
  const liveBranch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath)).stdout || undefined);
359
360
  const branch = liveBranch ?? input.branch;
360
- const preview = await previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy });
361
+ let preview: WorkspaceMergePreview;
362
+ try {
363
+ preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }));
364
+ } catch (err) {
365
+ return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) };
366
+ }
361
367
  const strategy = preview.strategy;
362
368
  const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
363
369
 
@@ -531,14 +537,16 @@ async function synthesizeNoFfMerge(
531
537
  baseSha: string,
532
538
  branchSha: string,
533
539
  message: string,
540
+ timeoutMs?: number,
534
541
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
535
- const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot);
542
+ const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree" });
536
543
  if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
537
544
  const treeOid = tree.stdout.split("\n")[0]?.trim();
538
545
  if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
539
546
  const commit = await git(
540
547
  ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
541
548
  repoRoot,
549
+ { timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree" },
542
550
  );
543
551
  if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
544
552
  return { ok: true, mergeSha: commit.stdout };
@@ -581,7 +589,12 @@ async function runLandGatesOnIntegratedTree(
581
589
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
582
590
  if (behind === 0) return { gates: await runLandGates(worktreePath) };
583
591
 
584
- const synth = await synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage);
592
+ let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
593
+ try {
594
+ synth = await withMergePhaseTimeout("synthesize", () => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize")));
595
+ } catch (err) {
596
+ return { abort: { error: errMessage(err) } };
597
+ }
585
598
  if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
586
599
 
587
600
  // Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
@@ -589,7 +602,17 @@ async function runLandGatesOnIntegratedTree(
589
602
  // creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
590
603
  const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
591
604
  const tmpWorktree = join(tmpParent, "tree");
592
- const add = await git(["worktree", "add", "--detach", tmpWorktree, synth.mergeSha], repoRoot);
605
+ let add: Awaited<ReturnType<typeof git>>;
606
+ try {
607
+ add = await withMergePhaseTimeout("worktree-add", () => git(
608
+ ["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
609
+ repoRoot,
610
+ { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
611
+ ));
612
+ } catch (err) {
613
+ rmSync(tmpParent, { recursive: true, force: true });
614
+ return { abort: { error: errMessage(err) } };
615
+ }
593
616
  if (!add.ok) {
594
617
  rmSync(tmpParent, { recursive: true, force: true });
595
618
  return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
@@ -597,7 +620,15 @@ async function runLandGatesOnIntegratedTree(
597
620
  try {
598
621
  return { gates: await runLandGates(tmpWorktree) };
599
622
  } finally {
600
- await git(["worktree", "remove", "--force", tmpWorktree], repoRoot);
623
+ try {
624
+ await withMergePhaseTimeout("cleanup", () => git(
625
+ ["worktree", "remove", "--force", tmpWorktree],
626
+ repoRoot,
627
+ { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
628
+ ));
629
+ } catch (err) {
630
+ console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
631
+ }
601
632
  rmSync(tmpParent, { recursive: true, force: true });
602
633
  }
603
634
  }
@@ -691,7 +722,16 @@ async function mergeRebaseFf(
691
722
  let integrationBaseSha = (await git(["rev-parse", base], repoRoot)).stdout;
692
723
  let needSync = false;
693
724
  if (upstream && remote && pushEnabled) {
694
- await git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
725
+ try {
726
+ const fetch = await withMergePhaseTimeout("fetch", () => git(
727
+ ["fetch", remote, base],
728
+ worktreePath,
729
+ { timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}` },
730
+ ));
731
+ if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` });
732
+ } catch (err) {
733
+ return head({ status: "review_requested", error: errMessage(err) });
734
+ }
695
735
  if (!(await git(["merge-base", "--is-ancestor", upstream, base], worktreePath)).ok) {
696
736
  // Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
697
737
  // of upstream); otherwise it's genuine divergence — refuse without mutating.