@bivy/bivy 0.16.8-staging.1 → 0.16.8-staging.3

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.
@@ -8,7 +8,7 @@
8
8
  // server injects its own session lookup, runtime access, event emit, and the fork
9
9
  // helpers as composition deps, so this stays free of daemon state and unit-testable.
10
10
  import { buildForkBundle } from "../session/fork.js";
11
- import { captureDirtyPatch, captureWorkspaceDirtyPatch } from "../session/fork-dirty.js";
11
+ import { captureDirtyPatch, captureWorkspaceDirtyPatch, captureWorkspaceSnapshot } from "../session/fork-dirty.js";
12
12
  export function createForkCommands(deps) {
13
13
  return {
14
14
  async "session.fork.retire-source"(msg) {
@@ -48,9 +48,15 @@ export function createForkCommands(deps) {
48
48
  // A git workspace must be captured successfully, including sessions that
49
49
  // were started in an unmanaged checkout rather than a Bivy worktree.
50
50
  // Treating a git/read failure as "clean" could retire the only WIP copy.
51
+ const sourceCwd = rec.session.cwd || rec.workspace;
51
52
  const dirtyPatch = rec.worktree
52
53
  ? captureDirtyPatch(rec.worktree.path)
53
- : captureWorkspaceDirtyPatch(rec.session.cwd || rec.workspace);
54
+ : captureWorkspaceDirtyPatch(sourceCwd);
55
+ // Plain workspaces have no remote clone source, so carry their files in
56
+ // the E2E bundle instead of silently substituting the destination cwd.
57
+ const workspaceSnapshot = !forkRecord.repoSlug && (msg.crossNode === true || dirtyPatch === undefined)
58
+ ? captureWorkspaceSnapshot(sourceCwd, { maxBytes: deps.forkWorkspaceMaxBytes() })
59
+ : undefined;
54
60
  // Publish the source branch so a cross-node fork's COMMITTED work travels
55
61
  // via origin (the destination adopts `origin/<branch>`; see
56
62
  // resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
@@ -66,7 +72,7 @@ export function createForkCommands(deps) {
66
72
  // When the client has already picked a target agent, pass it so the
67
73
  // bundle omits the native payload for a cross-runtime fork (it could
68
74
  // never be replayed there — see buildForkBundle). Unset => keep it.
69
- const bundle = buildForkBundle({ runtime: deps.getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: deps.agentFrom(msg), liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
75
+ const bundle = buildForkBundle({ runtime: deps.getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, workspaceSnapshot, targetRuntimeId: deps.agentFrom(msg), liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
70
76
  deps.sendEvent({ type: "session.fork.bundle", requestId, bundle });
71
77
  }
72
78
  catch (error) {
@@ -136,11 +142,15 @@ export function createForkCommands(deps) {
136
142
  // re-applies it into the fork's fresh worktree. Local git ops only.
137
143
  // Include unmanaged git workspaces too. standUpFork will cut an isolated
138
144
  // worktree for them, so their uncommitted state must ride with it.
145
+ const sourceCwd = rec.session.cwd || rec.workspace;
139
146
  const dirtyPatch = rec.worktree
140
147
  ? captureDirtyPatch(rec.worktree.path)
141
- : captureWorkspaceDirtyPatch(rec.session.cwd || rec.workspace);
148
+ : captureWorkspaceDirtyPatch(sourceCwd);
149
+ const workspaceSnapshot = dirtyPatch === undefined
150
+ ? captureWorkspaceSnapshot(sourceCwd, { maxBytes: deps.forkWorkspaceMaxBytes() })
151
+ : undefined;
142
152
  // Same runtime → the bundle carries the native payload → full fidelity.
143
- const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
153
+ const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, workspaceSnapshot, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
144
154
  // Cut a fresh fork branch (the source still holds its own); skip prereq
145
155
  // detection (same node + same runtime ⇒ agent and repo are present).
146
156
  const outcome = await deps.standUpFork({
@@ -74,7 +74,7 @@ export function validateNodeConfig(value) {
74
74
  const node = section(root, "node", ["workspace", "port", "maxConcurrentAutomations", "capabilities"], errors);
75
75
  const defaults = section(root, "defaults", ["agent", "model", "sandbox", "approval"], errors);
76
76
  const safety = section(root, "safety", ["maxSandbox", "approvalFloor"], errors);
77
- const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "wedgedTurnMinutes"], errors);
77
+ const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
78
78
  const github = section(root, "github", ["issuePrompt"], errors);
79
79
  const automation = section(root, "automation", ["checks", "checkTimeoutMinutes"], errors);
80
80
  const agentsRaw = section(root, "agents", Object.keys(record(root.agents) ?? {}), errors);
@@ -133,6 +133,7 @@ export function validateNodeConfig(value) {
133
133
  if (resume !== undefined && resume !== "auto" && resume !== "manual")
134
134
  errors.push("sessions.resume must be auto or manual");
135
135
  const autoAttachToolImages = optionalBoolean(sessions.autoAttachToolImages, "sessions.autoAttachToolImages", errors);
136
+ const forkWorkspaceMaxBytes = optionalInteger(sessions.forkWorkspaceMaxBytes, "sessions.forkWorkspaceMaxBytes", errors, 1_048_576, 1_073_741_824);
136
137
  // Upper bound is the wall-clock turn cap (60 min): a wedged window at/above it
137
138
  // would never fire before the cap. 0 disables the band.
138
139
  const wedgedTurnMinutes = optionalInteger(sessions.wedgedTurnMinutes, "sessions.wedgedTurnMinutes", errors, 0, 60);
@@ -215,7 +216,7 @@ export function validateNodeConfig(value) {
215
216
  ...(Object.keys(node).length ? { node: { workspace, port, maxConcurrentAutomations, capabilities } } : {}),
216
217
  ...(Object.keys(defaults).length ? { defaults: { agent, model, sandbox, approval } } : {}),
217
218
  ...(Object.keys(safety).length ? { safety: { maxSandbox, approvalFloor } } : {}),
218
- ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, wedgedTurnMinutes } } : {}),
219
+ ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
219
220
  ...(Object.keys(github).length ? { github: { issuePrompt } } : {}),
220
221
  ...(Object.keys(automation).length ? { automation: { checks, checkTimeoutMinutes } } : {}),
221
222
  ...(Object.keys(agents).length ? { agents } : {}),
@@ -271,6 +272,7 @@ export function configToLegacySettings(config) {
271
272
  ...(config.sessions?.standbyNodeId ? { syncStandbyNodeId: config.sessions.standbyNodeId } : {}),
272
273
  ...(config.sessions?.resume ? { sessionResumeMode: config.sessions.resume } : {}),
273
274
  ...(config.sessions?.autoAttachToolImages !== undefined ? { autoAttachToolImages: config.sessions.autoAttachToolImages } : {}),
275
+ ...(config.sessions?.forkWorkspaceMaxBytes !== undefined ? { forkWorkspaceMaxBytes: config.sessions.forkWorkspaceMaxBytes } : {}),
274
276
  };
275
277
  }
276
278
  export function mergeLegacyIntoNodeConfig(cli, settings) {
@@ -333,6 +335,7 @@ export function mergeLegacyIntoNodeConfig(cli, settings) {
333
335
  standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId : undefined,
334
336
  resume: settings.sessionResumeMode === "manual" ? "manual" : "auto",
335
337
  autoAttachToolImages: settings.autoAttachToolImages === true,
338
+ forkWorkspaceMaxBytes: Number.isInteger(settings.forkWorkspaceMaxBytes) ? Number(settings.forkWorkspaceMaxBytes) : undefined,
336
339
  },
337
340
  github: { issuePrompt: typeof settings.githubIssuePrompt === "string" ? settings.githubIssuePrompt : undefined },
338
341
  automation: {
@@ -358,7 +361,7 @@ export function setConfigValue(config, dotted, value) {
358
361
  "node.workspace", "node.port", "node.maxConcurrentAutomations", "node.capabilities",
359
362
  "defaults.agent", "defaults.model", "defaults.sandbox", "defaults.approval",
360
363
  "safety.maxSandbox", "safety.approvalFloor",
361
- "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.wedgedTurnMinutes",
364
+ "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
362
365
  "github.issuePrompt", "automation.checks", "automation.checkTimeoutMinutes",
363
366
  ]);
364
367
  if (!allowed.has(dotted) && !/^agents\.[a-z][a-z0-9-]{1,47}$/.test(dotted) && !/^environment\.[A-Z][A-Z0-9_]+$/.test(dotted))
package/dist/server.js CHANGED
@@ -1345,6 +1345,7 @@ function writeSettings(settings) {
1345
1345
  ...("syncStandbyNodeId" in settings ? { standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId || undefined : undefined } : {}),
1346
1346
  ...(settings.sessionResumeMode === "auto" || settings.sessionResumeMode === "manual" ? { resume: settings.sessionResumeMode } : {}),
1347
1347
  ...(typeof settings.autoAttachToolImages === "boolean" ? { autoAttachToolImages: settings.autoAttachToolImages } : {}),
1348
+ ...(Number.isInteger(settings.forkWorkspaceMaxBytes) ? { forkWorkspaceMaxBytes: Number(settings.forkWorkspaceMaxBytes) } : {}),
1348
1349
  };
1349
1350
  next.github = {
1350
1351
  ...next.github,
@@ -1463,6 +1464,7 @@ function nodeSettingsSnapshot() {
1463
1464
  })(),
1464
1465
  sessionResumeMode: nodeSessionResumeMode(),
1465
1466
  autoAttachToolImages: readSettings().autoAttachToolImages === true,
1467
+ forkWorkspaceMaxBytes: Number.isInteger(readSettings().forkWorkspaceMaxBytes) ? Number(readSettings().forkWorkspaceMaxBytes) : 50 * 1024 * 1024,
1466
1468
  };
1467
1469
  }
1468
1470
  async function applyNodeSettings(patch) {
@@ -1962,6 +1964,7 @@ const RELAY_COMMANDS = {
1962
1964
  modelFrom,
1963
1965
  pushModelAuthToControlPlane,
1964
1966
  pushForkSourceBranch: (rec) => branchPublish.pushForkSourceBranch(rec),
1967
+ forkWorkspaceMaxBytes: () => nodeSettingsSnapshot().forkWorkspaceMaxBytes,
1965
1968
  standUpFork: (opts) => forkStandUp.standUpFork(opts),
1966
1969
  retireSource: (input) => forkRetire.retireSource(input),
1967
1970
  }),
@@ -14,12 +14,27 @@ import path from "node:path";
14
14
  * the marker as success would lose work-in-progress.
15
15
  */
16
16
  const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB of patch text
17
+ const DEFAULT_WORKSPACE_MAX_BYTES = 50 * 1024 * 1024; // 50 MiB of files
18
+ function workspaceMaxBytes(configured) {
19
+ return configured !== undefined ? configured : DEFAULT_WORKSPACE_MAX_BYTES;
20
+ }
17
21
  /** Find the containing git checkout without throwing for ordinary non-git
18
22
  * workspaces. A discovered checkout is captured fail-closed by the caller. */
19
23
  export function captureWorkspaceDirtyPatch(cwd, opts = {}) {
20
24
  const probe = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 });
21
- if (probe.status !== 0)
22
- return undefined;
25
+ if (probe.error)
26
+ throw probe.error;
27
+ if (probe.signal === "SIGTERM")
28
+ throw new Error("Timed out while inspecting the session workspace for uncommitted changes.");
29
+ if (probe.status !== 0) {
30
+ // A plain directory is a supported workspace. Do not, however, turn a
31
+ // broken/permission-denied git invocation into an apparent clean tree: for
32
+ // a MOVE that could discard the only copy of the user's edits.
33
+ const detail = String(probe.stderr || "").toLowerCase();
34
+ if (/not a git repository|outside a git work tree/.test(detail))
35
+ return undefined;
36
+ throw new Error(`Could not inspect the session workspace with git${probe.stderr ? `: ${String(probe.stderr).trim()}` : ""}`);
37
+ }
23
38
  const root = String(probe.stdout || "").trim();
24
39
  return root ? captureDirtyPatch(root, opts) : undefined;
25
40
  }
@@ -58,6 +73,60 @@ export function captureDirtyPatch(repoDir, opts = {}) {
58
73
  }
59
74
  return { patch, untracked };
60
75
  }
76
+ /** Capture a non-git workspace for a fork. Symlinks are recorded, never followed,
77
+ * and .git is excluded as it is a repository boundary rather than workspace data. */
78
+ export function captureWorkspaceSnapshot(root, opts = {}) {
79
+ const maxBytes = workspaceMaxBytes(opts.maxBytes);
80
+ const entries = [];
81
+ let byteLength = 0;
82
+ const walk = (dir, prefix) => {
83
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
84
+ if (!prefix && (entry.name === ".git" || entry.name === ".bivy"))
85
+ continue;
86
+ const rel = prefix ? path.join(prefix, entry.name) : entry.name;
87
+ const abs = path.join(dir, entry.name);
88
+ const stat = fs.lstatSync(abs);
89
+ if (stat.isDirectory()) {
90
+ walk(abs, rel);
91
+ continue;
92
+ }
93
+ if (stat.isSymbolicLink()) {
94
+ const target = fs.readlinkSync(abs);
95
+ byteLength += Buffer.byteLength(target);
96
+ entries.push({ path: rel, kind: "symlink", target });
97
+ }
98
+ else if (stat.isFile()) {
99
+ byteLength += stat.size;
100
+ entries.push({ path: rel, kind: "file", data: fs.readFileSync(abs).toString("base64"), mode: stat.mode & 0o777 });
101
+ }
102
+ }
103
+ };
104
+ walk(path.resolve(root), "");
105
+ if (byteLength > maxBytes)
106
+ return { entries: [], byteLength, maxBytes, oversized: true };
107
+ return { entries, byteLength, maxBytes };
108
+ }
109
+ /** Materialise a captured workspace into a fresh destination directory. */
110
+ export function applyWorkspaceSnapshot(root, snapshot) {
111
+ if (!snapshot)
112
+ return;
113
+ if (snapshot.oversized)
114
+ throw new Error(`The workspace snapshot is ${snapshot.byteLength} bytes, above the ${snapshot.maxBytes}-byte transfer limit.`);
115
+ const destination = path.resolve(root);
116
+ for (const entry of snapshot.entries) {
117
+ const target = path.resolve(destination, entry.path);
118
+ if (target !== destination && !target.startsWith(`${destination}${path.sep}`))
119
+ throw new Error("Workspace snapshot contains an invalid path.");
120
+ fs.mkdirSync(path.dirname(target), { recursive: true });
121
+ if (entry.kind === "symlink")
122
+ fs.symlinkSync(entry.target ?? "", target);
123
+ else {
124
+ fs.writeFileSync(target, Buffer.from(entry.data ?? "", "base64"));
125
+ if (entry.mode !== undefined)
126
+ fs.chmodSync(target, entry.mode);
127
+ }
128
+ }
129
+ }
61
130
  /**
62
131
  * Re-apply a captured patch onto a fresh checkout at `repoDir`. An oversized
63
132
  * marker produces a warning as a final defence; normal fork stand-up rejects it
@@ -16,9 +16,12 @@
16
16
  // Generic over the record type R (server passes SessionRecord) so the outcome it
17
17
  // returns is the caller's own record type, not a narrowed copy.
18
18
  import { randomBytes } from "node:crypto";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
19
21
  import { normalizeSandboxTier } from "../harness/sandbox.js";
20
22
  import { parseRepo } from "../repo-workspace.js";
21
23
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "../session/fork-prereqs.js";
24
+ import { applyWorkspaceSnapshot } from "./fork-dirty.js";
22
25
  /**
23
26
  * A one-line, user-facing disclosure of the source's in-flight state, or
24
27
  * undefined when there is nothing to disclose. Pure so it is unit-testable.
@@ -57,6 +60,13 @@ export function createForkStandUp(deps) {
57
60
  missing: [],
58
61
  };
59
62
  }
63
+ if (bundle.workspaceSnapshot?.oversized) {
64
+ return {
65
+ ok: false,
66
+ error: `The source workspace is too large to transfer safely (${bundle.workspaceSnapshot.byteLength} bytes; limit ${bundle.workspaceSnapshot.maxBytes}). Reduce it and retry.`,
67
+ missing: [],
68
+ };
69
+ }
60
70
  // Carry the source's sandbox tier so a sandboxed session forks into a
61
71
  // sandboxed one, rather than defaulting to this node's tier (fork.ts).
62
72
  const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
@@ -159,12 +169,26 @@ export function createForkStandUp(deps) {
159
169
  worktree = wt;
160
170
  }
161
171
  else {
172
+ // Non-repo-backed source. Materialise the complete workspace into an
173
+ // isolated directory on the destination; this also prevents same-node
174
+ // plain-directory forks from running two agents in one cwd.
175
+ if (bundle.workspaceSnapshot) {
176
+ const root = path.resolve(deps.defaultWorkspace);
177
+ fs.mkdirSync(root, { recursive: true });
178
+ const isolated = fs.mkdtempSync(path.join(root, ".bivy-fork-"));
179
+ applyWorkspaceSnapshot(isolated, bundle.workspaceSnapshot);
180
+ workspace = isolated;
181
+ cwd = isolated;
182
+ }
162
183
  // Non-repo-backed source. The fork would otherwise reuse the PARENT's cwd,
163
184
  // putting two sessions in one working tree — so when that cwd is itself a git
164
185
  // checkout (a local repo without a GitHub origin), cut the fork its own
165
186
  // worktree on a fresh branch. Best-effort: a non-git workspace has no tree to
166
187
  // isolate, so the fork keeps the fallback cwd (no git collisions possible).
167
- const forkRepoRoot = await deps.gitRepoRoot(cwd);
188
+ // A snapshot is authoritative for a machine-local source. Do not inspect
189
+ // the destination fallback cwd and accidentally turn it into a worktree
190
+ // fork of an unrelated repository.
191
+ const forkRepoRoot = bundle.workspaceSnapshot ? undefined : await deps.gitRepoRoot(cwd);
168
192
  if (forkRepoRoot) {
169
193
  const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
170
194
  const wt = await deps.withRepoLock(forkRepoRoot, () => deps.createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
@@ -33,7 +33,14 @@ export function buildForkBundle(opts) {
33
33
  // Only worth capturing when the target is the same runtime (or not yet known).
34
34
  const nativeCouldReplay = !opts.targetRuntimeId || opts.targetRuntimeId === runtime.id;
35
35
  let native;
36
- if (sessionFile && nativeCouldReplay && runtime.capabilities.forkTransport && runtime.exportForFork) {
36
+ // A native export reads the runtime's persisted store, which may lag the
37
+ // live session while a turn is streaming. Never prefer that stale snapshot
38
+ // for an in-flight fork: the normalized live transcript below is the only
39
+ // representation that includes the current turn. This is especially
40
+ // important for same-runtime forks, where a successful native import would
41
+ // otherwise prevent the portable fallback from being used.
42
+ const nativeSafe = !opts.state?.working;
43
+ if (sessionFile && nativeSafe && nativeCouldReplay && runtime.capabilities.forkTransport && runtime.exportForFork) {
37
44
  try {
38
45
  native = runtime.exportForFork(sessionFile);
39
46
  }
@@ -42,7 +49,7 @@ export function buildForkBundle(opts) {
42
49
  // transcript below still supports replay or a seeded continuation.
43
50
  }
44
51
  }
45
- return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}), ...(opts.state ? { state: opts.state } : {}) };
52
+ return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}), ...(opts.workspaceSnapshot ? { workspaceSnapshot: opts.workspaceSnapshot } : {}), ...(opts.state ? { state: opts.state } : {}) };
46
53
  }
47
54
  /**
48
55
  * Decide the best fidelity a fork of `bundle` into `targetRuntime` can achieve:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.8-staging.1",
3
+ "version": "0.16.8-staging.3",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",