@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.
- package/dist/repo-workspace.js +19 -0
- package/dist/server.js +104 -21
- package/dist/session/fork-dirty.js +41 -3
- package/package.json +1 -1
package/dist/repo-workspace.js
CHANGED
|
@@ -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" :
|
|
5947
|
+
const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
|
|
5948
|
+
const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
|
|
5888
5949
|
const prereqInput = {
|
|
5889
|
-
agent: { id: targetRuntimeId, displayName:
|
|
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: `${
|
|
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
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
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
|
-
|
|
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