agent-relay-orchestrator 0.58.0 → 0.59.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.58.0",
3
+ "version": "0.59.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.34"
19
+ "agent-relay-sdk": "0.2.35"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/control.ts CHANGED
@@ -122,6 +122,12 @@ export function createControlHandler(
122
122
  prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
123
123
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
124
124
  autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
125
+ prLanded: isRecord(command.params.prLanded)
126
+ ? {
127
+ sha: typeof command.params.prLanded.sha === "string" ? command.params.prLanded.sha : undefined,
128
+ subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
129
+ }
130
+ : undefined,
125
131
  });
126
132
  await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
127
133
  } else if (command.type === "workspace.pr-arm-auto-merge") {
@@ -7,6 +7,7 @@ interface PullRequestState {
7
7
  url?: string;
8
8
  sha?: string;
9
9
  number?: number;
10
+ title?: string;
10
11
  reviewDecision?: PrReviewDecision;
11
12
  statusCheckRollup?: unknown[];
12
13
  mergeable?: string;
@@ -19,7 +20,7 @@ function prReviewDecision(value: unknown): PrReviewDecision | undefined {
19
20
 
20
21
  export function prMergedState(cwd: string, branch: string | undefined): PullRequestState | undefined {
21
22
  if (!branch) return undefined;
22
- const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url,mergeCommit,number,reviewDecision,statusCheckRollup,mergeable,mergeStateStatus"], {
23
+ const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url,mergeCommit,number,title,reviewDecision,statusCheckRollup,mergeable,mergeStateStatus"], {
23
24
  cwd,
24
25
  stdin: "ignore",
25
26
  stdout: "pipe",
@@ -33,6 +34,7 @@ export function prMergedState(cwd: string, branch: string | undefined): PullRequ
33
34
  url?: string;
34
35
  mergeCommit?: { oid?: string } | null;
35
36
  number?: number;
37
+ title?: unknown;
36
38
  reviewDecision?: unknown;
37
39
  statusCheckRollup?: unknown;
38
40
  mergeable?: unknown;
@@ -41,6 +43,7 @@ export function prMergedState(cwd: string, branch: string | undefined): PullRequ
41
43
  if (!data.state) return undefined;
42
44
  const sha = data.mergeCommit?.oid;
43
45
  const number = typeof data.number === "number" && Number.isSafeInteger(data.number) ? data.number : undefined;
46
+ const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : undefined;
44
47
  const reviewDecision = prReviewDecision(data.reviewDecision);
45
48
  const statusCheckRollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : undefined;
46
49
  const mergeable = typeof data.mergeable === "string" ? data.mergeable : undefined;
@@ -50,6 +53,7 @@ export function prMergedState(cwd: string, branch: string | undefined): PullRequ
50
53
  url: data.url,
51
54
  ...(sha ? { sha } : {}),
52
55
  ...(number ? { number } : {}),
56
+ ...(title ? { title } : {}),
53
57
  ...(reviewDecision ? { reviewDecision } : {}),
54
58
  ...(statusCheckRollup ? { statusCheckRollup } : {}),
55
59
  ...(mergeable ? { mergeable } : {}),
@@ -599,6 +599,25 @@ function upstreamRef(worktreePath: string, base: string): string | undefined {
599
599
  return res.ok && res.stdout ? res.stdout : undefined;
600
600
  }
601
601
 
602
+ /**
603
+ * Best-effort fetch of `base`'s upstream and return the ref to cut a recycled branch from.
604
+ * Used by the #423 PR-land recycle: a squash merge lands on ORIGIN while the worktree's local
605
+ * base is never advanced, so cutting from local base would recycle onto stale main. Fetching
606
+ * the upstream and returning `origin/<base>` (when it resolves) yields a branch off CURRENT
607
+ * base. Falls back to the local base ref when there is no upstream (no remote) or the
608
+ * upstream ref can't be resolved — the caller still verifies it contains the merge SHA.
609
+ */
610
+ function syncBaseFromOrigin(worktreePath: string, base: string | undefined): string | undefined {
611
+ if (!base) return undefined;
612
+ const upstream = upstreamRef(worktreePath, base);
613
+ if (!upstream) return base;
614
+ const slash = upstream.indexOf("/");
615
+ const remote = slash > 0 ? upstream.slice(0, slash) : undefined;
616
+ const remoteBranch = slash > 0 ? upstream.slice(slash + 1) : base;
617
+ if (remote) git(["fetch", remote, remoteBranch], worktreePath); // best-effort freshness
618
+ return git(["rev-parse", "--verify", "--quiet", upstream], worktreePath).ok ? upstream : base;
619
+ }
620
+
602
621
  function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): string | undefined {
603
622
  for (const candidate of [baseRef, baseSha]) {
604
623
  if (candidate && git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], worktreePath).ok) {
@@ -721,6 +740,15 @@ interface WorkspaceMergeInput {
721
740
  * - "on-approval": open PR, do NOT arm; reviewer pipeline arms it later.
722
741
  * - "manual": open PR, do NOT arm (today's legacy behavior). */
723
742
  autoMerge?: "on-green" | "on-approval" | "manual";
743
+ /**
744
+ * Set when the relay observed this branch's PR already merged on the remote and is
745
+ * dispatching a land-and-continue RECYCLE (#423). Carries the relay's ground truth (the
746
+ * merge SHA/subject). Its presence forces the live-owner recycle even when local git can't
747
+ * see the landing — a squash onto an advanced base re-creates the work as a new commit
748
+ * (patch ids drift, trees diverge) so `previewWorkspaceMerge` reports ahead>0 and would
749
+ * otherwise loop via review_requested. prMerged is monotonic and was read from THIS host's
750
+ * own `checkPr` scan, so trusting it is safe; we still refuse on a dirty tree. */
751
+ prLanded?: { sha?: string; subject?: string };
724
752
  }
725
753
 
726
754
  /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
@@ -791,6 +819,7 @@ function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: str
791
819
  if (pr.state !== "merged") return false;
792
820
  base.prMerged = true;
793
821
  if (pr.sha) base.prMergeSha = pr.sha;
822
+ if (pr.title) base.prMergeSubject = pr.title;
794
823
  return true;
795
824
  }
796
825
 
@@ -915,6 +944,27 @@ export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult
915
944
 
916
945
  if (preview.missing) return head({ status: "cleaned", error: preview.reason });
917
946
  if (preview.error) return head({ status: "review_requested", error: preview.error });
947
+ // #423 squash-recycle: the relay observed this branch's PR merged on the remote (ground
948
+ // truth) and asked for a land-and-continue recycle. For a squash onto an ADVANCED base —
949
+ // the common case in a busy PR-land instance — local cherry/tree detection can't see the
950
+ // landing, so `preview.noop` is false and we'd fall through to mergeRebaseFf → "origin
951
+ // moved ahead" → review_requested → re-fired every ~2min (soft-loop). Trust prLanded and
952
+ // recycle straight to a fresh branch off the UPDATED upstream base (the squash landed on
953
+ // origin; local base was never advanced).
954
+ //
955
+ // Two hard guardrails, because this resets the owner's branch:
956
+ // 1. Dirty tree → never reset (would blow away uncommitted work). Fall through to the
957
+ // reason guard below → review_requested, retried next scan.
958
+ // 2. Only recycle once the fetched base actually CONTAINS the merge SHA. If the fetch
959
+ // hasn't propagated the PR merge yet, fall through rather than reset onto a base
960
+ // missing the landed work — never reset on an unverified ground-truth/refs mismatch.
961
+ if (input.prLanded?.sha && (preview.dirtyCount ?? 0) === 0) {
962
+ const startRef = syncBaseFromOrigin(worktreePath, preview.baseRef);
963
+ if (startRef && git(["merge-base", "--is-ancestor", input.prLanded.sha, startRef], worktreePath).ok) {
964
+ const landedPreview: WorkspaceMergePreview = { ...preview, noop: true, prMerged: true, prMergeSha: input.prLanded.sha, reason: "PR merged on remote" };
965
+ return resolveNoopMerge(input, worktreePath, repoRoot, branch, landedPreview, head, startRef);
966
+ }
967
+ }
918
968
  // Nothing to land (ahead=0, clean): the branch tree is already in base. Resolve it
919
969
  // to a terminal state so it leaves the steward queue instead of looping forever in
920
970
  // review_requested (#230). Reclaim the spent worktree/branch when the owner is gone.
@@ -943,16 +993,22 @@ function resolveNoopMerge(
943
993
  branch: string | undefined,
944
994
  preview: WorkspaceMergePreview,
945
995
  head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
996
+ // Start point for the recycled branch. Defaults to the local base ref (the rebase-ff
997
+ // noop path, where the relay just advanced local base). The PR-land recycle (#423) passes
998
+ // the upstream tip (e.g. origin/main) so the live owner continues off UPDATED base rather
999
+ // than the worktree's stale local base.
1000
+ startRef?: string,
946
1001
  ): WorkspaceMergeResult {
947
1002
  // Live owner (#327): recycle-to-continue instead of bricking the session.
948
1003
  if (input.deleteBranch === false) {
949
1004
  const base = preview.baseRef;
950
1005
  if (base && branch) {
951
1006
  const fresh = nextBranchName(repoRoot, branch);
952
- if (git(["checkout", "-B", fresh, base], worktreePath).ok) {
1007
+ const start = startRef ?? base;
1008
+ if (git(["checkout", "-B", fresh, start], worktreePath).ok) {
953
1009
  // Old branch's tree is already in base (that's what noop means) — safe to drop.
954
1010
  const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
955
- const baseSha = git(["rev-parse", base], worktreePath).stdout || undefined;
1011
+ const baseSha = git(["rev-parse", start], worktreePath).stdout || undefined;
956
1012
  // Recycled onto base, which may declare deps the symlinked node_modules lacks (#51).
957
1013
  const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
958
1014
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;