@bivy/bivy 0.3.0-staging.46 → 0.3.0-staging.47

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.
@@ -29,15 +29,60 @@ export function parseGitHubRemote(input) {
29
29
  return parseRepo(`${ssh[1]}/${ssh[2]}`);
30
30
  return undefined;
31
31
  }
32
- /** Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout. */
32
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
+ /**
34
+ * A git failure that DEFINITIVELY means "this workspace is not a GitHub-connected
35
+ * checkout" — a genuine `undefined` answer — as opposed to a transient failure we
36
+ * must not mistake for one. `git remote get-url origin` reports "not a git
37
+ * repository" (no repo) or "No such remote" (a repo with no origin); both are
38
+ * real, stable answers. Anything else (notably `index.lock`/`config.lock`
39
+ * contention when many sessions touch the same shared clone at once) is transient.
40
+ */
41
+ function isDefinitiveNonGitHubError(error) {
42
+ const e = error;
43
+ const text = `${e?.stderr ?? ""} ${e?.message ?? String(error)}`;
44
+ return /not a git repository|No such remote|does not appear to be a git repository/i.test(text);
45
+ }
46
+ /**
47
+ * Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout.
48
+ *
49
+ * Retries transient git failures before giving up. Misclassifying a momentarily
50
+ * busy GitHub checkout as "not a repo" is what let a session skip worktree
51
+ * isolation and run directly in the shared clone root, where its `git
52
+ * checkout`/`git stash` collided with a concurrent session (the "sessions
53
+ * mixing" bug). So: a DEFINITIVE non-GitHub result (not a repo / no origin)
54
+ * resolves to `undefined` as before, but a transient error is retried and then
55
+ * THROWN — the caller must fail loudly rather than silently degrade to running
56
+ * the agent in the shared root.
57
+ */
33
58
  export async function inferGitHubRepoFromWorkspace(workspace) {
34
- try {
35
- const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"], { cwd: workspace });
36
- return parseGitHubRemote(stdout);
37
- }
38
- catch {
39
- return undefined;
59
+ let lastError;
60
+ for (let attempt = 0; attempt < 3; attempt++) {
61
+ try {
62
+ const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"], { cwd: workspace });
63
+ return parseGitHubRemote(stdout);
64
+ }
65
+ catch (error) {
66
+ if (isDefinitiveNonGitHubError(error))
67
+ return undefined;
68
+ lastError = error;
69
+ if (attempt < 2)
70
+ await delay(50 * (attempt + 1));
71
+ }
40
72
  }
73
+ throw new Error(`Could not determine the GitHub repo for ${workspace} (the checkout may be busy): ` +
74
+ `${lastError?.message ?? String(lastError)}`);
75
+ }
76
+ /**
77
+ * True when `dir` is a Bivy-managed shared clone root — a direct child of the
78
+ * repos root, i.e. `<reposRoot>/owner__repo` (see `cloneOrUpdateRepo`). Every
79
+ * session for a repo shares that one checkout, so an agent must NEVER run
80
+ * directly in it; it runs in a per-session worktree instead. Worktree paths live
81
+ * DEEPER (`<clone>/.bivy/worktrees/<slug>`) and are intentionally not matched, so
82
+ * this cleanly distinguishes "the shared root" from "an isolated worktree".
83
+ */
84
+ export function isSharedCloneRoot(dir, reposRoot) {
85
+ return path.resolve(path.dirname(path.resolve(dir))) === path.resolve(reposRoot);
41
86
  }
42
87
  /** A GitHub token from env or the local `gh` login, or undefined (public only). */
43
88
  export async function resolveGitHubToken(env = process.env) {
package/dist/server.js CHANGED
@@ -47,7 +47,7 @@ import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint }
47
47
  import { PolicyEngine } from "./policy/policy-engine.js";
48
48
  import { TerminalManager } from "./terminal.js";
49
49
  import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
50
- import { createWorktree, removeWorktree, branchSlug } from "./worktree.js";
50
+ import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
51
51
  import { HarnessManager } from "./harness/manager.js";
52
52
  import { startEgressProxyIfEnabled } from "./harness/egress.js";
53
53
  import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
@@ -55,7 +55,7 @@ import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
55
55
  import { checkDiskAdmission } from "./harness/disk-admission.js";
56
56
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
57
57
  import { injectMcpProxyForSession } from "./harness/mcp-inject.js";
58
- import { parseRepo, inferGitHubRepoFromWorkspace, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
58
+ import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
59
59
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
60
60
  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";
61
61
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
@@ -5525,6 +5525,22 @@ async function standUpFork(opts) {
5525
5525
  cwd = wt.path;
5526
5526
  worktree = wt;
5527
5527
  }
5528
+ else {
5529
+ // Non-repo-backed source. The fork would otherwise reuse the PARENT's cwd,
5530
+ // putting two sessions in one working tree — so when that cwd is itself a git
5531
+ // checkout (a local repo without a GitHub origin), cut the fork its own
5532
+ // worktree on a fresh branch. Best-effort: a non-git workspace has no tree to
5533
+ // isolate, so the fork keeps the fallback cwd (no git collisions possible).
5534
+ const forkRepoRoot = await gitRepoRoot(cwd);
5535
+ if (forkRepoRoot) {
5536
+ const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
5537
+ const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
5538
+ applyDirtyPatch(wt.path, bundle.dirtyPatch);
5539
+ workspace = forkRepoRoot;
5540
+ cwd = wt.path;
5541
+ worktree = wt;
5542
+ }
5543
+ }
5528
5544
  // Materialise the transcript, then stand the session up — resume the imported
5529
5545
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
5530
5546
  const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
@@ -7164,9 +7180,52 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7164
7180
  if (!admission.allowed)
7165
7181
  throw new Error(`Not enough disk to start a new worktree session: ${admission.reason}`);
7166
7182
  const wtOpts = typeof opts.worktree === "object" ? opts.worktree : {};
7167
- worktree = await createWorktree({ repoDir: workspace, id: wtOpts.branch ?? `session-${Date.now()}`, branch: wtOpts.branch, base: wtOpts.base });
7183
+ // A random suffix, never a timestamp: two sessions started in the same
7184
+ // millisecond would otherwise resolve to the same slug → same worktree path
7185
+ // and branch, and `createWorktree` would ADOPT the first's worktree, dropping
7186
+ // the second session into a directory another session already owns.
7187
+ worktree = await createWorktree({ repoDir: workspace, id: wtOpts.branch ?? `session-${randomBytes(6).toString("hex")}`, branch: wtOpts.branch, base: wtOpts.base });
7168
7188
  runtimeWorkspace = worktree.path;
7169
7189
  }
7190
+ // Resume with a reaped worktree. When a repo-backed session is resumed but its
7191
+ // worktree directory was removed while it was closed (disk cleanup, a manual
7192
+ // rm, `git worktree remove`), restoredWorktree is undefined and we'd otherwise
7193
+ // fall back to the shared clone root — which the invariant below then rejects.
7194
+ // Re-provision a fresh worktree on the SAME branch instead (branches survive
7195
+ // `git worktree remove`, so the agent's committed history is intact), restoring
7196
+ // isolation so the resumed session is usable again. Best-effort: if the clone
7197
+ // or branch is gone, we leave it to the invariant to fail safe rather than
7198
+ // corrupt a neighbour. The clone root is reconstructed from `source`
7199
+ // (`repo:owner/repo`) because stored `workspace` is the old worktree path.
7200
+ if (requestedSessionFile && !worktree && storedMeta?.worktree && storedMeta?.branch) {
7201
+ const parsedSource = parseRepoSource(storedMeta.source);
7202
+ if (parsedSource) {
7203
+ const repoDir = path.join(reposRoot, `${parsedSource.owner}__${parsedSource.repo}`);
7204
+ try {
7205
+ // Clear any stale registration left by a dir that was rm'd out from under
7206
+ // git, so re-adding the branch's worktree doesn't hit "already checked out".
7207
+ runGit(["worktree", "prune"], repoDir);
7208
+ const reprovisioned = await createWorktree({ repoDir, id: storedMeta.branch, branch: storedMeta.branch });
7209
+ worktree = reprovisioned;
7210
+ runtimeWorkspace = reprovisioned.path;
7211
+ }
7212
+ catch (error) {
7213
+ console.warn(`Could not re-provision worktree for resumed session on ${storedMeta.branch}: ${error instanceof Error ? error.message : String(error)}`);
7214
+ }
7215
+ }
7216
+ }
7217
+ // Isolation invariant. A session must NEVER run directly in a Bivy-managed
7218
+ // shared clone root (`<reposRoot>/owner__repo`): every session for that repo
7219
+ // shares that one checkout, so an agent running there collides with concurrent
7220
+ // sessions on `git checkout`/`git stash` — exactly the "sessions mixing" bug.
7221
+ // A GitHub-backed session is supposed to get its own worktree; reaching here
7222
+ // without one means an earlier step degraded (e.g. a transient repo-inference
7223
+ // failure, or a resume whose worktree was reaped). Fail loudly instead of
7224
+ // silently sharing the tree and corrupting a neighbouring session's work.
7225
+ if (!worktree && isSharedCloneRoot(runtimeWorkspace, reposRoot)) {
7226
+ throw new Error(`Refusing to start a session in the shared clone root ${runtimeWorkspace} without an isolated worktree — ` +
7227
+ `this would collide with concurrent sessions on the same repo. Retry; if it persists the checkout may be busy.`);
7228
+ }
7170
7229
  const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7171
7230
  // Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
7172
7231
  // OWN agent service — over re-opening a fresh copy from disk. Falls back to
@@ -7194,8 +7253,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7194
7253
  if (requestedSessionFile && storedMeta?.name && !session.getName())
7195
7254
  session.setName(storedMeta.name);
7196
7255
  const sessionWorkspace = session.cwd || runtimeWorkspace;
7197
- const inferredRepo = opts.source || storedMeta?.source ? undefined : await inferGitHubRepoFromWorkspace(sessionWorkspace);
7198
- if (!worktree && requestedSessionFile && inferredRepo) {
7256
+ // Best-effort here (unlike createWorkspaceSession, which must fail loudly):
7257
+ // this only decides whether to ADOPT an already-checked-out branch as the
7258
+ // session's worktree label, so a transient inference failure should quietly
7259
+ // skip adoption rather than break resuming the session.
7260
+ const inferredRepo = opts.source || storedMeta?.source ? undefined : await inferGitHubRepoFromWorkspace(sessionWorkspace).catch(() => undefined);
7261
+ if (!worktree && requestedSessionFile && inferredRepo && !isSharedCloneRoot(sessionWorkspace, reposRoot)) {
7199
7262
  const branch = runGit(["branch", "--show-current"], sessionWorkspace) || runGit(["rev-parse", "--short", "HEAD"], sessionWorkspace) || undefined;
7200
7263
  if (branch) {
7201
7264
  const mainWorktree = runGit(["worktree", "list", "--porcelain"], sessionWorkspace)?.split("\n").find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.46",
3
+ "version": "0.3.0-staging.47",
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.",