@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.
- package/dist/repo-workspace.js +52 -7
- package/dist/server.js +68 -5
- package/package.json +1 -1
package/dist/repo-workspace.js
CHANGED
|
@@ -29,15 +29,60 @@ export function parseGitHubRemote(input) {
|
|
|
29
29
|
return parseRepo(`${ssh[1]}/${ssh[2]}`);
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7198
|
-
|
|
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