@bivy/bivy 0.6.0-staging.83 → 0.6.0-staging.84

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.
@@ -164,6 +164,25 @@ export async function resolveBranchBaseRef(repoDir, branch) {
164
164
  throw new Error(`Branch "${branch}" was not found on the remote.`);
165
165
  }
166
166
  }
167
+ /**
168
+ * Base ref for ADOPTING a source branch onto a fresh clone on another node (a
169
+ * cross-node fork). Prefers the pushed `origin/<branch>` so the source's
170
+ * committed work travels; falls back to the repo's default branch when the
171
+ * source branch was never pushed (best-effort — any uncommitted work still
172
+ * arrives via the fork's dirty patch). Fetches first so `origin/<branch>` is
173
+ * current. Contrast with `resolveBranchBaseRef`, which is user-facing and throws
174
+ * on a missing branch; a fork must degrade rather than fail.
175
+ */
176
+ export async function resolveAdoptBaseRef(repoDir, branch) {
177
+ await fetchOrigin(repoDir);
178
+ try {
179
+ await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd: repoDir });
180
+ return `origin/${branch}`;
181
+ }
182
+ catch {
183
+ return resolveDefaultBaseRef(repoDir);
184
+ }
185
+ }
167
186
  /**
168
187
  * Whether an existing Bivy-owned checkout at `dest` can be reused as-is, i.e. it
169
188
  * has a `.git` entry AND `git rev-parse` accepts it as a real repository. A
package/dist/server.js CHANGED
@@ -60,7 +60,7 @@ import { checkDiskAdmission } from "./harness/disk-admission.js";
60
60
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
61
61
  import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
62
62
  import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
63
- import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
63
+ import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
64
64
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
65
65
  import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
66
66
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
@@ -3560,6 +3560,7 @@ const RELAY_COMMANDS = {
3560
3560
  source: rec.source,
3561
3561
  title: rec.session.getName(),
3562
3562
  model: rec.session.getCurrentModel()?.name,
3563
+ sandbox: rec.sandbox,
3563
3564
  };
3564
3565
  let dirtyPatch;
3565
3566
  if (rec.worktree) {
@@ -3568,6 +3569,14 @@ const RELAY_COMMANDS = {
3568
3569
  }
3569
3570
  catch { /* best effort — omit dirty state */ }
3570
3571
  }
3572
+ // Publish the source branch so a cross-node fork's COMMITTED work travels
3573
+ // via origin (the destination adopts `origin/<branch>`; see
3574
+ // resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
3575
+ // for a genuine cross-node fork — a same-node cross-agent fork adopts the
3576
+ // LOCAL branch and needs no push. Best-effort: a no-token/offline node just
3577
+ // falls back to the default base downstream.
3578
+ if (msg.crossNode === true)
3579
+ await pushForkSourceBranch(rec);
3571
3580
  // Refresh the account model-auth vault so the destination node can pull
3572
3581
  // this session's model credentials during import (fork credential-move,
3573
3582
  // docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
@@ -3648,6 +3657,7 @@ const RELAY_COMMANDS = {
3648
3657
  source: rec.source,
3649
3658
  title: rec.session.getName(),
3650
3659
  model: rec.session.getCurrentModel()?.name,
3660
+ sandbox: rec.sandbox,
3651
3661
  };
3652
3662
  // Carry uncommitted work: capture from the SOURCE worktree; standUpFork
3653
3663
  // re-applies it into the fork's fresh worktree. Local git ops only.
@@ -5859,6 +5869,48 @@ async function applyRequestedModel(record, model) {
5859
5869
  broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
5860
5870
  }
5861
5871
  }
5872
+ // Serialize clone + worktree work per repo directory. Two forks (or a fork and
5873
+ // a GitHub pickup) hitting the same shared clone concurrently race on
5874
+ // `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
5875
+ // see "already exists"/"already checked out" or, worse, `createWorktree`'s
5876
+ // adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
5877
+ // mutex removes the race without a filesystem lock.
5878
+ const repoWorktreeLocks = new Map();
5879
+ async function withRepoLock(key, fn) {
5880
+ const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
5881
+ // Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
5882
+ // failing fork doesn't poison the next waiter's gate. Each caller still awaits
5883
+ // its own `run` and gets its own result/exception. Bounded by repo count.
5884
+ const gate = prev.then(() => { }, () => { });
5885
+ const run = gate.then(fn);
5886
+ repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
5887
+ return run;
5888
+ }
5889
+ /**
5890
+ * Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
5891
+ * the node, so a cross-node fork's committed work travels via origin (the
5892
+ * destination bases its adopted worktree on `origin/<branch>` — see
5893
+ * `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
5894
+ * means the destination falls back to the default base and the dirty patch.
5895
+ */
5896
+ async function pushForkSourceBranch(rec) {
5897
+ const parts = repoSessionParts(rec);
5898
+ if (!parts)
5899
+ return;
5900
+ const { wt, parsed } = parts;
5901
+ try {
5902
+ const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5903
+ if (!token)
5904
+ return;
5905
+ const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
5906
+ await pushBranch(cfg, wt.path, wt.branch);
5907
+ rec.branchPushed = true;
5908
+ }
5909
+ catch {
5910
+ // offline / no rights / protected branch — committed work may not reach a
5911
+ // cross-node destination, but the fork still proceeds from the best base.
5912
+ }
5913
+ }
5862
5914
  /**
5863
5915
  * Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
5864
5916
  * (optional) prerequisite detection, repo/worktree reconstruction, transcript
@@ -5869,8 +5921,10 @@ async function applyRequestedModel(record, model) {
5869
5921
  */
5870
5922
  async function standUpFork(opts) {
5871
5923
  const { bundle, targetRuntimeId } = opts;
5872
- const targetRuntime = getRuntime(targetRuntimeId);
5873
5924
  const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
5925
+ // Carry the source's sandbox tier so a sandboxed session forks into a
5926
+ // sandboxed one, rather than defaulting to this node's tier (fork.ts).
5927
+ const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
5874
5928
  // Credential-move: if the chosen model's provider isn't logged in on this node,
5875
5929
  // pull the account model-auth vault (a login done on another node carries over),
5876
5930
  // then re-check. Best-effort — a local-only node just skips it.
@@ -5882,41 +5936,64 @@ async function standUpFork(opts) {
5882
5936
  modelConfigured = await providerConfigured();
5883
5937
  }
5884
5938
  // Prerequisite detection. A missing AGENT is a hard blocker — stop before any
5885
- // clone/worktree work. Skipped for a same-node local fork.
5939
+ // clone/worktree work. Skipped for a same-node local fork. Read the agent's
5940
+ // availability + display name from the runtime REGISTRY (which never throws)
5941
+ // rather than resolving the runtime up front: `getRuntime` throws for a
5942
+ // known-but-not-installed agent, which — called eagerly — surfaced a raw
5943
+ // "not available" string with an empty `missing[]` instead of this friendly
5944
+ // install checklist. An unknown id (no registry entry) is treated as
5945
+ // unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
5886
5946
  const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
5887
- const agentAvailable = agentInfo ? agentInfo.status === "available" : true;
5947
+ const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
5948
+ const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
5888
5949
  const prereqInput = {
5889
- agent: { id: targetRuntimeId, displayName: targetRuntime.displayName, available: agentAvailable },
5950
+ agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
5890
5951
  ...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
5891
5952
  };
5892
5953
  if (opts.detectPrereqs) {
5893
5954
  const early = evaluateForkPrereqs(prereqInput);
5894
5955
  if (blockingForkPrereqs(early).length > 0) {
5895
- return { ok: false, error: `${targetRuntime.displayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5956
+ return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5896
5957
  }
5897
5958
  }
5959
+ // Safe now: the agent is available (or this is a same-node local fork whose
5960
+ // agent is self-evidently present). The per-session sandbox tier bakes into
5961
+ // the runtime's launch flags.
5962
+ const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
5898
5963
  // Reconstruct repo + worktree when the source was repo-backed.
5899
5964
  let workspace = fallback.workspace;
5900
5965
  let cwd = fallback.cwd;
5901
5966
  let repoReachable;
5902
5967
  let worktree;
5968
+ let dirtyWarning;
5903
5969
  const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
5904
5970
  if (parsed) {
5905
5971
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5906
5972
  repoReachable = Boolean(token);
5907
5973
  const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
5908
5974
  const srcBranch = bundle.record.branch;
5909
- let wt;
5910
- if (opts.worktree === "fresh") {
5911
- // Cut a new branch from the source branch (or the repo's default base).
5912
- const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5913
- wt = await createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
5914
- }
5915
- else {
5916
- // Adopt the source branch, or a fresh random worktree when it had none.
5917
- wt = await createWorktree({ repoDir, id: srcBranch ?? `fork-${randomBytes(6).toString("hex")}`, branch: srcBranch, base: srcBranch ? undefined : await resolveDefaultBaseRef(repoDir) });
5918
- }
5919
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
5975
+ // Serialize clone-adjacent worktree ops on this repo so concurrent forks /
5976
+ // pickups don't race on `git worktree add` or clobber each other's trees.
5977
+ const wt = await withRepoLock(repoDir, async () => {
5978
+ if (opts.worktree === "fresh") {
5979
+ // Same-node fork: cut a NEW branch from the source's LOCAL branch (which
5980
+ // holds its latest, possibly-unpushed commits) or the repo default.
5981
+ const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5982
+ return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
5983
+ }
5984
+ // Cross-node adopt: the source branch has no LOCAL ref here. Base the
5985
+ // adopted branch on the pushed `origin/<branch>` so committed work travels
5986
+ // (was: undefined → the destination's DEFAULT branch, silently dropping
5987
+ // every commit). Give the worktree DIR a unique suffix so a same-branch
5988
+ // adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
5989
+ // — another live session's tree.
5990
+ const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
5991
+ const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
5992
+ return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
5993
+ });
5994
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
5995
+ if (applied.warning)
5996
+ dirtyWarning = applied.warning;
5920
5997
  workspace = repoDir;
5921
5998
  cwd = wt.path;
5922
5999
  worktree = wt;
@@ -5930,8 +6007,10 @@ async function standUpFork(opts) {
5930
6007
  const forkRepoRoot = await gitRepoRoot(cwd);
5931
6008
  if (forkRepoRoot) {
5932
6009
  const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
5933
- const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
5934
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
6010
+ const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
6011
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
6012
+ if (applied.warning)
6013
+ dirtyWarning = applied.warning;
5935
6014
  workspace = forkRepoRoot;
5936
6015
  cwd = wt.path;
5937
6016
  worktree = wt;
@@ -5941,8 +6020,8 @@ async function standUpFork(opts) {
5941
6020
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
5942
6021
  const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
5943
6022
  const record = plan.kind === "resume"
5944
- ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false })
5945
- : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false });
6023
+ ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
6024
+ : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
5946
6025
  // Mark the new session as a fork of its source, so the run card can show
5947
6026
  // "Forked from …" and the lineage survives a reload (persisted below). Just
5948
6027
  // the parent's session id — an identifier, not content, so it's safe to
@@ -5961,6 +6040,10 @@ async function standUpFork(opts) {
5961
6040
  }
5962
6041
  if (bundle.record.title && !record.session.getName())
5963
6042
  record.session.setName(bundle.record.title);
6043
+ // Surface a non-fatal note when the source's uncommitted changes didn't apply
6044
+ // cleanly, so the fork isn't silently missing work-in-progress.
6045
+ if (dirtyWarning)
6046
+ broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
5964
6047
  await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
5965
6048
  persistSessionMetadata(record);
5966
6049
  scheduleAdvertise();
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -53,14 +53,52 @@ export function captureDirtyPatch(repoDir, opts = {}) {
53
53
  * source pushed the branch instead (`pushedInstead`) or the working tree was
54
54
  * clean (empty patch). Uses `git apply` so both tracked hunks and untracked
55
55
  * new-file hunks (produced via `--no-index`) land correctly.
56
+ *
57
+ * NEVER throws: a fork's uncommitted changes are best-effort, and the source's
58
+ * base commit frequently differs from what the destination cloned (a diverged
59
+ * default branch, an unpushed source branch), so a strict `git apply` fails on
60
+ * hunk-context mismatch and previously took the whole fork down with it. Instead
61
+ * we fall back to `git apply --3way` (which reconstructs the hunks from the blob
62
+ * SHAs the patch carries and merges what it can) and, when even that fails,
63
+ * surface a warning and leave the tree as the clone left it — the fork still
64
+ * succeeds, minus the un-appliable working-tree edits.
56
65
  */
57
66
  export function applyDirtyPatch(repoDir, dirty) {
58
67
  if (!dirty || dirty.pushedInstead || !dirty.patch.trim())
59
- return;
68
+ return { applied: false };
60
69
  const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
61
70
  fs.writeFileSync(tmp, dirty.patch);
62
71
  try {
63
- execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
72
+ try {
73
+ execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
74
+ return { applied: true };
75
+ }
76
+ catch {
77
+ // Clean apply failed — the destination's base drifted from the source's.
78
+ // Retry with a 3-way merge, which reconstructs the pre-image from the blob
79
+ // SHAs the patch carries (present because the destination cloned the same
80
+ // repo) and merges what it can. `git apply --3way` exits non-zero BOTH for
81
+ // an un-appliable patch (nothing lands) AND for a conflicting one (it lands
82
+ // the non-conflicting hunks and writes conflict markers) — so read the exit
83
+ // status/stderr with spawnSync rather than treating every non-zero as a
84
+ // total failure that drops all the WIP.
85
+ const res = spawnSync("git", ["-C", repoDir, "apply", "--3way", "--whitespace=nowarn", tmp], { encoding: "utf8" });
86
+ if (res.status === 0)
87
+ return { applied: true }; // merged cleanly onto the diverged base
88
+ const stderr = (res.stderr || "").toString();
89
+ if (/with conflicts/i.test(stderr)) {
90
+ return {
91
+ applied: true,
92
+ conflicted: true,
93
+ warning: "Some uncommitted changes from the source didn't apply cleanly and were merged with conflict markers — review and resolve them in the fork.",
94
+ };
95
+ }
96
+ const detail = stderr.split("\n").find((l) => l.trim()) || "patch did not apply";
97
+ return {
98
+ applied: false,
99
+ warning: `Couldn't re-apply the source's uncommitted changes (${detail}); they were left behind. Re-make them in the fork if you still need them.`,
100
+ };
101
+ }
64
102
  }
65
103
  finally {
66
104
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.6.0-staging.83",
3
+ "version": "0.6.0-staging.84",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",