@bivy/bivy 0.16.2 → 0.16.3-staging.2

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 } from "../session/fork-dirty.js";
11
+ import { captureDirtyPatch, captureWorkspaceDirtyPatch } from "../session/fork-dirty.js";
12
12
  export function createForkCommands(deps) {
13
13
  return {
14
14
  async "session.fork.retire-source"(msg) {
@@ -45,13 +45,12 @@ export function createForkCommands(deps) {
45
45
  }
46
46
  try {
47
47
  const forkRecord = deps.forkRecordFor(rec);
48
- let dirtyPatch;
49
- if (rec.worktree) {
50
- try {
51
- dirtyPatch = captureDirtyPatch(rec.worktree.path);
52
- }
53
- catch { /* best effort — omit dirty state */ }
54
- }
48
+ // A git workspace must be captured successfully, including sessions that
49
+ // were started in an unmanaged checkout rather than a Bivy worktree.
50
+ // Treating a git/read failure as "clean" could retire the only WIP copy.
51
+ const dirtyPatch = rec.worktree
52
+ ? captureDirtyPatch(rec.worktree.path)
53
+ : captureWorkspaceDirtyPatch(rec.session.cwd || rec.workspace);
55
54
  // Publish the source branch so a cross-node fork's COMMITTED work travels
56
55
  // via origin (the destination adopts `origin/<branch>`; see
57
56
  // resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
@@ -135,13 +134,11 @@ export function createForkCommands(deps) {
135
134
  const forkRecord = deps.forkRecordFor(rec);
136
135
  // Carry uncommitted work: capture from the SOURCE worktree; standUpFork
137
136
  // re-applies it into the fork's fresh worktree. Local git ops only.
138
- let dirtyPatch;
139
- if (rec.worktree) {
140
- try {
141
- dirtyPatch = captureDirtyPatch(rec.worktree.path);
142
- }
143
- catch { /* best effort */ }
144
- }
137
+ // Include unmanaged git workspaces too. standUpFork will cut an isolated
138
+ // worktree for them, so their uncommitted state must ride with it.
139
+ const dirtyPatch = rec.worktree
140
+ ? captureDirtyPatch(rec.worktree.path)
141
+ : captureWorkspaceDirtyPatch(rec.session.cwd || rec.workspace);
145
142
  // Same runtime → the bundle carries the native payload → full fidelity.
146
143
  const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
147
144
  // Cut a fresh fork branch (the source still holds its own); skip prereq
@@ -8,12 +8,21 @@ import path from "node:path";
8
8
  * checks it out); this carries the in-flight
9
9
  * edits on top so a fork never silently drops work-in-progress.
10
10
  *
11
- * The patch is size-capped. When the working tree is larger
12
- * than the cap (big or binary churn), we DON'T inline it — `capture` returns
13
- * `pushedInstead: true` and the caller commits & pushes the branch so the
14
- * destination reproduces from the pushed commit instead.
11
+ * The patch is size-capped. When the working tree is larger than the cap, the
12
+ * capture carries an explicit oversized marker. Fork stand-up rejects that
13
+ * bundle: pushing a branch cannot carry uncommitted files, and silently treating
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
+ /** Find the containing git checkout without throwing for ordinary non-git
18
+ * workspaces. A discovered checkout is captured fail-closed by the caller. */
19
+ export function captureWorkspaceDirtyPatch(cwd, opts = {}) {
20
+ const probe = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 });
21
+ if (probe.status !== 0)
22
+ return undefined;
23
+ const root = String(probe.stdout || "").trim();
24
+ return root ? captureDirtyPatch(root, opts) : undefined;
25
+ }
17
26
  function git(repoDir, args) {
18
27
  try {
19
28
  return execFileSync("git", ["-C", repoDir, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
@@ -43,15 +52,17 @@ export function captureDirtyPatch(repoDir, opts = {}) {
43
52
  // Each untracked file becomes a "new file" patch via --no-index against /dev/null.
44
53
  const untrackedPatches = untracked.map((rel) => git(repoDir, ["diff", "--no-index", "--binary", "--", "/dev/null", rel]));
45
54
  const patch = [tracked, ...untrackedPatches].filter(Boolean).join("");
46
- if (Buffer.byteLength(patch, "utf8") > maxBytes) {
47
- return { patch: "", untracked: [], pushedInstead: true };
55
+ const byteLength = Buffer.byteLength(patch, "utf8");
56
+ if (byteLength > maxBytes) {
57
+ return { patch: "", untracked, pushedInstead: true, byteLength, maxBytes };
48
58
  }
49
59
  return { patch, untracked };
50
60
  }
51
61
  /**
52
- * Re-apply a captured patch onto a fresh checkout at `repoDir`. A no-op when the
53
- * source pushed the branch instead (`pushedInstead`) or the working tree was
54
- * clean (empty patch). Uses `git apply` so both tracked hunks and untracked
62
+ * Re-apply a captured patch onto a fresh checkout at `repoDir`. An oversized
63
+ * marker produces a warning as a final defence; normal fork stand-up rejects it
64
+ * before reaching this function. A clean patch is a no-op. Uses `git apply` so
65
+ * both tracked hunks and untracked
55
66
  * new-file hunks (produced via `--no-index`) land correctly.
56
67
  *
57
68
  * NEVER throws: a fork's uncommitted changes are best-effort, and the source's
@@ -64,7 +75,13 @@ export function captureDirtyPatch(repoDir, opts = {}) {
64
75
  * succeeds, minus the un-appliable working-tree edits.
65
76
  */
66
77
  export function applyDirtyPatch(repoDir, dirty) {
67
- if (!dirty || dirty.pushedInstead || !dirty.patch.trim())
78
+ if (dirty?.pushedInstead) {
79
+ return {
80
+ applied: false,
81
+ warning: "The source working tree exceeded the fork transfer limit, so its uncommitted changes were not applied. The fork was stopped to prevent data loss.",
82
+ };
83
+ }
84
+ if (!dirty || !dirty.patch.trim())
68
85
  return { applied: false };
69
86
  const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
70
87
  fs.writeFileSync(tmp, dirty.patch);
@@ -43,6 +43,20 @@ export function createForkStandUp(deps) {
43
43
  async function standUpFork(opts) {
44
44
  const { bundle, targetRuntimeId } = opts;
45
45
  const fallback = opts.fallback ?? { workspace: deps.defaultWorkspace, cwd: deps.defaultWorkspace };
46
+ // An oversized dirty marker means no WIP bytes are present in the bundle.
47
+ // Never continue and pretend the pushed branch preserved them: git push only
48
+ // transports commits. The source remains untouched, so the user can commit,
49
+ // reduce the change set, or raise the configured transfer limit and retry.
50
+ if (bundle.dirtyPatch?.pushedInstead) {
51
+ const size = bundle.dirtyPatch.byteLength;
52
+ const limit = bundle.dirtyPatch.maxBytes;
53
+ const detail = size && limit ? ` (${Math.ceil(size / 1024)} KiB; limit ${Math.ceil(limit / 1024)} KiB)` : "";
54
+ return {
55
+ ok: false,
56
+ error: `The source has too many uncommitted changes to transfer safely${detail}. Commit them or reduce the working-tree changes, then retry; the source was not modified.`,
57
+ missing: [],
58
+ };
59
+ }
46
60
  // Carry the source's sandbox tier so a sandboxed session forks into a
47
61
  // sandboxed one, rather than defaulting to this node's tier (fork.ts).
48
62
  const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
@@ -131,6 +145,13 @@ export function createForkStandUp(deps) {
131
145
  return deps.createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
132
146
  });
133
147
  const applied = deps.applyDirtyPatch(wt.path, bundle.dirtyPatch);
148
+ if (bundle.dirtyPatch?.patch.trim() && applied.applied !== true) {
149
+ return {
150
+ ok: false,
151
+ error: applied.warning ?? "The source's uncommitted changes could not be applied safely. The source was not modified; retry after committing or reducing the changes.",
152
+ missing: [],
153
+ };
154
+ }
134
155
  if (applied.warning)
135
156
  dirtyWarning = applied.warning;
136
157
  workspace = repoDir;
@@ -148,6 +169,13 @@ export function createForkStandUp(deps) {
148
169
  const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
149
170
  const wt = await deps.withRepoLock(forkRepoRoot, () => deps.createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
150
171
  const applied = deps.applyDirtyPatch(wt.path, bundle.dirtyPatch);
172
+ if (bundle.dirtyPatch?.patch.trim() && applied.applied !== true) {
173
+ return {
174
+ ok: false,
175
+ error: applied.warning ?? "The source's uncommitted changes could not be applied safely. The source was not modified; retry after committing or reducing the changes.",
176
+ missing: [],
177
+ };
178
+ }
151
179
  if (applied.warning)
152
180
  dirtyWarning = applied.warning;
153
181
  workspace = forkRepoRoot;
@@ -9,18 +9,19 @@ import { normalizeMessages, buildSeedPrompt, buildForkHistory, } from "./transcr
9
9
  */
10
10
  export function buildForkBundle(opts) {
11
11
  const { runtime, sessionFile, record } = opts;
12
- // Prefer the build-free readMessages fast path (pi/Claude); fall back to the
13
- // live session's transcript for runtimes without one (the generic CLI runtime),
14
- // so a fork *from* any agent still carries its real history.
12
+ // The live session is authoritative: a persisted native reader can lag the
13
+ // current turn (or return an empty-but-valid snapshot during a flush race).
14
+ // Prefer liveMessages whenever the caller has them, and use readMessages only
15
+ // when no live transcript was supplied. This avoids silently truncating a fork
16
+ // made while the source is open, while still supporting offline/native refs.
15
17
  let messages = opts.liveMessages;
16
- if (sessionFile && runtime.readMessages) {
18
+ if (messages === undefined && sessionFile && runtime.readMessages) {
17
19
  try {
18
- messages = runtime.readMessages(sessionFile) ?? messages;
20
+ messages = runtime.readMessages(sessionFile);
19
21
  }
20
22
  catch {
21
- // Runtime-native readers are best-effort. The live transcript is the
22
- // universal source fallback and keeps one broken adapter from blocking a
23
- // fork out to every other agent.
23
+ // Runtime-native readers are best-effort. An unavailable reader yields an
24
+ // empty portable transcript, which still degrades safely to a seed.
24
25
  }
25
26
  }
26
27
  const normalized = normalizeMessages(messages, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.2",
3
+ "version": "0.16.3-staging.2",
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.",