agent-relay-orchestrator 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.22"
19
+ "agent-relay-sdk": "0.2.24"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/control.ts CHANGED
@@ -4,6 +4,7 @@ import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
4
4
  import { handleSelfUpgrade } from "./self-upgrade";
5
5
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
6
6
  import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
7
+ import { armWorkspacePrAutoMerge } from "./workspace-pr";
7
8
 
8
9
  interface ControlHandler {
9
10
  handleCommand(command: RelayCommand): Promise<boolean>;
@@ -120,8 +121,20 @@ export function createControlHandler(
120
121
  push: command.params.push !== false,
121
122
  prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
122
123
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
124
+ autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
123
125
  });
124
126
  await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
127
+ } else if (command.type === "workspace.pr-arm-auto-merge") {
128
+ const rawPrNumber = command.params.prNumber;
129
+ const result = armWorkspacePrAutoMerge({
130
+ id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
131
+ repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
132
+ worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
133
+ branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
134
+ prNumber: typeof rawPrNumber === "number" && Number.isSafeInteger(rawPrNumber) ? rawPrNumber : undefined,
135
+ prUrl: typeof command.params.prUrl === "string" ? command.params.prUrl : undefined,
136
+ });
137
+ await relay.updateCommand(command.id, result.autoMergeArmed ? "succeeded" : "failed", result as unknown as Record<string, unknown>, result.error);
125
138
  } else if (command.type === "workspace.deps-refresh") {
126
139
  const result = refreshWorkspaceDeps(
127
140
  typeof command.params.repoRoot === "string" ? command.params.repoRoot : "",
package/src/spawn.ts CHANGED
@@ -6,7 +6,7 @@ import type { OrchestratorConfig } from "./config";
6
6
  import type { ManagedAgentReport, ManagedSessionExitDiagnostics } from "./relay";
7
7
  import { resolveSpawnWorkspace, workspacesRoot } from "./workspace-probe";
8
8
  import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
9
- import { errMessage } from "agent-relay-sdk";
9
+ import { errMessage, extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
10
10
  import { isPidAlive, parseProcStateIsZombie } from "agent-relay-sdk/process-utils";
11
11
  import { shellEscape } from "agent-relay-sdk/shell-utils";
12
12
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
@@ -890,6 +890,10 @@ function logFileDiagnostics(logFile: string): Pick<ManagedSessionExitDiagnostics
890
890
  }
891
891
 
892
892
  function describeSessionExit(record: SessionRecord, diagnostics: Omit<ManagedSessionExitDiagnostics, "lastError">): string {
893
+ if (record.provider === "claude") {
894
+ const modelUnavailable = extractClaudeModelUnavailableMessage((diagnostics.logTail ?? []).join("\n"));
895
+ if (modelUnavailable) return modelUnavailable;
896
+ }
893
897
  const seconds = Math.max(0, Math.round(diagnostics.runtimeMs / 1000));
894
898
  const parts = [`managed ${record.provider} session ${record.name} exited after ${seconds}s`];
895
899
  if (diagnostics.systemd?.unavailable) {
@@ -1104,7 +1108,7 @@ export function captureSessionMirror(
1104
1108
  // The mirror log lives in the same directory as the provider log (both written
1105
1109
  // by the same user on this host). Derive from the record's logFile when known so
1106
1110
  // it tracks any per-session log relocation.
1107
- const logDir = record?.logFile ? dirname(record.logFile) : LOG_DIR;
1111
+ const logDir = record?.logFile ? dirname(record.logFile) : process.env.AGENT_RELAY_LOG_DIR || LOG_DIR;
1108
1112
  const mirrorPath = join(logDir, `session-mirror-${safeMirrorLogName(agentId)}.log`);
1109
1113
  let content: string;
1110
1114
  try {
@@ -0,0 +1,85 @@
1
+ import { resolve } from "node:path";
2
+
3
+ type PrReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
4
+
5
+ interface PullRequestState {
6
+ state: "merged" | "open";
7
+ url?: string;
8
+ sha?: string;
9
+ number?: number;
10
+ reviewDecision?: PrReviewDecision;
11
+ statusCheckRollup?: unknown[];
12
+ }
13
+
14
+ function prReviewDecision(value: unknown): PrReviewDecision | undefined {
15
+ return value === "APPROVED" || value === "CHANGES_REQUESTED" || value === "REVIEW_REQUIRED" ? value : undefined;
16
+ }
17
+
18
+ export function prMergedState(cwd: string, branch: string | undefined): PullRequestState | undefined {
19
+ if (!branch) return undefined;
20
+ const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url,mergeCommit,number,reviewDecision,statusCheckRollup"], {
21
+ cwd,
22
+ stdin: "ignore",
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ env: process.env,
26
+ });
27
+ if (proc.exitCode !== 0) return undefined;
28
+ try {
29
+ const data = JSON.parse(proc.stdout.toString()) as {
30
+ state?: string;
31
+ url?: string;
32
+ mergeCommit?: { oid?: string } | null;
33
+ number?: number;
34
+ reviewDecision?: unknown;
35
+ statusCheckRollup?: unknown;
36
+ };
37
+ if (!data.state) return undefined;
38
+ const sha = data.mergeCommit?.oid;
39
+ const number = typeof data.number === "number" && Number.isSafeInteger(data.number) ? data.number : undefined;
40
+ const reviewDecision = prReviewDecision(data.reviewDecision);
41
+ const statusCheckRollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : undefined;
42
+ return {
43
+ state: data.state === "MERGED" ? "merged" : "open",
44
+ url: data.url,
45
+ ...(sha ? { sha } : {}),
46
+ ...(number ? { number } : {}),
47
+ ...(reviewDecision ? { reviewDecision } : {}),
48
+ ...(statusCheckRollup ? { statusCheckRollup } : {}),
49
+ };
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ export function armWorkspacePrAutoMerge(input: {
56
+ id?: string;
57
+ repoRoot?: string;
58
+ worktreePath?: string;
59
+ branch?: string;
60
+ prNumber?: number;
61
+ prUrl?: string;
62
+ }): { workspaceId?: string; status: "merge_planned"; autoMergeArmed: boolean; prNumber?: number; prUrl?: string; error?: string } {
63
+ const cwd = input.worktreePath ? resolve(input.worktreePath) : input.repoRoot ? resolve(input.repoRoot) : undefined;
64
+ if (!cwd) return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: false, error: "worktreePath or repoRoot required" };
65
+ const target = input.prNumber ? String(input.prNumber) : input.prUrl ?? input.branch;
66
+ if (!target) return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: false, error: "prNumber, prUrl, or branch required" };
67
+ const proc = Bun.spawnSync(["gh", "pr", "merge", target, "--auto", "--merge"], {
68
+ cwd,
69
+ stdin: "ignore",
70
+ stdout: "pipe",
71
+ stderr: "pipe",
72
+ env: process.env,
73
+ });
74
+ if (proc.exitCode !== 0) {
75
+ return {
76
+ workspaceId: input.id,
77
+ status: "merge_planned",
78
+ autoMergeArmed: false,
79
+ prNumber: input.prNumber,
80
+ prUrl: input.prUrl,
81
+ error: proc.stderr.toString().trim() || proc.stdout.toString().trim() || "gh pr merge failed",
82
+ };
83
+ }
84
+ return { workspaceId: input.id, status: "merge_planned", autoMergeArmed: true, prNumber: input.prNumber, prUrl: input.prUrl };
85
+ }
@@ -6,6 +6,7 @@ import type { WorkspaceDepsProvision, WorkspaceDepsRefreshDir, WorkspaceDepsRefr
6
6
  import { errMessage } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
8
  import { git, requireGit } from "./git";
9
+ import { prMergedState } from "./workspace-pr";
9
10
 
10
11
  const MAX_DIFF_PATCH_BYTES = 200_000;
11
12
 
@@ -715,6 +716,11 @@ interface WorkspaceMergeInput {
715
716
  push?: boolean;
716
717
  prTitle?: string;
717
718
  prBody?: string;
719
+ /** Auto-merge policy for pr-strategy lands (#305).
720
+ * - "on-green": arm GitHub auto-merge after PR creation (default).
721
+ * - "on-approval": open PR, do NOT arm; reviewer pipeline arms it later.
722
+ * - "manual": open PR, do NOT arm (today's legacy behavior). */
723
+ autoMerge?: "on-green" | "on-approval" | "manual";
718
724
  }
719
725
 
720
726
  /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
@@ -742,25 +748,6 @@ function ghAvailable(): boolean {
742
748
  * Returns undefined when there's no PR, no branch, or gh fails —
743
749
  * we never invent a merge.
744
750
  */
745
- function prMergedState(cwd: string, branch: string | undefined): { state: "merged" | "open"; url?: string; sha?: string } | undefined {
746
- if (!branch) return undefined;
747
- const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url,mergeCommit"], {
748
- cwd,
749
- stdin: "ignore",
750
- stdout: "pipe",
751
- stderr: "pipe",
752
- });
753
- if (proc.exitCode !== 0) return undefined; // no PR for the branch, or gh unavailable/unauthed
754
- try {
755
- const data = JSON.parse(proc.stdout.toString()) as { state?: string; url?: string; mergeCommit?: { oid?: string } | null };
756
- if (!data.state) return undefined;
757
- const sha = data.mergeCommit?.oid;
758
- return { state: data.state === "MERGED" ? "merged" : "open", url: data.url, ...(sha ? { sha } : {}) };
759
- } catch {
760
- return undefined;
761
- }
762
- }
763
-
764
751
  /**
765
752
  * Predict whether merging the branch's commits into base would conflict, using
766
753
  * git's three-way merge-tree (no working-tree changes). Exit 0 = clean, exit 1
@@ -798,6 +785,9 @@ function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: str
798
785
  if (!pr) return false;
799
786
  base.prState = pr.state;
800
787
  if (pr.url) base.prUrl = pr.url;
788
+ if (pr.number) base.prNumber = pr.number;
789
+ if (pr.reviewDecision) base.reviewDecision = pr.reviewDecision;
790
+ if (pr.statusCheckRollup) base.statusCheckRollup = pr.statusCheckRollup;
801
791
  if (pr.state !== "merged") return false;
802
792
  base.prMerged = true;
803
793
  if (pr.sha) base.prMergeSha = pr.sha;
@@ -913,7 +903,12 @@ export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult
913
903
  if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
914
904
  const worktreePath = resolve(input.worktreePath);
915
905
  const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
916
- const branch = input.branch ?? shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath).stdout || undefined);
906
+ // Probe the live HEAD branch first it's the authoritative source. Fall back to the
907
+ // DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
908
+ // This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
909
+ // `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
910
+ const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath).stdout || undefined);
911
+ const branch = liveBranch ?? input.branch;
917
912
  const preview = previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy });
918
913
  const strategy = preview.strategy;
919
914
  const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
@@ -995,15 +990,45 @@ function mergePr(
995
990
  const body = input.prBody || `Automated PR for agent workspace branch \`${branch}\`.`;
996
991
  const args = ["pr", "create", "--head", branch, "--title", title, "--body", body];
997
992
  if (base) args.push("--base", base);
998
- const proc = Bun.spawnSync(["gh", ...args], { cwd: worktreePath, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
993
+ // Pass process.env explicitly so runtime env mutations (e.g. test PATH injection)
994
+ // are visible to the child process. Bun's default is the startup-time env snapshot.
995
+ const proc = Bun.spawnSync(["gh", ...args], { cwd: worktreePath, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: process.env });
999
996
  const stdout = proc.stdout.toString().trim();
1000
997
  if (proc.exitCode !== 0) {
1001
998
  return head({ status: "review_requested", error: proc.stderr.toString().trim() || "gh pr create failed" });
1002
999
  }
1003
1000
  const prUrl = stdout.split("\n").map((line) => line.trim()).find((line) => /^https?:\/\//.test(line));
1004
- // PR opened: work is on its way out but not landed. Keep the worktree/branch
1005
- // alive for the PR; record the plan with the URL.
1006
- return head({ status: "merge_planned", prUrl, error: undefined });
1001
+
1002
+ // Auto-merge policy (#305). Treat absent as "on-green" so new lands always terminate.
1003
+ const autoMerge = input.autoMerge ?? "on-green";
1004
+
1005
+ if (autoMerge === "on-approval") {
1006
+ // Reviewer pipeline arms auto-merge later — don't arm here.
1007
+ return head({ status: "merge_planned", prUrl, awaitingApproval: true, error: undefined });
1008
+ }
1009
+
1010
+ if (autoMerge === "manual") {
1011
+ // Legacy behavior: open the PR and stop — a human merges.
1012
+ return head({ status: "merge_planned", prUrl, error: undefined });
1013
+ }
1014
+
1015
+ // "on-green" (default): arm GitHub auto-merge. Use --merge (repo's merge style).
1016
+ // Never throw — if arming fails (repo has auto-merge disabled) we still return
1017
+ // merge_planned so the relay reconcile scan can finalize when the PR merges.
1018
+ const mergeTarget = prUrl ?? branch!;
1019
+ const armProc = Bun.spawnSync(["gh", "pr", "merge", mergeTarget, "--auto", "--merge"], {
1020
+ cwd: worktreePath,
1021
+ stdin: "ignore",
1022
+ stdout: "pipe",
1023
+ stderr: "pipe",
1024
+ env: process.env,
1025
+ });
1026
+ if (armProc.exitCode !== 0) {
1027
+ // Arm failed (e.g. repo has auto-merge disabled) — return merge_planned so the
1028
+ // reconcile scan still finalizes when the PR is merged by a human.
1029
+ return head({ status: "merge_planned", prUrl, autoMergeArmed: false, error: undefined });
1030
+ }
1031
+ return head({ status: "merge_planned", prUrl, autoMergeArmed: true, error: undefined });
1007
1032
  }
1008
1033
 
1009
1034
  // Identity stamped on the merge commit a no-ff land records (#287). The merge is a