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 +2 -2
- package/src/control.ts +13 -0
- package/src/spawn.ts +6 -2
- package/src/workspace-pr.ts +85 -0
- package/src/workspace-probe.ts +49 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "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.
|
|
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
|
+
}
|
package/src/workspace-probe.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
//
|
|
1006
|
-
|
|
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
|