@bivy/bivy 0.16.19-staging.2 → 0.16.19-staging.4

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.
@@ -16,6 +16,8 @@
16
16
  * issues (and Slack) across many repos/nodes. Pure HTTP + claim/loop logic lives
17
17
  * here; the actual run is injected so the daemon keeps the agent wiring.
18
18
  */
19
+ import { randomUUID } from "node:crypto";
20
+ import { WorkResultOutbox } from "./work-result-outbox.js";
19
21
  import { RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
20
22
  /** True when every required tag is among this node's declared capabilities.
21
23
  * A node that fails this must never attempt to claim the item — the hard
@@ -73,12 +75,26 @@ export function resolveControlPlaneTaskConfig(relay, env = process.env, nodeName
73
75
  capabilities,
74
76
  };
75
77
  }
76
- async function cp(cfg, method, path) {
78
+ async function cp(cfg, method, path, claimToken, body, signal) {
79
+ const timeout = AbortSignal.timeout(10_000);
77
80
  return fetch(`${cfg.controlPlaneUrl}${path}`, {
78
81
  method,
79
- headers: { authorization: `Bearer ${cfg.enrollmentToken}` },
82
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
83
+ headers: { authorization: `Bearer ${cfg.enrollmentToken}`, 'content-type': 'application/json', ...(claimToken ? { 'x-bivy-work-claim': claimToken } : {}) },
84
+ body: body === undefined ? undefined : JSON.stringify(body),
80
85
  });
81
86
  }
87
+ async function sendWorkResult(cfg, result) {
88
+ try {
89
+ const res = await cp(cfg, 'POST', `/node/work/${encodeURIComponent(result.id)}/${result.action}`, result.claimToken);
90
+ if (res.ok)
91
+ return 'acknowledged';
92
+ if (res.status === 409 || res.status === 404)
93
+ return 'lost';
94
+ }
95
+ catch { /* Outbox retains the intent; retry delivery without agent work. */ }
96
+ return 'retry';
97
+ }
82
98
  async function transitionWork(cfg, id, action) {
83
99
  // Best-effort — a dropped transition never loses the run itself — but NOT
84
100
  // silent: a swallowed `complete`/`fail`/`needs-attention` leaves the control
@@ -101,10 +117,13 @@ export async function fetchPendingWork(cfg) {
101
117
  const data = (await res.json().catch(() => ({})));
102
118
  return Array.isArray(data.items) ? data.items : [];
103
119
  }
104
- /** Atomically claim an item. Returns true only if THIS node won the claim. */
120
+ /** Consume the authoritative claim snapshot, not the earlier queue listing. */
105
121
  export async function claimWork(cfg, id) {
106
- const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`);
107
- return res.ok;
122
+ const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`, randomUUID());
123
+ if (!res.ok)
124
+ return undefined;
125
+ const data = await res.json();
126
+ return data.item ?? { id }; // compatibility with older control planes
108
127
  }
109
128
  /** Renew ownership and retain the reason a renewal was rejected. Cancellation is
110
129
  * intentionally distinct from a generic lost lease so an active agent can be
@@ -131,11 +150,12 @@ export async function needsAttentionWork(cfg, id) {
131
150
  * Best-effort: a dropped report loses one evidence update, never the run
132
151
  * itself. It is not throwing, but the failure is logged (A4) so a persistently
133
152
  * failing evidence channel is visible in diagnostics instead of silent. */
134
- export async function reportEvidence(cfg, id, patch) {
153
+ export async function reportEvidence(cfg, id, patch, claimToken) {
135
154
  try {
136
155
  const res = await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
137
156
  method: "POST",
138
- headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
157
+ headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json", ...(claimToken ? { 'x-bivy-work-claim': claimToken } : {}) },
158
+ signal: AbortSignal.timeout(10_000),
139
159
  body: JSON.stringify(patch),
140
160
  });
141
161
  if (!res.ok) {
@@ -158,6 +178,8 @@ export class ControlPlaneTaskPoller {
158
178
  policy;
159
179
  sleep;
160
180
  leaseHeartbeatMs;
181
+ outbox;
182
+ flushingResults;
161
183
  constructor(cfg, runItem,
162
184
  /** Node's cap on concurrently-running queue sessions (0/undefined = unlimited).
163
185
  * Read fresh each tick so the Settings → Nodes value takes effect live. */
@@ -168,8 +190,11 @@ export class ControlPlaneTaskPoller {
168
190
  this.policy = options.policy;
169
191
  this.sleep = options.sleep ?? defaultSleep;
170
192
  this.leaseHeartbeatMs = options.leaseHeartbeatMs ?? 30_000;
193
+ this.outbox = new WorkResultOutbox(options.resultDirectory, `${cfg.controlPlaneUrl}:${cfg.enrollmentToken}`);
171
194
  }
172
195
  start() {
196
+ if (this.timer)
197
+ return;
173
198
  void this.tick();
174
199
  this.timer = setInterval(() => void this.tick(), this.cfg.pollMs);
175
200
  this.timer.unref?.();
@@ -187,10 +212,10 @@ export class ControlPlaneTaskPoller {
187
212
  }
188
213
  void this.tick();
189
214
  }
190
- /** Number of queue items currently running on this node. Lets an ephemeral
191
- * machine's self-teardown avoid exiting while it's mid-work. */
215
+ /** Include undelivered results so an ephemeral machine cannot tear down its
216
+ * only durable outbox while completion reconciliation is still pending. */
192
217
  inFlightCount() {
193
- return this.inFlight.size;
218
+ return new Set([...this.inFlight.keys(), ...this.outbox.list().map(result => result.id)]).size;
194
219
  }
195
220
  /**
196
221
  * Replace the routing labels this live poller serves.
@@ -212,8 +237,10 @@ export class ControlPlaneTaskPoller {
212
237
  stop() {
213
238
  if (this.timer)
214
239
  clearInterval(this.timer);
240
+ this.timer = undefined;
215
241
  }
216
242
  async tick() {
243
+ await this.flushResults();
217
244
  let items;
218
245
  try {
219
246
  items = await fetchPendingWork(this.cfg);
@@ -224,7 +251,7 @@ export class ControlPlaneTaskPoller {
224
251
  const max = this.maxConcurrent?.() ?? 0;
225
252
  const running = [];
226
253
  for (const item of items) {
227
- if (this.inFlight.has(item.id))
254
+ if (this.inFlight.has(item.id) || this.outbox.has(item.id))
228
255
  continue;
229
256
  // Hard block: never contend for an item requiring a capability this
230
257
  // node hasn't declared. It stays pending for a node that has it (or
@@ -268,12 +295,18 @@ export class ControlPlaneTaskPoller {
268
295
  // Claim first so only one node runs it; skip if another node won (no
269
296
  // claim → not ours → don't run or complete it). A heartbeat keeps the
270
297
  // finite lease alive; process death stops it and makes the item reclaimable.
271
- if (!(await claimWork(this.cfg, item.id)) || run.state !== "active")
298
+ const claimed = await claimWork(this.cfg, item.id);
299
+ if (!claimed || run.state !== 'active')
272
300
  return;
301
+ item = { ...item, ...claimed };
302
+ run.claimToken = claimed.claimToken;
303
+ this.setLeaseDeadline(run, claimed.leaseExpiresAt);
273
304
  run.heartbeat = setInterval(() => void this.checkLease(item.id, run), this.leaseHeartbeatMs);
274
305
  run.heartbeat.unref?.();
275
- const report = (patch) => reportEvidence(this.cfg, item.id, patch);
276
- await transitionWork(this.cfg, item.id, "running");
306
+ const report = (patch) => reportEvidence(this.cfg, item.id, patch, run.claimToken);
307
+ const started = await cp(this.cfg, 'POST', `/node/work/${encodeURIComponent(item.id)}/running`, run.claimToken);
308
+ if (!started.ok)
309
+ return; // Never execute after a rejected lifecycle transition.
277
310
  if (run.state !== "active")
278
311
  return;
279
312
  console.log(`[control-plane-tasks] running ${item.source} item ${item.id}: ${item.title}`);
@@ -286,14 +319,105 @@ export class ControlPlaneTaskPoller {
286
319
  return;
287
320
  await this.runWithPolicy(item, report, run);
288
321
  }
322
+ catch (error) {
323
+ console.warn(`[control-plane-tasks] work ${item.id} orchestration failed:`, error);
324
+ }
289
325
  finally {
290
326
  if (run.heartbeat)
291
327
  clearInterval(run.heartbeat);
328
+ if (run.deadline)
329
+ clearTimeout(run.deadline);
292
330
  // A stale completion must not remove a newer reservation for the same id.
293
331
  if (this.inFlight.get(item.id) === run)
294
332
  this.inFlight.delete(item.id);
295
333
  }
296
334
  }
335
+ setLeaseDeadline(run, expiry) {
336
+ if (!expiry && !run.claimToken)
337
+ return; // legacy servers do not advertise a lease
338
+ const remaining = expiry ? Date.parse(expiry) - Date.now() : 0;
339
+ if (run.deadline)
340
+ clearTimeout(run.deadline);
341
+ const lose = () => {
342
+ if (run.state !== 'active')
343
+ return;
344
+ run.state = 'lost';
345
+ run.controller.abort(new Error('Run lease expired without confirmed renewal'));
346
+ if (run.heartbeat)
347
+ clearInterval(run.heartbeat);
348
+ };
349
+ if (!Number.isFinite(remaining) || remaining <= 0) {
350
+ lose();
351
+ return;
352
+ }
353
+ run.deadline = setTimeout(lose, Math.max(1, remaining - Math.min(1000, remaining / 10)));
354
+ run.deadline.unref?.();
355
+ }
356
+ flushResults() {
357
+ if (this.flushingResults)
358
+ return this.flushingResults;
359
+ this.flushingResults = (async () => {
360
+ for (const result of this.outbox.list()) {
361
+ // Active workers own their delivery/heartbeat loop.
362
+ if (this.inFlight.has(result.id))
363
+ continue;
364
+ const status = await sendWorkResult(this.cfg, result);
365
+ if (status !== 'retry')
366
+ this.outbox.remove(result.id);
367
+ }
368
+ })().catch(error => console.warn('[control-plane-tasks] outcome reconciliation failed:', error))
369
+ .finally(() => { this.flushingResults = undefined; });
370
+ return this.flushingResults;
371
+ }
372
+ async finishWork(id, action, run) {
373
+ const result = { id, action, claimToken: run.claimToken };
374
+ this.outbox.put(result);
375
+ // Keep ownership while retrying delivery, but never beyond its confirmed
376
+ // deadline. After a restart the outbox is reconciled before queue intake.
377
+ do {
378
+ const status = await sendWorkResult(this.cfg, result);
379
+ if (status !== 'retry') {
380
+ this.outbox.remove(id);
381
+ return;
382
+ }
383
+ if (!run.claimToken)
384
+ return; // legacy server: retain for the recovery poll
385
+ await this.sleep(1000);
386
+ } while (run.state === 'active');
387
+ }
388
+ /** Redeliver the same reservation after uncertain acknowledgement, without
389
+ * consuming another attempt or allowing agent work beyond our lease. */
390
+ async reserveAttempt(id, attempt, run) {
391
+ while (run.state === 'active') {
392
+ let rejected = false;
393
+ try {
394
+ const response = await cp(this.cfg, 'POST', `/node/work/${encodeURIComponent(id)}/attempt`, run.claimToken, { attempt }, run.controller.signal);
395
+ if (response.ok) {
396
+ const data = await response.json();
397
+ if (data.item?.attempt !== attempt + 1)
398
+ throw new Error('Invalid attempt reservation acknowledgement');
399
+ return run.state === 'active' ? data.item.attempt : undefined;
400
+ }
401
+ rejected = response.status !== 408 && response.status !== 429 && response.status < 500;
402
+ }
403
+ catch (error) {
404
+ if (run.state !== 'active')
405
+ return undefined;
406
+ console.warn(`[control-plane-tasks] work ${id} attempt reservation failed:`, error instanceof Error ? error.message : error);
407
+ }
408
+ if (rejected) {
409
+ // A conflict may mean either exhausted budget or lost ownership.
410
+ // Confirm ownership before parking; cancelled/stale workers just stop.
411
+ await this.checkLease(id, run);
412
+ if (run.state === 'active')
413
+ await this.finishWork(id, 'needs-attention', run);
414
+ return undefined;
415
+ }
416
+ if (run.state === 'active')
417
+ await this.sleep(1000);
418
+ }
419
+ return undefined;
420
+ }
297
421
  checkLease(id, run) {
298
422
  if (run.state !== "active")
299
423
  return Promise.resolve();
@@ -301,9 +425,17 @@ export class ControlPlaneTaskPoller {
301
425
  return run.leaseCheck;
302
426
  run.leaseCheck = (async () => {
303
427
  try {
304
- const renewal = await renewWorkLease(this.cfg, id);
305
- if (renewal === "renewed" || this.inFlight.get(id) !== run || run.state !== "active")
428
+ const response = await cp(this.cfg, 'POST', `/node/work/${encodeURIComponent(id)}/heartbeat`, run.claimToken);
429
+ const data = await response.json();
430
+ if (this.inFlight.get(id) !== run || run.state !== 'active')
431
+ return;
432
+ if (response.ok) {
433
+ this.setLeaseDeadline(run, data.leaseExpiresAt);
434
+ return;
435
+ }
436
+ if (response.status !== 409 && response.status !== 404)
306
437
  return;
438
+ const renewal = data.reason === 'cancelled' ? 'cancelled' : 'lost';
307
439
  run.state = renewal;
308
440
  if (run.heartbeat) {
309
441
  clearInterval(run.heartbeat);
@@ -312,8 +444,7 @@ export class ControlPlaneTaskPoller {
312
444
  run.controller.abort(new Error(renewal === "cancelled" ? "Run cancelled" : "Run lease lost"));
313
445
  }
314
446
  catch (error) {
315
- // A transient network error does not prove ownership was lost. Keep the
316
- // Run alive and let the next heartbeat retry.
447
+ // Transient failures may retry only until the last confirmed deadline.
317
448
  console.warn(`[control-plane-tasks] work ${id} heartbeat failed:`, error instanceof Error ? error.message : error);
318
449
  }
319
450
  finally {
@@ -333,7 +464,7 @@ export class ControlPlaneTaskPoller {
333
464
  */
334
465
  async runWithPolicy(item, report, run) {
335
466
  let current = item;
336
- let attempt = 1;
467
+ let attempt = Math.max(1, item.attempt ?? 1);
337
468
  let rerouteCount = 0;
338
469
  for (;;) {
339
470
  if (run.state !== "active")
@@ -344,12 +475,16 @@ export class ControlPlaneTaskPoller {
344
475
  await this.runItem({ ...current, attempt }, report, run.controller.signal);
345
476
  if (run.state !== "active")
346
477
  return;
347
- await completeWork(this.cfg, item.id);
478
+ await this.finishWork(item.id, 'complete', run);
348
479
  return;
349
480
  }
350
481
  catch (error) {
351
482
  // Abort errors are ordinary throws to the policy layer unless guarded.
352
483
  // A cancelled/lost Run has no node-side terminal transition or retry.
484
+ if (this.outbox.has(item.id)) {
485
+ console.error(`[control-plane-tasks] work ${item.id} result delivery/persistence failed; agent will not be retried:`, error);
486
+ return;
487
+ }
353
488
  if (run.state !== "active")
354
489
  return;
355
490
  // Deployment admission is not an agent failure. Never reroute/retry it
@@ -362,7 +497,7 @@ export class ControlPlaneTaskPoller {
362
497
  });
363
498
  if (run.state !== "active")
364
499
  return;
365
- await needsAttentionWork(this.cfg, item.id);
500
+ await this.finishWork(item.id, 'needs-attention', run);
366
501
  return;
367
502
  }
368
503
  const policy = typeof this.policy === "function" ? this.policy(current) : this.policy;
@@ -381,11 +516,19 @@ export class ControlPlaneTaskPoller {
381
516
  await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary, attempt }] });
382
517
  if (run.state !== "active")
383
518
  return;
384
- await needsAttentionWork(this.cfg, item.id);
519
+ await this.finishWork(item.id, 'needs-attention', run);
385
520
  return;
386
521
  }
387
522
  if (decision.action === "retry" || decision.action === "reroute") {
388
- attempt += 1;
523
+ if (run.claimToken) {
524
+ const reserved = await this.reserveAttempt(item.id, attempt, run);
525
+ if (reserved === undefined)
526
+ return;
527
+ attempt = reserved;
528
+ }
529
+ else {
530
+ attempt += 1;
531
+ } // legacy control plane
389
532
  const kind = decision.action === "retry" ? "retry" : "fallback";
390
533
  console.warn(`[control-plane-tasks] item ${item.id} ${kind} (${decision.condition}): ${decision.summary}`);
391
534
  await report({
@@ -425,13 +568,13 @@ export class ControlPlaneTaskPoller {
425
568
  await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary: decision.summary }] });
426
569
  if (run.state !== "active")
427
570
  return;
428
- await needsAttentionWork(this.cfg, item.id);
571
+ await this.finishWork(item.id, 'needs-attention', run);
429
572
  return;
430
573
  }
431
574
  console.warn(`[control-plane-tasks] item ${item.id} failed:`, error);
432
575
  if (run.state !== "active")
433
576
  return;
434
- await failWork(this.cfg, item.id);
577
+ await this.finishWork(item.id, 'fail', run);
435
578
  return;
436
579
  }
437
580
  }
package/dist/server.js CHANGED
@@ -5097,6 +5097,9 @@ async function executeWorkItem(item, report, signal) {
5097
5097
  const sandbox = safety.sandbox;
5098
5098
  const sessionOpts = {
5099
5099
  makeActive: false,
5100
+ // The same durable Run adopts its branch after a retry/reclaim instead of
5101
+ // producing a second randomly named branch/PR on a fresh process.
5102
+ workBranch: `bivy/run-${createHash('sha256').update(item.id).digest('hex').slice(0, 24)}`,
5100
5103
  title: item.title,
5101
5104
  credentialLabels,
5102
5105
  runtimeId: item.runtimeId,
@@ -5210,6 +5213,7 @@ function startControlPlaneTasksIfConfigured() {
5210
5213
  // limit, park quota/auth/context); user-authored rulesets can add fallback
5211
5214
  // chains. Queue runs are unattended, so they act automatically within bounds.
5212
5215
  controlPlanePoller = new ControlPlaneTaskPoller(cfg, runWorkItem, nodeGithubMaxConcurrent, {
5216
+ resultDirectory: path.join(appDir, 'work-results'),
5213
5217
  policy: (item) => {
5214
5218
  // Repository policy is version-controlled with the code and wins over the
5215
5219
  // node-global UI ruleset for this run. The shared clone exists by the time
@@ -8403,7 +8407,7 @@ async function createGitWorkspaceSession(repoDir, parsed, opts = {}) {
8403
8407
  // Start from an opaque, git-safe unique branch. The first user message then
8404
8408
  // triggers sessionNamer.maybeNameSession(), which renames both the session and local branch
8405
8409
  // before the first publish/PR attempt.
8406
- const branch = `bivy/session-${randomBytes(6).toString("hex")}`;
8410
+ const branch = opts.workBranch ?? `bivy/session-${randomBytes(6).toString("hex")}`;
8407
8411
  const record = await createSession(repoDir, undefined, {
8408
8412
  worktree: { branch, base },
8409
8413
  source: `repo:${parsed.slug}`,
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ /** Metadata only. Persist before delivery so restarting the daemon retries the
6
+ * acknowledgement, not the agent. Scope files to the enrolled control plane. */
7
+ export class WorkResultOutbox {
8
+ pending = new Map();
9
+ file;
10
+ constructor(directory, scope = "") {
11
+ if (!directory)
12
+ return;
13
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
14
+ this.file = path.join(directory, `${createHash("sha256").update(scope).digest("hex")}.json`);
15
+ if (existsSync(this.file)) {
16
+ const rows = JSON.parse(readFileSync(this.file, "utf8"));
17
+ if (!Array.isArray(rows) || rows.some(row => !row || typeof row.id !== 'string' || !['complete', 'fail', 'needs-attention'].includes(row.action)))
18
+ throw new Error('Invalid work result outbox; refusing to replay work');
19
+ for (const row of rows)
20
+ this.pending.set(row.id, row);
21
+ }
22
+ }
23
+ list() { return [...this.pending.values()]; }
24
+ has(id) { return this.pending.has(id); }
25
+ put(result) { this.pending.set(result.id, result); this.flush(); }
26
+ remove(id) { this.pending.delete(id); this.flush(); }
27
+ flush() {
28
+ if (!this.file)
29
+ return;
30
+ const temp = `${this.file}.tmp`;
31
+ const fd = openSync(temp, 'w', 0o600);
32
+ try {
33
+ writeFileSync(fd, JSON.stringify(this.list()));
34
+ fsyncSync(fd);
35
+ }
36
+ finally {
37
+ closeSync(fd);
38
+ }
39
+ try {
40
+ renameSync(temp, this.file);
41
+ }
42
+ catch (error) {
43
+ if (existsSync(temp))
44
+ unlinkSync(temp);
45
+ throw error;
46
+ }
47
+ const dir = openSync(path.dirname(this.file), 'r');
48
+ try {
49
+ fsyncSync(dir);
50
+ }
51
+ finally {
52
+ closeSync(dir);
53
+ }
54
+ }
55
+ }
package/dist/worktree.js CHANGED
@@ -52,7 +52,7 @@ function excludeMeshDir(repoRoot) {
52
52
  }
53
53
  }
54
54
  /**
55
- * Create a worktree for `repoDir` on a new branch. The worktree lives under
55
+ * Create or recover a worktree for `repoDir`, preserving existing work. It lives under
56
56
  * `<repoRoot>/.bivy/worktrees/<slug>` by default (excluded from git).
57
57
  */
58
58
  export async function createWorktree(opts) {
@@ -63,41 +63,38 @@ export async function createWorktree(opts) {
63
63
  const branch = opts.branch ?? `bivy/${slug}`;
64
64
  const root = opts.root ?? path.join(repoRoot, ".bivy", "worktrees");
65
65
  const wtPath = path.join(root, slug);
66
- const base = opts.base ?? (await currentRef(repoRoot));
66
+ let base = opts.base ?? (await currentRef(repoRoot));
67
67
  excludeMeshDir(repoRoot);
68
68
  fs.mkdirSync(root, { recursive: true });
69
- try {
70
- await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, base]);
69
+ // Reuse the registered checkout in place. Recovery must not force-remove
70
+ // staged, unstaged, untracked or ignored work left by the previous attempt.
71
+ // Use Git's registry rather than merely accepting an existing directory.
72
+ const { stdout: registered } = await exec("git", ["-C", repoRoot, "worktree", "list", "--porcelain", "-z"]);
73
+ const existing = registered.split("\0\0").map(entry => entry.split("\0"))
74
+ .find(fields => fields.includes(`worktree ${wtPath}`));
75
+ if (existing && fs.existsSync(wtPath)) {
76
+ if (!existing.includes(`branch refs/heads/${branch}`))
77
+ throw new Error(`Worktree ${wtPath} is not on expected branch ${branch}`);
78
+ return { path: wtPath, branch, repoRoot };
71
79
  }
72
- catch (error) {
73
- // The branch already exists (e.g. a GitHub-issue pickup whose branch was
74
- // pushed on an earlier run, now re-triggered after the session closed). Adopt
75
- // it instead of hard-failing, which previously bubbled up and silently marked
76
- // the work item "done" with nothing happening. Clear any stale worktree dir
77
- // first, then check the existing branch out into a fresh worktree.
78
- const localExists = await refExists(repoRoot, `refs/heads/${branch}`);
79
- const remoteExists = !localExists && (await refExists(repoRoot, `refs/remotes/origin/${branch}`));
80
- if (localExists || remoteExists) {
81
- await removeWorktree(repoRoot, wtPath);
82
- fs.rmSync(wtPath, { recursive: true, force: true });
83
- if (localExists) {
84
- await exec("git", ["-C", repoRoot, "worktree", "add", wtPath, branch]);
85
- }
86
- else {
87
- // Recreate the local branch from origin, then check it out in the worktree.
88
- await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, `origin/${branch}`]);
89
- }
90
- }
91
- else if (base !== "HEAD" && !(await refExists(repoRoot, base))) {
92
- // Defense in depth for stale internal metadata: callers should resolve a
93
- // fork base first, but a missing ref must not prevent session stand-up.
94
- await removeWorktree(repoRoot, wtPath);
95
- fs.rmSync(wtPath, { recursive: true, force: true });
96
- await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, "HEAD"]);
97
- }
98
- else {
99
- throw error;
100
- }
80
+ // A manually reaped directory can leave a stale registration. Remove only
81
+ // that registration, without --force (locked or newly dirty work stays safe).
82
+ if (existing)
83
+ await exec("git", ["-C", repoRoot, "worktree", "remove", wtPath]);
84
+ // A remote-only branch does NOT make `worktree add -b` fail. Resolve it
85
+ // before creation, otherwise a fresh Machine silently starts at default HEAD.
86
+ const localExists = await refExists(repoRoot, `refs/heads/${branch}`);
87
+ const remoteExists = !localExists && await refExists(repoRoot, `refs/remotes/origin/${branch}`);
88
+ if (localExists) {
89
+ await exec("git", ["-C", repoRoot, "worktree", "add", wtPath, branch]);
90
+ }
91
+ else {
92
+ if (remoteExists)
93
+ base = `refs/remotes/origin/${branch}`;
94
+ else if (base !== "HEAD" && !(await refExists(repoRoot, base)))
95
+ base = "HEAD";
96
+ // Fail closed on path collisions; never delete an unrelated directory.
97
+ await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, wtPath, base]);
101
98
  }
102
99
  // Opportunistically reuse a sibling worktree's installed deps (node_modules,
103
100
  // target, .venv) via copy-on-write when the filesystem supports it, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.19-staging.2",
3
+ "version": "0.16.19-staging.4",
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.",