agent-relay-orchestrator 0.29.0 → 0.30.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.29.0",
3
+ "version": "0.30.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.18"
19
+ "agent-relay-sdk": "0.2.19"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
@@ -144,12 +144,28 @@ export function decodeControlOutput(data: string): Uint8Array {
144
144
  return Uint8Array.from(out);
145
145
  }
146
146
 
147
- // The control stream is read as latin1 so the `%output` octal path above sees faithful
148
- // bytes. But command-reply blocks (capture-pane grid rows) arrive as raw UTF-8, so their
149
- // lines reach us as the byte-faithful latin1 representation. Re-decode them to real UTF-8
150
- // here, or multi-byte glyphs (box-drawing, powerline) double-encode on the next repaint
151
- // and render as mojibake (#270). Safe to do per line: no UTF-8 continuation byte is
152
- // 0x0A/0x0D, so the newline split never lands mid-sequence.
147
+ // Byte-faithful single-byte decode (true ISO-8859-1: byte N U+00NN). We CANNOT use
148
+ // `new TextDecoder("latin1")` per the WHATWG encoding standard "latin1" is an alias for
149
+ // windows-1252, which remaps 0x80–0x9F to printable code points (e.g. 0x96 U+2013). Those
150
+ // bytes are exactly the UTF-8 continuation/lead octets of box-drawing & powerline glyphs
151
+ // (▐ = U+2590 = E2 96 90), so a windows-1252 read is NOT reversible via `charCodeAt & 0xff`
152
+ // and corrupts every multi-byte glyph (#270 regression). fromCharCode maps each byte 1:1, so
153
+ // the round-trip below is exact. Chunked to keep fromCharCode.apply off the arg-count limit.
154
+ export function decodeLatin1(bytes: Uint8Array): string {
155
+ let out = "";
156
+ const CHUNK = 0x8000;
157
+ for (let i = 0; i < bytes.length; i += CHUNK) {
158
+ out += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ // The control stream is read byte-faithfully (see decodeLatin1) so the `%output` octal path
164
+ // above sees raw bytes. But command-reply blocks (capture-pane grid rows) arrive as raw
165
+ // UTF-8, so their lines reach us as the byte-faithful latin1 representation. Re-decode them
166
+ // to real UTF-8 here, or multi-byte glyphs (box-drawing, powerline) double-encode on the
167
+ // next repaint and render as mojibake (#270). Safe to do per line: no UTF-8 continuation
168
+ // byte is 0x0A/0x0D, so the newline split never lands mid-sequence.
153
169
  const REPLY_UTF8_DECODER = new TextDecoder("utf-8");
154
170
  export function latin1LineToUtf8(line: string): string {
155
171
  const bytes = new Uint8Array(line.length);
@@ -383,12 +399,12 @@ class SessionStream {
383
399
  const proc = this.proc;
384
400
  if (!proc?.stdout || typeof proc.stdout === "number") return;
385
401
  const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
386
- const decoder = new TextDecoder("latin1");
387
402
  try {
388
403
  for (;;) {
389
404
  const { done, value } = await reader.read();
390
405
  if (done) break;
391
- this.lineBuf += decoder.decode(value, { stream: true });
406
+ // True ISO-8859-1, NOT TextDecoder("latin1") (= windows-1252, lossy in 0x80–0x9F).
407
+ this.lineBuf += decodeLatin1(value);
392
408
  let nl: number;
393
409
  while ((nl = this.lineBuf.indexOf("\n")) !== -1) {
394
410
  const line = this.lineBuf.slice(0, nl).replace(/\r$/, "");
@@ -125,6 +125,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
125
125
 
126
126
  const id = workspaceId(input);
127
127
  const repoRoot = probe.repoRoot;
128
+ const baseRef = terminalBaseRef(repoRoot, probe.branch);
128
129
  const baseSha = probe.headSha ?? requireGit(["rev-parse", "HEAD"], repoRoot);
129
130
  const branch = await availableBranch(repoRoot, branchName(input, id));
130
131
  const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
@@ -151,7 +152,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
151
152
  sourceCwd,
152
153
  worktreePath,
153
154
  branch,
154
- baseRef: probe.branch,
155
+ baseRef,
155
156
  baseSha,
156
157
  status: "active",
157
158
  deps,
@@ -977,6 +978,44 @@ function mergePr(
977
978
  return head({ status: "merge_planned", prUrl, error: undefined });
978
979
  }
979
980
 
981
+ // Identity stamped on the merge commit a no-ff land records (#287). The merge is a
982
+ // relay/orchestrator action, not the author's — attribute it clearly so `git log`
983
+ // shows who tied the branch in, without impersonating the agent's commits (whose
984
+ // original author and SHA are preserved underneath as the merge's second parent).
985
+ const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const;
986
+
987
+ function landMergeMessage(branch: string | undefined, subject: string | undefined): string {
988
+ const name = shortBranch(branch) ?? branch ?? "branch";
989
+ return subject ? `Merge ${name}: ${subject}` : `Merge ${name}`;
990
+ }
991
+
992
+ // Record a no-ff merge of `branchSha` into `base` when base is NOT checked out in any
993
+ // worktree (#287). We can't run a working-tree merge, so synthesize the merge commit
994
+ // with plumbing: compute the merged tree, commit it with both parents (base first, so
995
+ // `--first-parent` still reads as base's mainline), then advance the ref with a CAS on
996
+ // the old value. Preserves the branch's commit SHAs without a working tree.
997
+ function recordNoFfMerge(
998
+ repoRoot: string,
999
+ base: string,
1000
+ baseSha: string,
1001
+ branchSha: string,
1002
+ message: string,
1003
+ ): { ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string } {
1004
+ const tree = git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot);
1005
+ if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
1006
+ const treeOid = tree.stdout.split("\n")[0]?.trim();
1007
+ if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
1008
+ const commit = git(
1009
+ ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
1010
+ repoRoot,
1011
+ );
1012
+ if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
1013
+ const mergeSha = commit.stdout;
1014
+ const update = git(["update-ref", `refs/heads/${base}`, mergeSha, baseSha], repoRoot);
1015
+ if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
1016
+ return { ok: true, mergeSha };
1017
+ }
1018
+
980
1019
  function mergeRebaseFf(
981
1020
  input: WorkspaceMergeInput,
982
1021
  worktreePath: string,
@@ -1005,30 +1044,46 @@ function mergeRebaseFf(
1005
1044
  }
1006
1045
  }
1007
1046
 
1008
- // Rebase the agent branch onto base so base can fast-forward to it.
1009
- if (countBehind(worktreePath, base) > 0) {
1010
- const rebase = git(["rebase", base], worktreePath);
1011
- if (!rebase.ok) {
1012
- git(["rebase", "--abort"], worktreePath);
1013
- return head({ conflict: true, status: "conflict", error: rebase.stderr || "rebase onto base failed" });
1014
- }
1015
- }
1047
+ // SHA preservation (#287): never rebase the agent branch rewriting its commits
1048
+ // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
1049
+ // base verbatim). headSha is the preserved landed commit; when base has advanced we
1050
+ // tie the branch in with a no-ff merge so the agent's commits keep their identity.
1016
1051
  const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
1017
1052
  // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
1018
1053
  // an empty/failed read just omits it from the message body.
1019
1054
  const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
1055
+ const behind = countBehind(worktreePath, base);
1020
1056
 
1021
- // Advance base to the rebased branch. If base is checked out somewhere, do a
1022
- // real ff-only merge there so its working tree stays consistent; otherwise
1023
- // just move the ref. Refuse if the base worktree has uncommitted changes.
1057
+ // Advance base. `baseTip` is base's new tip after the land: it equals headSha on a
1058
+ // clean fast-forward, or the merge commit on a no-ff merge. If base is checked out
1059
+ // somewhere, operate in that worktree so its working tree stays consistent; otherwise
1060
+ // move/synthesize the ref directly. Refuse if the base worktree has uncommitted changes.
1061
+ let baseTip = headSha;
1024
1062
  const baseWorktree = worktreeForBranch(repoRoot, base);
1025
1063
  if (baseWorktree) {
1026
1064
  if (baseWorktree.dirty) return head({ status: "review_requested", error: `base branch '${base}' has uncommitted changes in ${baseWorktree.path}` });
1027
- const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
1028
- if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
1029
- } else {
1065
+ if (behind === 0) {
1066
+ const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
1067
+ if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
1068
+ } else {
1069
+ const merge = git(
1070
+ ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
1071
+ baseWorktree.path,
1072
+ );
1073
+ if (!merge.ok) {
1074
+ git(["merge", "--abort"], baseWorktree.path);
1075
+ return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
1076
+ }
1077
+ baseTip = git(["rev-parse", "HEAD"], baseWorktree.path).stdout;
1078
+ }
1079
+ } else if (behind === 0) {
1030
1080
  const update = git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
1031
1081
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
1082
+ } else {
1083
+ const baseSha = git(["rev-parse", base], repoRoot).stdout;
1084
+ const merged = recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
1085
+ if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
1086
+ baseTip = merged.mergeSha;
1032
1087
  }
1033
1088
 
1034
1089
  // Publish the advanced base so local and origin converge (#190). We verified
@@ -1052,22 +1107,23 @@ function mergeRebaseFf(
1052
1107
  const fresh = nextBranchName(repoRoot, branch);
1053
1108
  const switched = git(["checkout", "-B", fresh, base], worktreePath);
1054
1109
  if (switched.ok) {
1055
- // The old branch now equals base (just fast-forwarded) drop the litter.
1110
+ // The old branch is now fully contained in base (fast-forwarded, or merged in
1111
+ // as the no-ff merge's second parent) — drop the litter.
1056
1112
  const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
1057
1113
  // The worktree just moved onto the advanced base, which may declare deps the
1058
1114
  // shared (symlinked) node_modules lacks. Re-provision so the recycled session
1059
1115
  // continues from a buildable state (issue #51). No-op when nothing is stale.
1060
1116
  const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
1061
1117
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
1062
- return head({ merged: true, status: "active", mergedSha: headSha, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
1118
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
1063
1119
  }
1064
1120
  // Recycle failed — keep the existing branch. Still landed, still active.
1065
- return head({ merged: true, status: "active", mergedSha: headSha, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
1121
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
1066
1122
  }
1067
1123
  const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
1068
1124
  const worktreeRemoved = removed.ok;
1069
1125
  const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
1070
- return head({ merged: true, status: "merged", mergedSha: headSha, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, error: undefined });
1126
+ return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, error: undefined });
1071
1127
  }
1072
1128
 
1073
1129
  async function availableBranch(repoRoot: string, base: string): Promise<string> {
@@ -1084,6 +1140,33 @@ function workspaceId(input: WorkspaceResolutionInput): string {
1084
1140
  return safeSegment(raw, 80);
1085
1141
  }
1086
1142
 
1143
+ // The branch a workspace lands INTO. Normally the source checkout's current branch
1144
+ // (`main`, or a deliberate feature branch). But when an agent is (re-)spawned from inside
1145
+ // a managed worktree — e.g. one recycled onto `agent/<owner>/<id>-N` after a land — the
1146
+ // source HEAD is that transient session branch. Chaining a new workspace onto it strands
1147
+ // the work one hop short of the real base: an `agent/...` branch has no remote upstream,
1148
+ // so the land advances a local-only branch that never reaches origin/main while still
1149
+ // reporting "✅ landed" (#285). Resolve to the repo's terminal base in that case; only fall
1150
+ // back to the session branch if no default base can be found (no worse than before).
1151
+ function terminalBaseRef(repoRoot: string, currentBranch: string | undefined): string | undefined {
1152
+ if (!currentBranch || !currentBranch.startsWith("agent/")) return currentBranch;
1153
+ return repoDefaultBranch(repoRoot) ?? currentBranch;
1154
+ }
1155
+
1156
+ // The repo's mainline: origin/HEAD's target when set (and present locally), else local
1157
+ // `main`/`master`. Returns undefined when none resolve.
1158
+ function repoDefaultBranch(repoRoot: string): string | undefined {
1159
+ const head = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repoRoot);
1160
+ if (head.ok && head.stdout) {
1161
+ const name = head.stdout.replace(/^[^/]+\//, ""); // strip the `origin/` remote prefix
1162
+ if (name && git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], repoRoot).ok) return name;
1163
+ }
1164
+ for (const candidate of ["main", "master"]) {
1165
+ if (git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
1166
+ }
1167
+ return undefined;
1168
+ }
1169
+
1087
1170
  function branchName(input: WorkspaceResolutionInput, id: string): string {
1088
1171
  const owner = input.policyName || input.label || input.automationId || "manual";
1089
1172
  // 40 fits a full UUID (36) plus the `sp_`-stripped slack. A tighter cap (was 24)