agent-relay-orchestrator 0.58.0 → 0.60.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.60.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.36"
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") {
@@ -68,6 +68,10 @@ interface PendingCommand {
68
68
  resolve: (lines: string[]) => void;
69
69
  reject: (err: Error) => void;
70
70
  timer: ReturnType<typeof setTimeout> | null;
71
+ // Fired SYNCHRONOUSLY when this command's reply block closes — before readLoop hands any
72
+ // later same-chunk %output to enqueue(). The resync screen-capture uses it to snapshot the
73
+ // pre-capture delta boundary (#275 dedup); see snapshotPreCaptureDeltas.
74
+ onFinishSync?: () => void;
71
75
  }
72
76
  // On attach we include this many lines of tmux scrollback ABOVE the current screen so the
73
77
  // viewer can scroll back through pre-attach history (the client lands on the live screen;
@@ -352,6 +356,12 @@ class SessionStream {
352
356
  // long the ground gate has been deferring (for the CAN fallback).
353
357
  private resyncInFlight = false;
354
358
  private groundDeferStart = 0;
359
+ // #275 dedup: deltas that were in `pending` at the resync capture's %end — the PRE-capture
360
+ // %output. doResync drains exactly these before the repaint; %output that arrives after the
361
+ // capture (even later in the same readLoop chunk) accumulates in a fresh `pending` and
362
+ // applies ON TOP of the repaint on the trailing flush, never re-scrolled beneath it.
363
+ private resyncPreCapture: Uint8Array[] | null = null;
364
+ private resyncPreCaptureBytes = 0;
355
365
 
356
366
  constructor(
357
367
  private readonly session: string,
@@ -456,6 +466,11 @@ class SessionStream {
456
466
  const entry = this.pendingCommands.shift();
457
467
  if (!entry) return; // unexpected extra block — drop it rather than mis-correlate
458
468
  if (entry.timer) clearTimeout(entry.timer);
469
+ // Snapshot the delta boundary NOW, synchronously, before resolve() and before readLoop
470
+ // processes any further %output in this chunk — so the boundary is exactly this block's %end.
471
+ if (entry.onFinishSync) {
472
+ try { entry.onFinishSync(); } catch {}
473
+ }
459
474
  if (isError) entry.reject(new Error(block.lines.join(" ").trim() || "tmux command error"));
460
475
  else entry.resolve(block.lines.map(latin1LineToUtf8));
461
476
  }
@@ -587,16 +602,22 @@ class SessionStream {
587
602
  this.resyncInFlight = true;
588
603
  try {
589
604
  const repaint = await this.resyncRepaint();
590
- if (!repaint || this.closed || this.subscribers.size === 0) return;
591
- // Ordering (#275): every %output read before the capture's %end is already in
592
- // `pending`; drain it to subscribers BEFORE the repaint so scrolled lines can't
593
- // re-apply on top of it (the duplicate-text race). Deltas after %end describe
594
- // post-capture changes and correctly apply on top of the repaint next flush.
595
- this.flush();
605
+ // Ordering (#275): the %output snapshotted at the capture's %end (resyncPreCapture) is
606
+ // the PRE-capture stream drain it to subscribers BEFORE the repaint so scrolled lines
607
+ // can't re-apply on top of it (the duplicate-text race). %output that arrived AFTER the
608
+ // capture stayed in `pending` and applies on top of the repaint via the trailing flush.
609
+ this.drainPreCaptureDeltas();
610
+ if (!repaint || this.closed || this.subscribers.size === 0) {
611
+ this.flush(); // release any post-capture deltas normally (no repaint to inject)
612
+ return;
613
+ }
596
614
  const out = forceAbort ? this.prependCan(repaint) : repaint;
597
615
  this.broadcast(out);
616
+ this.flush(); // post-capture deltas, on top of the fresh repaint
598
617
  tdbg(`resync ${this.session} bytes=${out.length} viewers=${this.subscribers.size}${forceAbort ? " (forced)" : ""}`);
599
618
  } catch (e) {
619
+ this.drainPreCaptureDeltas();
620
+ this.flush();
600
621
  tdbg(`resync ${this.session} failed: ${errMessage(e)}`);
601
622
  } finally {
602
623
  this.resyncInFlight = false;
@@ -628,12 +649,58 @@ class SessionStream {
628
649
  // The grid + cursor are read in-band through the control client (#275), so the capture
629
650
  // is serialized with %output deltas and costs no process spawn.
630
651
  private async resyncRepaint(): Promise<Uint8Array | null> {
631
- const body = await this.readScreen();
632
- if (!body) return null;
652
+ // ORDER IS LOAD-BEARING for the #275 dedup. The screen capture is the serialization
653
+ // point: doResync drains `pending` (pre-capture %output) and emits this repaint right
654
+ // after. So the capture MUST be the LAST in-band read here — any awaited round-trip
655
+ // after it (the old readCursorState/paneDims) lets live %output land in `pending` that
656
+ // doResync then flushes BEFORE this now-stale repaint, re-scrolling lines the repaint
657
+ // still shows on-screen into the client's scrollback → duplicated text under load.
658
+ // Read the cursor first; reuse the cached row count (resize drives its own backfill, so
659
+ // termRows is authoritative without a round-trip); capture the grid last.
633
660
  const cursor = await this.readCursorState();
634
- const dims = await this.paneDims();
635
- const rows = dims.rows ?? this.termRows;
636
- return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
661
+ // Capture LAST, and tag it so its %end snapshots the pre-capture delta boundary (the
662
+ // capture-pane reply IS the serialization point against the %output stream).
663
+ const lines = await this.command(
664
+ `capture-pane -p -e -t "${this.session}"`,
665
+ true,
666
+ () => this.snapshotPreCaptureDeltas(),
667
+ ).catch(() => null);
668
+ const body = lines ? lines.join("\n") : "";
669
+ if (!body) return null;
670
+ return new TextEncoder().encode(buildInPlaceRepaint(body, this.termRows, cursor));
671
+ }
672
+
673
+ // Swap the live `pending` buffer aside at the resync capture's %end: these chunks are the
674
+ // PRE-capture %output (drained before the repaint), and a fresh `pending` collects anything
675
+ // after (applied on top, next flush). Runs synchronously inside finishBlock (#275).
676
+ private snapshotPreCaptureDeltas(): void {
677
+ this.resyncPreCapture = this.pending;
678
+ this.resyncPreCaptureBytes = this.pendingBytes;
679
+ this.pending = [];
680
+ this.pendingBytes = 0;
681
+ if (this.flushTimer !== null) {
682
+ clearTimeout(this.flushTimer);
683
+ this.flushTimer = null;
684
+ }
685
+ }
686
+
687
+ // Broadcast the snapshotted pre-capture deltas (mirrors flush()'s ANSI-state bookkeeping)
688
+ // ahead of the repaint. No-op when nothing was snapshotted (capture timed out / errored).
689
+ private drainPreCaptureDeltas(): void {
690
+ const chunks = this.resyncPreCapture;
691
+ const total = this.resyncPreCaptureBytes;
692
+ this.resyncPreCapture = null;
693
+ this.resyncPreCaptureBytes = 0;
694
+ if (!chunks || total === 0) return;
695
+ const merged = new Uint8Array(total);
696
+ let offset = 0;
697
+ for (const chunk of chunks) {
698
+ merged.set(chunk, offset);
699
+ offset += chunk.length;
700
+ }
701
+ this.broadcastState = scanAnsiState(merged, this.broadcastState);
702
+ this.broadcast(merged);
703
+ if (this.broadcastState === "ground") this.fireGroundWaiters();
637
704
  }
638
705
 
639
706
  // Read tmux's authoritative grid (styled) plus a consistent cursor, and turn it into a
@@ -793,14 +860,14 @@ class SessionStream {
793
860
  // Write a command to the control client's stdin and register a FIFO entry for its reply
794
861
  // block. `wantReply` commands resolve with the block's lines (or reject on %error /
795
862
  // timeout); fire-and-forget commands resolve with [] once their empty block closes.
796
- private command(line: string, wantReply = false): Promise<string[]> {
863
+ private command(line: string, wantReply = false, onFinishSync?: () => void): Promise<string[]> {
797
864
  if (this.closed) return wantReply ? Promise.reject(new Error("terminal stream closed")) : Promise.resolve([]);
798
865
  const stdin = this.proc?.stdin;
799
866
  if (!stdin || typeof stdin === "number") {
800
867
  return wantReply ? Promise.reject(new Error("control client unavailable")) : Promise.resolve([]);
801
868
  }
802
869
  return new Promise<string[]>((resolve, reject) => {
803
- const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null };
870
+ const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null, onFinishSync };
804
871
  if (wantReply) entry.timer = setTimeout(() => this.commandTimeout(entry), COMMAND_TIMEOUT_MS);
805
872
  this.pendingCommands.push(entry);
806
873
  try {
@@ -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;