@bivy/bivy 0.6.0-staging.83 → 0.6.0-staging.85
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 +205 -48
- 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";
|
|
@@ -3084,6 +3084,7 @@ const RELAY_COMMANDS = {
|
|
|
3084
3084
|
},
|
|
3085
3085
|
async "models.list"(msg) {
|
|
3086
3086
|
const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
|
|
3087
|
+
const wantedRuntimeId = typeof msg.runtimeId === "string" && msg.runtimeId ? msg.runtimeId : undefined;
|
|
3087
3088
|
let record;
|
|
3088
3089
|
try {
|
|
3089
3090
|
record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, msg.path) : active;
|
|
@@ -3096,7 +3097,12 @@ const RELAY_COMMANDS = {
|
|
|
3096
3097
|
relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId, error: "Session not found" });
|
|
3097
3098
|
return;
|
|
3098
3099
|
}
|
|
3099
|
-
|
|
3100
|
+
// On a draft (no session id), a runtime hint from the composer takes
|
|
3101
|
+
// precedence so an agent switch previews *that* agent's models even if a
|
|
3102
|
+
// stale `active` on another runtime lingers on the node.
|
|
3103
|
+
if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
|
|
3104
|
+
record = null;
|
|
3105
|
+
record ??= await sessionForModelQuery(wantedRuntimeId);
|
|
3100
3106
|
const session = record.session;
|
|
3101
3107
|
const current = session.getCurrentModel();
|
|
3102
3108
|
const models = await publicModelsList(session, current);
|
|
@@ -3108,6 +3114,17 @@ const RELAY_COMMANDS = {
|
|
|
3108
3114
|
// (e.g. Claude) — the "Claude shows Codex models" bug.
|
|
3109
3115
|
relay?.sendEvent({ type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking });
|
|
3110
3116
|
},
|
|
3117
|
+
"models.prefetch"(msg) {
|
|
3118
|
+
// The composer's agent picker opened: warm the scratch session for each
|
|
3119
|
+
// offered agent in the background so the first switch to any of them answers
|
|
3120
|
+
// instantly. Fire-and-forget — no reply; the follow-up models.list carries
|
|
3121
|
+
// the result. Ignore anything but a bounded string[] of runtime ids.
|
|
3122
|
+
const ids = Array.isArray(msg.runtimeIds)
|
|
3123
|
+
? msg.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
|
|
3124
|
+
: [];
|
|
3125
|
+
if (ids.length)
|
|
3126
|
+
prefetchModels(ids);
|
|
3127
|
+
},
|
|
3111
3128
|
async "model.select"(msg) {
|
|
3112
3129
|
const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
|
|
3113
3130
|
let record;
|
|
@@ -3560,6 +3577,7 @@ const RELAY_COMMANDS = {
|
|
|
3560
3577
|
source: rec.source,
|
|
3561
3578
|
title: rec.session.getName(),
|
|
3562
3579
|
model: rec.session.getCurrentModel()?.name,
|
|
3580
|
+
sandbox: rec.sandbox,
|
|
3563
3581
|
};
|
|
3564
3582
|
let dirtyPatch;
|
|
3565
3583
|
if (rec.worktree) {
|
|
@@ -3568,6 +3586,14 @@ const RELAY_COMMANDS = {
|
|
|
3568
3586
|
}
|
|
3569
3587
|
catch { /* best effort — omit dirty state */ }
|
|
3570
3588
|
}
|
|
3589
|
+
// Publish the source branch so a cross-node fork's COMMITTED work travels
|
|
3590
|
+
// via origin (the destination adopts `origin/<branch>`; see
|
|
3591
|
+
// resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
|
|
3592
|
+
// for a genuine cross-node fork — a same-node cross-agent fork adopts the
|
|
3593
|
+
// LOCAL branch and needs no push. Best-effort: a no-token/offline node just
|
|
3594
|
+
// falls back to the default base downstream.
|
|
3595
|
+
if (msg.crossNode === true)
|
|
3596
|
+
await pushForkSourceBranch(rec);
|
|
3571
3597
|
// Refresh the account model-auth vault so the destination node can pull
|
|
3572
3598
|
// this session's model credentials during import (fork credential-move,
|
|
3573
3599
|
// docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
|
|
@@ -3648,6 +3674,7 @@ const RELAY_COMMANDS = {
|
|
|
3648
3674
|
source: rec.source,
|
|
3649
3675
|
title: rec.session.getName(),
|
|
3650
3676
|
model: rec.session.getCurrentModel()?.name,
|
|
3677
|
+
sandbox: rec.sandbox,
|
|
3651
3678
|
};
|
|
3652
3679
|
// Carry uncommitted work: capture from the SOURCE worktree; standUpFork
|
|
3653
3680
|
// re-applies it into the fork's fresh worktree. Local git ops only.
|
|
@@ -5859,6 +5886,48 @@ async function applyRequestedModel(record, model) {
|
|
|
5859
5886
|
broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
|
|
5860
5887
|
}
|
|
5861
5888
|
}
|
|
5889
|
+
// Serialize clone + worktree work per repo directory. Two forks (or a fork and
|
|
5890
|
+
// a GitHub pickup) hitting the same shared clone concurrently race on
|
|
5891
|
+
// `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
|
|
5892
|
+
// see "already exists"/"already checked out" or, worse, `createWorktree`'s
|
|
5893
|
+
// adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
|
|
5894
|
+
// mutex removes the race without a filesystem lock.
|
|
5895
|
+
const repoWorktreeLocks = new Map();
|
|
5896
|
+
async function withRepoLock(key, fn) {
|
|
5897
|
+
const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
|
|
5898
|
+
// Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
|
|
5899
|
+
// failing fork doesn't poison the next waiter's gate. Each caller still awaits
|
|
5900
|
+
// its own `run` and gets its own result/exception. Bounded by repo count.
|
|
5901
|
+
const gate = prev.then(() => { }, () => { });
|
|
5902
|
+
const run = gate.then(fn);
|
|
5903
|
+
repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
|
|
5904
|
+
return run;
|
|
5905
|
+
}
|
|
5906
|
+
/**
|
|
5907
|
+
* Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
|
|
5908
|
+
* the node, so a cross-node fork's committed work travels via origin (the
|
|
5909
|
+
* destination bases its adopted worktree on `origin/<branch>` — see
|
|
5910
|
+
* `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
|
|
5911
|
+
* means the destination falls back to the default base and the dirty patch.
|
|
5912
|
+
*/
|
|
5913
|
+
async function pushForkSourceBranch(rec) {
|
|
5914
|
+
const parts = repoSessionParts(rec);
|
|
5915
|
+
if (!parts)
|
|
5916
|
+
return;
|
|
5917
|
+
const { wt, parsed } = parts;
|
|
5918
|
+
try {
|
|
5919
|
+
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
5920
|
+
if (!token)
|
|
5921
|
+
return;
|
|
5922
|
+
const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
|
|
5923
|
+
await pushBranch(cfg, wt.path, wt.branch);
|
|
5924
|
+
rec.branchPushed = true;
|
|
5925
|
+
}
|
|
5926
|
+
catch {
|
|
5927
|
+
// offline / no rights / protected branch — committed work may not reach a
|
|
5928
|
+
// cross-node destination, but the fork still proceeds from the best base.
|
|
5929
|
+
}
|
|
5930
|
+
}
|
|
5862
5931
|
/**
|
|
5863
5932
|
* Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
|
|
5864
5933
|
* (optional) prerequisite detection, repo/worktree reconstruction, transcript
|
|
@@ -5869,8 +5938,10 @@ async function applyRequestedModel(record, model) {
|
|
|
5869
5938
|
*/
|
|
5870
5939
|
async function standUpFork(opts) {
|
|
5871
5940
|
const { bundle, targetRuntimeId } = opts;
|
|
5872
|
-
const targetRuntime = getRuntime(targetRuntimeId);
|
|
5873
5941
|
const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
|
|
5942
|
+
// Carry the source's sandbox tier so a sandboxed session forks into a
|
|
5943
|
+
// sandboxed one, rather than defaulting to this node's tier (fork.ts).
|
|
5944
|
+
const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
|
|
5874
5945
|
// Credential-move: if the chosen model's provider isn't logged in on this node,
|
|
5875
5946
|
// pull the account model-auth vault (a login done on another node carries over),
|
|
5876
5947
|
// then re-check. Best-effort — a local-only node just skips it.
|
|
@@ -5882,41 +5953,64 @@ async function standUpFork(opts) {
|
|
|
5882
5953
|
modelConfigured = await providerConfigured();
|
|
5883
5954
|
}
|
|
5884
5955
|
// Prerequisite detection. A missing AGENT is a hard blocker — stop before any
|
|
5885
|
-
// clone/worktree work. Skipped for a same-node local fork.
|
|
5956
|
+
// clone/worktree work. Skipped for a same-node local fork. Read the agent's
|
|
5957
|
+
// availability + display name from the runtime REGISTRY (which never throws)
|
|
5958
|
+
// rather than resolving the runtime up front: `getRuntime` throws for a
|
|
5959
|
+
// known-but-not-installed agent, which — called eagerly — surfaced a raw
|
|
5960
|
+
// "not available" string with an empty `missing[]` instead of this friendly
|
|
5961
|
+
// install checklist. An unknown id (no registry entry) is treated as
|
|
5962
|
+
// unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
|
|
5886
5963
|
const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
|
|
5887
|
-
const agentAvailable = agentInfo ? agentInfo.status === "available" :
|
|
5964
|
+
const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
|
|
5965
|
+
const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
|
|
5888
5966
|
const prereqInput = {
|
|
5889
|
-
agent: { id: targetRuntimeId, displayName:
|
|
5967
|
+
agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
|
|
5890
5968
|
...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
|
|
5891
5969
|
};
|
|
5892
5970
|
if (opts.detectPrereqs) {
|
|
5893
5971
|
const early = evaluateForkPrereqs(prereqInput);
|
|
5894
5972
|
if (blockingForkPrereqs(early).length > 0) {
|
|
5895
|
-
return { ok: false, error: `${
|
|
5973
|
+
return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
|
|
5896
5974
|
}
|
|
5897
5975
|
}
|
|
5976
|
+
// Safe now: the agent is available (or this is a same-node local fork whose
|
|
5977
|
+
// agent is self-evidently present). The per-session sandbox tier bakes into
|
|
5978
|
+
// the runtime's launch flags.
|
|
5979
|
+
const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
|
|
5898
5980
|
// Reconstruct repo + worktree when the source was repo-backed.
|
|
5899
5981
|
let workspace = fallback.workspace;
|
|
5900
5982
|
let cwd = fallback.cwd;
|
|
5901
5983
|
let repoReachable;
|
|
5902
5984
|
let worktree;
|
|
5985
|
+
let dirtyWarning;
|
|
5903
5986
|
const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
|
|
5904
5987
|
if (parsed) {
|
|
5905
5988
|
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
5906
5989
|
repoReachable = Boolean(token);
|
|
5907
5990
|
const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
|
|
5908
5991
|
const srcBranch = bundle.record.branch;
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5992
|
+
// Serialize clone-adjacent worktree ops on this repo so concurrent forks /
|
|
5993
|
+
// pickups don't race on `git worktree add` or clobber each other's trees.
|
|
5994
|
+
const wt = await withRepoLock(repoDir, async () => {
|
|
5995
|
+
if (opts.worktree === "fresh") {
|
|
5996
|
+
// Same-node fork: cut a NEW branch from the source's LOCAL branch (which
|
|
5997
|
+
// holds its latest, possibly-unpushed commits) or the repo default.
|
|
5998
|
+
const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
|
|
5999
|
+
return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
|
|
6000
|
+
}
|
|
6001
|
+
// Cross-node adopt: the source branch has no LOCAL ref here. Base the
|
|
6002
|
+
// adopted branch on the pushed `origin/<branch>` so committed work travels
|
|
6003
|
+
// (was: undefined → the destination's DEFAULT branch, silently dropping
|
|
6004
|
+
// every commit). Give the worktree DIR a unique suffix so a same-branch
|
|
6005
|
+
// adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
|
|
6006
|
+
// — another live session's tree.
|
|
6007
|
+
const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
|
|
6008
|
+
const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
|
|
6009
|
+
return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
|
|
6010
|
+
});
|
|
6011
|
+
const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6012
|
+
if (applied.warning)
|
|
6013
|
+
dirtyWarning = applied.warning;
|
|
5920
6014
|
workspace = repoDir;
|
|
5921
6015
|
cwd = wt.path;
|
|
5922
6016
|
worktree = wt;
|
|
@@ -5930,8 +6024,10 @@ async function standUpFork(opts) {
|
|
|
5930
6024
|
const forkRepoRoot = await gitRepoRoot(cwd);
|
|
5931
6025
|
if (forkRepoRoot) {
|
|
5932
6026
|
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);
|
|
6027
|
+
const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
|
|
6028
|
+
const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6029
|
+
if (applied.warning)
|
|
6030
|
+
dirtyWarning = applied.warning;
|
|
5935
6031
|
workspace = forkRepoRoot;
|
|
5936
6032
|
cwd = wt.path;
|
|
5937
6033
|
worktree = wt;
|
|
@@ -5941,8 +6037,8 @@ async function standUpFork(opts) {
|
|
|
5941
6037
|
// transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
|
|
5942
6038
|
const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
|
|
5943
6039
|
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 });
|
|
6040
|
+
? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
|
|
6041
|
+
: await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
|
|
5946
6042
|
// Mark the new session as a fork of its source, so the run card can show
|
|
5947
6043
|
// "Forked from …" and the lineage survives a reload (persisted below). Just
|
|
5948
6044
|
// the parent's session id — an identifier, not content, so it's safe to
|
|
@@ -5961,6 +6057,10 @@ async function standUpFork(opts) {
|
|
|
5961
6057
|
}
|
|
5962
6058
|
if (bundle.record.title && !record.session.getName())
|
|
5963
6059
|
record.session.setName(bundle.record.title);
|
|
6060
|
+
// Surface a non-fatal note when the source's uncommitted changes didn't apply
|
|
6061
|
+
// cleanly, so the fork isn't silently missing work-in-progress.
|
|
6062
|
+
if (dirtyWarning)
|
|
6063
|
+
broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
|
|
5964
6064
|
await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
|
|
5965
6065
|
persistSessionMetadata(record);
|
|
5966
6066
|
scheduleAdvertise();
|
|
@@ -7972,32 +8072,75 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
|
|
|
7972
8072
|
// races the runtime.select that switches the default agent, pin the pill to the
|
|
7973
8073
|
// *previous* runtime (the reported agent-switching bug). Mirror how session.new/
|
|
7974
8074
|
// session.open already refuse to touch `active` for remote clients: reuse a
|
|
7975
|
-
//
|
|
7976
|
-
//
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
8075
|
+
// non-active scratch session per runtime instead of spawning a fresh runtime
|
|
8076
|
+
// process on every picker read.
|
|
8077
|
+
//
|
|
8078
|
+
// Keyed by runtime id, not a single slot: switching agents (Claude → Codex →
|
|
8079
|
+
// Claude) used to evict and re-spawn the one scratch on every switch — the
|
|
8080
|
+
// "switching agent takes a long time before models appear" bug. A map keeps one
|
|
8081
|
+
// warm scratch per runtime so a switch back to an agent already viewed this
|
|
8082
|
+
// session answers from the live session with no re-spawn, and `prefetchModels`
|
|
8083
|
+
// can warm several ahead of the first pick.
|
|
8084
|
+
const modelQueryScratch = new Map();
|
|
8085
|
+
const modelQueryScratchPending = new Map();
|
|
8086
|
+
async function sessionForModelQuery(runtimeId) {
|
|
8087
|
+
const wanted = resolveRuntimeId(runtimeId);
|
|
8088
|
+
// A live active session answers for itself — but only when it IS the runtime
|
|
8089
|
+
// being queried, so a prefetch/draft read for a *different* agent doesn't get
|
|
8090
|
+
// the active session's (wrong-runtime) model list.
|
|
8091
|
+
if (active && active.runtimeId === wanted)
|
|
7981
8092
|
return active;
|
|
7982
|
-
const
|
|
7983
|
-
if (
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
//
|
|
7991
|
-
//
|
|
7992
|
-
//
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
.
|
|
7999
|
-
|
|
8000
|
-
return
|
|
8093
|
+
const cached = modelQueryScratch.get(wanted);
|
|
8094
|
+
if (cached && openSessions.has(cached.id) && cached.runtimeId === wanted && !sessionBusy(cached)) {
|
|
8095
|
+
touchSession(cached);
|
|
8096
|
+
return cached;
|
|
8097
|
+
}
|
|
8098
|
+
// De-dupe concurrent picker reads per runtime. Without this, a WS models.list
|
|
8099
|
+
// and an HTTP GET /api/models fired together on page load both miss the reuse
|
|
8100
|
+
// guard above (the scratch assignment only lands after createSession resolves
|
|
8101
|
+
// ~0.3s later) and each stand up a session, leaving two empty rows a fraction
|
|
8102
|
+
// of a second apart. Collapse concurrent builds onto one promise per runtime,
|
|
8103
|
+
// mirroring resumingSessions.
|
|
8104
|
+
const inflight = modelQueryScratchPending.get(wanted);
|
|
8105
|
+
if (inflight)
|
|
8106
|
+
return inflight;
|
|
8107
|
+
const build = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true, runtimeId: wanted })
|
|
8108
|
+
.then((rec) => { modelQueryScratch.set(wanted, rec); return rec; })
|
|
8109
|
+
.finally(() => { modelQueryScratchPending.delete(wanted); });
|
|
8110
|
+
modelQueryScratchPending.set(wanted, build);
|
|
8111
|
+
return build;
|
|
8112
|
+
}
|
|
8113
|
+
/**
|
|
8114
|
+
* Warm the model-query scratch for one or more runtimes in the background so the
|
|
8115
|
+
* first agent switch to any of them answers instantly instead of paying the
|
|
8116
|
+
* runtime spin-up on the critical path. Fired when the agent picker opens (see
|
|
8117
|
+
* the `models.prefetch` command). Best-effort and de-duped: a runtime already
|
|
8118
|
+
* warm (or being warmed) is a no-op, and a spin-up failure is swallowed — the
|
|
8119
|
+
* normal models.list path will surface any real error when the user picks it.
|
|
8120
|
+
*/
|
|
8121
|
+
function prefetchModels(runtimeIds) {
|
|
8122
|
+
const wanted = [];
|
|
8123
|
+
for (const id of runtimeIds) {
|
|
8124
|
+
let resolved;
|
|
8125
|
+
try {
|
|
8126
|
+
resolved = resolveRuntimeId(id);
|
|
8127
|
+
}
|
|
8128
|
+
catch {
|
|
8129
|
+
continue; // unknown/uninstalled agent — nothing to warm
|
|
8130
|
+
}
|
|
8131
|
+
if (wanted.includes(resolved))
|
|
8132
|
+
continue;
|
|
8133
|
+
const cached = modelQueryScratch.get(resolved);
|
|
8134
|
+
if (cached && openSessions.has(cached.id) && !sessionBusy(cached))
|
|
8135
|
+
continue;
|
|
8136
|
+
if (modelQueryScratchPending.has(resolved))
|
|
8137
|
+
continue;
|
|
8138
|
+
wanted.push(resolved);
|
|
8139
|
+
}
|
|
8140
|
+
// Warm serially, not in a burst: spinning up every agent subprocess at once
|
|
8141
|
+
// would spike a small node's memory/CPU right as the user is interacting. Each
|
|
8142
|
+
// build is cached (and de-duped) so this cost is paid at most once per runtime.
|
|
8143
|
+
void wanted.reduce((chain, id) => chain.then(() => sessionForModelQuery(id).then(() => undefined, () => undefined)), Promise.resolve());
|
|
8001
8144
|
}
|
|
8002
8145
|
async function createRepoSession(parsed, opts = {}) {
|
|
8003
8146
|
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
@@ -8888,10 +9031,13 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
8888
9031
|
try {
|
|
8889
9032
|
const requestedSessionId = typeof req.query.sessionId === "string" && req.query.sessionId ? req.query.sessionId : undefined;
|
|
8890
9033
|
const requestedPath = typeof req.query.path === "string" ? req.query.path : undefined;
|
|
9034
|
+
const wantedRuntimeId = typeof req.query.runtimeId === "string" && req.query.runtimeId ? req.query.runtimeId : undefined;
|
|
8891
9035
|
let record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, requestedPath) : active;
|
|
8892
9036
|
if (requestedSessionId && !record)
|
|
8893
9037
|
return res.status(404).json({ error: "Session not found" });
|
|
8894
|
-
record
|
|
9038
|
+
if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
|
|
9039
|
+
record = undefined;
|
|
9040
|
+
record ??= await sessionForModelQuery(wantedRuntimeId);
|
|
8895
9041
|
const session = record.session;
|
|
8896
9042
|
const current = session.getCurrentModel();
|
|
8897
9043
|
const models = await publicModelsList(session, current);
|
|
@@ -8902,6 +9048,17 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
8902
9048
|
next(error);
|
|
8903
9049
|
}
|
|
8904
9050
|
});
|
|
9051
|
+
// Warm the per-runtime model-query scratch ahead of the first agent switch (see
|
|
9052
|
+
// prefetchModels). Fire-and-forget: returns immediately while the runtimes spin
|
|
9053
|
+
// up in the background, so the picker never blocks on it.
|
|
9054
|
+
app.post("/api/models/prefetch", (req, res) => {
|
|
9055
|
+
const ids = Array.isArray(req.body?.runtimeIds)
|
|
9056
|
+
? req.body.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
|
|
9057
|
+
: [];
|
|
9058
|
+
if (ids.length)
|
|
9059
|
+
prefetchModels(ids);
|
|
9060
|
+
res.json({ ok: true });
|
|
9061
|
+
});
|
|
8905
9062
|
app.post("/api/models/select", async (req, res, next) => {
|
|
8906
9063
|
try {
|
|
8907
9064
|
const requestedSessionId = typeof req.body?.sessionId === "string" && req.body.sessionId ? req.body.sessionId : undefined;
|
|
@@ -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