@agentproto/worktree 0.13.0 → 0.14.0

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.
@@ -6,8 +6,9 @@ import { resolve, join, dirname, basename, posix, sep } from 'path';
6
6
  import { ToolError } from '@agentproto/tool';
7
7
  import { spawn } from 'child_process';
8
8
  import { z } from 'zod';
9
- import { homedir } from 'os';
10
- import { existsSync, mkdirSync, realpathSync, readdirSync } from 'fs';
9
+ import { AsyncLocalStorage } from 'async_hooks';
10
+ import { loadavg, cpus, homedir } from 'os';
11
+ import { existsSync, mkdirSync, realpathSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
11
12
  import { createServer } from 'net';
12
13
 
13
14
  /**
@@ -17,26 +18,81 @@ import { createServer } from 'net';
17
18
  function mergedEnv(extra) {
18
19
  return extra ? { ...process.env, ...extra } : process.env;
19
20
  }
21
+ function abortReason(signal) {
22
+ const reason = signal.reason;
23
+ return reason instanceof Error ? reason : new Error("aborted");
24
+ }
25
+ function signalTree(child, sig) {
26
+ if (child.pid === void 0) return;
27
+ try {
28
+ if (process.platform === "win32") child.kill(sig);
29
+ else process.kill(-child.pid, sig);
30
+ } catch {
31
+ }
32
+ }
33
+ function killOnAbort(child, opts) {
34
+ const { signal } = opts;
35
+ if (!signal) return () => {
36
+ };
37
+ let escalation;
38
+ const onAbort = () => {
39
+ signalTree(child, "SIGTERM");
40
+ escalation = setTimeout(() => signalTree(child, "SIGKILL"), opts.killGraceMs ?? 5e3);
41
+ escalation.unref();
42
+ };
43
+ if (signal.aborted) onAbort();
44
+ else signal.addEventListener("abort", onAbort, { once: true });
45
+ return () => {
46
+ signal.removeEventListener("abort", onAbort);
47
+ if (escalation && !signal.aborted) clearTimeout(escalation);
48
+ };
49
+ }
20
50
  function execArgv(command, args, cwd, opts = {}) {
21
51
  return new Promise((resolvePromise, reject) => {
22
- const child = spawn(command, args, { cwd, shell: false, env: mergedEnv(opts.env) });
52
+ if (opts.signal?.aborted) return reject(abortReason(opts.signal));
53
+ const child = spawn(command, args, {
54
+ cwd,
55
+ shell: false,
56
+ env: mergedEnv(opts.env),
57
+ detached: opts.signal !== void 0 && process.platform !== "win32"
58
+ });
59
+ const detach = killOnAbort(child, opts);
23
60
  let stdout = "";
24
61
  let stderr = "";
25
62
  child.stdout?.on("data", (d) => stdout += d.toString("utf8"));
26
63
  child.stderr?.on("data", (d) => stderr += d.toString("utf8"));
27
- child.on("error", reject);
28
- child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
64
+ child.on("error", (err) => {
65
+ detach();
66
+ reject(err);
67
+ });
68
+ child.on("close", (code) => {
69
+ detach();
70
+ resolvePromise({ exitCode: code ?? -1, stdout, stderr });
71
+ });
29
72
  });
30
73
  }
31
74
  function execShell(cmd, cwd, opts = {}) {
32
75
  return new Promise((resolvePromise, reject) => {
33
- const child = spawn(cmd, { cwd, shell: true, env: mergedEnv(opts.env) });
76
+ if (opts.signal?.aborted) return reject(abortReason(opts.signal));
77
+ const child = spawn(cmd, {
78
+ cwd,
79
+ shell: true,
80
+ env: mergedEnv(opts.env),
81
+ detached: opts.signal !== void 0 && process.platform !== "win32"
82
+ });
83
+ const detach = killOnAbort(child, opts);
34
84
  let stdout = "";
35
85
  let stderr = "";
36
86
  child.stdout?.on("data", (d) => stdout += d.toString("utf8"));
37
87
  child.stderr?.on("data", (d) => stderr += d.toString("utf8"));
38
- child.on("error", reject);
39
- child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
88
+ child.on("error", (err) => {
89
+ detach();
90
+ reject(err);
91
+ });
92
+ child.on("close", (code) => {
93
+ detach();
94
+ resolvePromise({ exitCode: code ?? -1, stdout, stderr });
95
+ });
40
96
  });
41
97
  }
42
98
  async function execGit(repoRoot, args) {
@@ -283,29 +339,223 @@ function resolveLocalWriteFiles(files, slug) {
283
339
  content: applySlugPlaceholder(file.content, slug)
284
340
  }));
285
341
  }
286
- async function cloneEntry(src, dest) {
342
+ var DEFAULT_PROVISION_CONCURRENCY = 2;
343
+ var PROVISION_CONCURRENCY_ENV = "AGENTPROTO_WORKTREES_PROVISION_CONCURRENCY";
344
+ var DEFAULT_PROVISION_LIMITS = Object.freeze({
345
+ concurrency: DEFAULT_PROVISION_CONCURRENCY,
346
+ perRepo: Object.freeze({}),
347
+ loadFactor: 0
348
+ });
349
+ function parseCap(raw) {
350
+ if (typeof raw === "string" && raw.trim() !== "") raw = Number(raw);
351
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return void 0;
352
+ return raw === 0 ? Infinity : raw;
353
+ }
354
+ function normalizeRepoKey(key) {
355
+ const trimmed = key.trim();
356
+ if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
357
+ if (trimmed.includes("/") || trimmed.includes("\\")) return resolve(trimmed);
358
+ return trimmed;
359
+ }
360
+ function parseProvisionLimits(raw, env = process.env) {
361
+ const section = raw !== null && typeof raw === "object" ? raw : {};
362
+ const concurrency = parseCap(env[PROVISION_CONCURRENCY_ENV]) ?? parseCap(section["provisionConcurrency"]) ?? DEFAULT_PROVISION_CONCURRENCY;
363
+ const perRepo = {};
364
+ const byRepo = section["provisionConcurrencyByRepo"];
365
+ if (byRepo !== null && typeof byRepo === "object" && !Array.isArray(byRepo)) {
366
+ for (const [key, value] of Object.entries(byRepo)) {
367
+ const cap = parseCap(value);
368
+ if (cap !== void 0 && key.trim() !== "") perRepo[normalizeRepoKey(key)] = cap;
369
+ }
370
+ }
371
+ const factor = section["provisionLoadFactor"];
372
+ const loadFactor = typeof factor === "number" && Number.isFinite(factor) && factor > 0 ? factor : 0;
373
+ return { concurrency, perRepo, loadFactor };
374
+ }
375
+ var ProvisionCancelledError = class extends Error {
376
+ constructor(message = "worktree provisioning cancelled") {
377
+ super(message);
378
+ this.name = "ProvisionCancelledError";
379
+ }
380
+ };
381
+ var contextStore = new AsyncLocalStorage();
382
+ function runWithProvisionContext(ctx, fn) {
383
+ return contextStore.run(ctx, fn);
384
+ }
385
+ function currentProvisionContext() {
386
+ return contextStore.getStore();
387
+ }
388
+ var ProvisionScheduler = class {
389
+ limits;
390
+ queue = [];
391
+ running = /* @__PURE__ */ new Set();
392
+ seq = 0;
393
+ loadTimer;
394
+ loadavg1;
395
+ cpuCount;
396
+ loadPollMs;
397
+ constructor(opts = {}) {
398
+ this.limits = opts.limits ?? DEFAULT_PROVISION_LIMITS;
399
+ this.loadavg1 = opts.loadavg1 ?? (() => loadavg()[0] ?? 0);
400
+ this.cpuCount = opts.cpuCount ?? (() => cpus().length || 1);
401
+ this.loadPollMs = opts.loadPollMs ?? 5e3;
402
+ }
403
+ /** Replace the limits (config is re-read per provision). Raising a cap
404
+ * admits waiters immediately; lowering one never interrupts a running
405
+ * segment, it just delays the next admission. */
406
+ configure(limits) {
407
+ this.limits = limits;
408
+ this.pump();
409
+ }
410
+ snapshot() {
411
+ return { running: this.running.size, queued: this.queue.length, limit: this.limits.concurrency };
412
+ }
413
+ /**
414
+ * Request a heavy-phase slot. Resolves with a lease once admitted; rejects
415
+ * with {@link ProvisionCancelledError} when `signal` aborts first (the
416
+ * queue entry is dropped, so a cancelled spawn never occupies a slot).
417
+ */
418
+ acquire(req) {
419
+ if (req.signal?.aborted) return Promise.reject(new ProvisionCancelledError());
420
+ return new Promise((resolvePromise, rejectPromise) => {
421
+ const waiter = {
422
+ seq: this.seq++,
423
+ repoKey: req.repoKey,
424
+ callerKey: req.callerKey ?? "anonymous",
425
+ signal: req.signal,
426
+ onQueued: req.onQueued,
427
+ lastPosition: 0,
428
+ grant: resolvePromise,
429
+ reject: rejectPromise,
430
+ detach: () => {
431
+ }
432
+ };
433
+ if (req.signal) {
434
+ const onAbort = () => {
435
+ const idx = this.queue.indexOf(waiter);
436
+ if (idx === -1) return;
437
+ this.queue.splice(idx, 1);
438
+ waiter.reject(new ProvisionCancelledError("worktree provisioning cancelled while queued"));
439
+ this.pump();
440
+ };
441
+ req.signal.addEventListener("abort", onAbort, { once: true });
442
+ waiter.detach = () => req.signal.removeEventListener("abort", onAbort);
443
+ }
444
+ this.queue.push(waiter);
445
+ this.pump();
446
+ });
447
+ }
448
+ repoCap(repoKey) {
449
+ const { perRepo } = this.limits;
450
+ const own = (key) => Object.hasOwn(perRepo, key) ? perRepo[key] : void 0;
451
+ return Math.min(own(repoKey) ?? own(basename(repoKey)) ?? Infinity, this.limits.concurrency);
452
+ }
453
+ runningFor(match) {
454
+ let n = 0;
455
+ for (const slot of this.running) if (match(slot)) n++;
456
+ return n;
457
+ }
458
+ /** Waiters in the order they would be admitted absent per-repo caps. */
459
+ projectedOrder() {
460
+ const held = /* @__PURE__ */ new Map();
461
+ for (const slot of this.running) held.set(slot.callerKey, (held.get(slot.callerKey) ?? 0) + 1);
462
+ const remaining = [...this.queue];
463
+ const ordered = [];
464
+ while (remaining.length > 0) {
465
+ let best = 0;
466
+ for (let i = 1; i < remaining.length; i++) {
467
+ if ((held.get(remaining[i].callerKey) ?? 0) < (held.get(remaining[best].callerKey) ?? 0)) best = i;
468
+ }
469
+ const [next] = remaining.splice(best, 1);
470
+ ordered.push(next);
471
+ held.set(next.callerKey, (held.get(next.callerKey) ?? 0) + 1);
472
+ }
473
+ return ordered;
474
+ }
475
+ loadGuardBlocks() {
476
+ const { loadFactor } = this.limits;
477
+ if (loadFactor <= 0) return false;
478
+ if (this.running.size === 0) return false;
479
+ return this.loadavg1() > this.cpuCount() * loadFactor;
480
+ }
481
+ pump() {
482
+ for (; ; ) {
483
+ if (this.queue.length === 0) break;
484
+ if (this.running.size >= this.limits.concurrency) break;
485
+ if (this.loadGuardBlocks()) {
486
+ this.armLoadTimer();
487
+ break;
488
+ }
489
+ const next = this.projectedOrder().find(
490
+ (w) => this.runningFor((s) => s.repoKey === w.repoKey) < this.repoCap(w.repoKey)
491
+ );
492
+ if (!next) break;
493
+ this.queue.splice(this.queue.indexOf(next), 1);
494
+ next.detach();
495
+ const slot = { repoKey: next.repoKey, callerKey: next.callerKey };
496
+ this.running.add(slot);
497
+ let released = false;
498
+ next.grant({
499
+ release: () => {
500
+ if (released) return;
501
+ released = true;
502
+ this.running.delete(slot);
503
+ this.pump();
504
+ }
505
+ });
506
+ }
507
+ this.notifyPositions();
508
+ }
509
+ notifyPositions() {
510
+ this.projectedOrder().forEach((waiter, idx) => {
511
+ const position = idx + 1;
512
+ if (position === waiter.lastPosition) return;
513
+ waiter.lastPosition = position;
514
+ try {
515
+ waiter.onQueued?.(position);
516
+ } catch {
517
+ }
518
+ });
519
+ }
520
+ armLoadTimer() {
521
+ if (this.loadTimer) return;
522
+ this.loadTimer = setTimeout(() => {
523
+ this.loadTimer = void 0;
524
+ this.pump();
525
+ }, this.loadPollMs);
526
+ this.loadTimer.unref();
527
+ }
528
+ };
529
+ var provisionScheduler = new ProvisionScheduler();
530
+
531
+ // src/clone.ts
532
+ async function cloneEntry(src, dest, signal) {
533
+ const execOpts = signal ? { signal } : {};
287
534
  await mkdir(dirname(dest), { recursive: true });
288
535
  const platform = process.platform;
289
536
  const cloneArgs = platform === "darwin" ? ["-Rc", src, dest] : platform === "linux" ? ["-r", "--reflink=auto", src, dest] : null;
290
537
  if (cloneArgs) {
291
- const result = await execArgv("cp", cloneArgs, dirname(dest));
538
+ const result = await execArgv("cp", cloneArgs, dirname(dest), execOpts);
292
539
  if (result.exitCode === 0) return;
540
+ if (signal?.aborted) throw new ProvisionCancelledError();
293
541
  }
294
- const fallback = await execArgv("cp", ["-R", src, dest], dirname(dest));
542
+ const fallback = await execArgv("cp", ["-R", src, dest], dirname(dest), execOpts);
543
+ if (signal?.aborted) throw new ProvisionCancelledError();
295
544
  if (fallback.exitCode !== 0) {
296
545
  throw new Error(
297
546
  `clone of '${src}' into '${dest}' failed: ${fallback.stderr || fallback.stdout}`
298
547
  );
299
548
  }
300
549
  }
301
- async function cloneEntries(repoRoot, cwd, patterns) {
550
+ async function cloneEntries(repoRoot, cwd, patterns, signal) {
302
551
  for (const pattern of patterns) {
303
552
  const matches = await expandCloneGlob(repoRoot, pattern);
304
553
  for (const rel of matches) {
554
+ if (signal?.aborted) throw new ProvisionCancelledError();
305
555
  const dest = join(cwd, rel);
306
556
  const existing = await lstat(dest).catch(() => null);
307
557
  if (existing) continue;
308
- await cloneEntry(join(repoRoot, rel), dest);
558
+ await cloneEntry(join(repoRoot, rel), dest, signal);
309
559
  }
310
560
  }
311
561
  }
@@ -409,16 +659,20 @@ async function runSetup(config, ctx, opts = {}) {
409
659
  const commands = normalizeHook(config.worktree?.setup);
410
660
  const runs = [];
411
661
  const env = { ...hookEnv(ctx), TURBO_CACHE_DIR: resolveWorktreesTurboCacheDir() };
662
+ const execOpts = { env, ...opts.signal ? { signal: opts.signal } : {} };
412
663
  for (const command of commands) {
413
- let result = await execShell(command, ctx.worktreePath, { env });
664
+ if (opts.signal?.aborted) throw new ProvisionCancelledError();
665
+ let result = await execShell(command, ctx.worktreePath, execOpts);
414
666
  let run = { command, result };
415
667
  runs.push(run);
416
668
  await appendHookLog(opts.logPath, command, result);
669
+ if (opts.signal?.aborted) throw new ProvisionCancelledError();
417
670
  if (result.exitCode !== 0 && opts.retryOnFailure) {
418
- result = await execShell(command, ctx.worktreePath, { env });
671
+ result = await execShell(command, ctx.worktreePath, execOpts);
419
672
  run = { command, result };
420
673
  runs.push(run);
421
674
  await appendHookLog(opts.logPath, `${command} (retry 1/1)`, result);
675
+ if (opts.signal?.aborted) throw new ProvisionCancelledError();
422
676
  }
423
677
  if (result.exitCode !== 0) throw new HookError("setup", run, opts.logPath);
424
678
  }
@@ -435,6 +689,127 @@ async function runTeardown(config, ctx, opts = {}) {
435
689
  }
436
690
  return runs;
437
691
  }
692
+ var WORKTREE_TRASH_DIRNAME = ".trash";
693
+ var WORKTREE_TRASH_PIDFILE = ".deleting";
694
+ var DELETER_SCRIPT = `trap 'rm -f "$1/.deleting"' EXIT; [ ! -e "$1/.deleting" ] || echo $$ > "$1/.deleting"; n=1; while [ "$n" -gt 0 ]; do n=0; for d in "$1"/*; do [ -e "$d" ] || continue; rm -rf "$d"; n=1; done; done`;
695
+ function isPidAlive(pid) {
696
+ try {
697
+ process.kill(pid, 0);
698
+ return true;
699
+ } catch (err) {
700
+ return err.code === "EPERM";
701
+ }
702
+ }
703
+ function defaultSpawnDeleter(trashParent) {
704
+ const child = spawn("sh", ["-c", DELETER_SCRIPT, "_", trashParent], { detached: true, stdio: "ignore" });
705
+ child.unref();
706
+ return child.pid;
707
+ }
708
+ function ensureTrashDeleter(trashParent, spawnDeleter = defaultSpawnDeleter) {
709
+ const pidFile = join(trashParent, WORKTREE_TRASH_PIDFILE);
710
+ try {
711
+ const pid2 = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
712
+ if (Number.isInteger(pid2) && pid2 > 0 && isPidAlive(pid2)) return;
713
+ } catch {
714
+ }
715
+ let placeholder = false;
716
+ try {
717
+ writeFileSync(pidFile, String(process.pid));
718
+ placeholder = true;
719
+ } catch {
720
+ }
721
+ let pid;
722
+ try {
723
+ pid = spawnDeleter(trashParent);
724
+ } catch (err) {
725
+ if (placeholder) rmSync(pidFile, { force: true });
726
+ throw err;
727
+ }
728
+ if (pid === void 0 && placeholder) rmSync(pidFile, { force: true });
729
+ }
730
+ function realOrResolved(path) {
731
+ try {
732
+ return realpathSync(path);
733
+ } catch {
734
+ return path;
735
+ }
736
+ }
737
+ async function removeWorktreeFast(repoRoot, path, options = {}) {
738
+ const force = options.force === true;
739
+ if (!force) {
740
+ const status = await execArgv(
741
+ "git",
742
+ ["-C", path, "status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none"],
743
+ repoRoot
744
+ );
745
+ if (status.exitCode !== 0) {
746
+ throw new Error(
747
+ `git status failed in ${path} (exit ${status.exitCode}): ${status.stderr.trim() || status.stdout.trim()}`
748
+ );
749
+ }
750
+ if (status.stdout.trim().length > 0) {
751
+ throw new Error(
752
+ `git worktree remove failed (exit 128): '${path}' contains modified or untracked files, use --force to delete it`
753
+ );
754
+ }
755
+ }
756
+ const list = await execArgv("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], repoRoot);
757
+ if (list.exitCode !== 0) {
758
+ throw new Error(
759
+ `git worktree list failed (exit ${list.exitCode}): ${list.stderr.trim() || list.stdout.trim()}`
760
+ );
761
+ }
762
+ const target = realOrResolved(path);
763
+ let found = false;
764
+ let locked = false;
765
+ for (const block of list.stdout.split(/\n\s*\n/)) {
766
+ const lines = block.split("\n");
767
+ const head = lines.find((line) => line.startsWith("worktree "));
768
+ if (!head || realOrResolved(head.slice("worktree ".length).trim()) !== target) continue;
769
+ found = true;
770
+ locked = lines.some((line) => line === "locked" || line.startsWith("locked "));
771
+ break;
772
+ }
773
+ if (!found) {
774
+ throw new Error(`not a linked worktree of ${repoRoot}: ${path}`);
775
+ }
776
+ if (locked) {
777
+ throw new Error(`git worktree remove failed (exit 128): '${path}' is locked; unlock it first`);
778
+ }
779
+ const poolDir = join(path, "..");
780
+ const stamp = (options.now ?? Date.now)();
781
+ const trashDir = join(poolDir, WORKTREE_TRASH_DIRNAME, `${basename(path)}-${stamp}`);
782
+ try {
783
+ if (!existsSync(join(poolDir, WORKTREE_TRASH_DIRNAME))) {
784
+ mkdirSync(join(poolDir, WORKTREE_TRASH_DIRNAME));
785
+ }
786
+ await rename(path, trashDir);
787
+ } catch {
788
+ await execGit(repoRoot, force ? ["worktree", "remove", "--force", path] : ["worktree", "remove", path]);
789
+ return;
790
+ }
791
+ await execGit(repoRoot, ["worktree", "prune"]);
792
+ if (options.spawnRemoval) {
793
+ options.spawnRemoval(trashDir);
794
+ } else {
795
+ ensureTrashDeleter(dirname(trashDir), options.spawnDeleter);
796
+ }
797
+ }
798
+ function sweepWorktreeTrash(root, options = {}) {
799
+ let names;
800
+ try {
801
+ names = readdirSync(join(root, WORKTREE_TRASH_DIRNAME));
802
+ } catch {
803
+ return;
804
+ }
805
+ names = names.filter((name) => name !== WORKTREE_TRASH_PIDFILE);
806
+ if (names.length === 0) return;
807
+ if (options.spawnRemoval) {
808
+ for (const name of names) options.spawnRemoval(join(root, WORKTREE_TRASH_DIRNAME, name));
809
+ } else {
810
+ ensureTrashDeleter(join(root, WORKTREE_TRASH_DIRNAME), options.spawnDeleter);
811
+ }
812
+ }
438
813
  var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
439
814
  var SESSIONS_BUCKETS_ROOT = () => resolve(homedir(), ".agentproto", "workspaces");
440
815
  var sessionRefSchema = z.object({
@@ -570,6 +945,12 @@ async function computeProvenance(repoRoot, worktreePath, options = {}) {
570
945
  }
571
946
 
572
947
  // src/provider/bodies/provision-worktree.body.ts
948
+ async function discardCancelledWorktree(repoRoot, cwd, branch) {
949
+ await removeWorktreeFast(repoRoot, cwd, { force: true }).catch(() => {
950
+ });
951
+ await execGit(repoRoot, ["branch", "-D", branch]).catch(() => {
952
+ });
953
+ }
573
954
  var provisionWorktreeBuiltin = implementTool(
574
955
  provisionWorktreeTool,
575
956
  async ({ input }) => {
@@ -609,156 +990,124 @@ var provisionWorktreeBuiltin = implementTool(
609
990
  await writeFile(dest, file.content);
610
991
  }
611
992
  }
612
- if (cloneGlobs.length > 0) {
993
+ const ctx = currentProvisionContext();
994
+ const signal = ctx?.signal;
995
+ const report = (progress) => {
613
996
  try {
614
- await cloneEntries(input.repoRoot, cwd, cloneGlobs);
615
- } catch (err) {
616
- throw new ToolError({
617
- code: "execution_failed",
618
- message: `cloneGlobs failed: ${err instanceof Error ? err.message : String(err)}`
619
- });
997
+ ctx?.onProgress?.(progress);
998
+ } catch {
620
999
  }
1000
+ };
1001
+ const hasSetup = config ? normalizeHook(config.worktree?.setup).length > 0 : false;
1002
+ const firstHeavy = cloneGlobs.length > 0 ? "clone" : depsCmd ? "deps" : copyGlobs.length > 0 ? "copy" : hasSetup ? "setup" : null;
1003
+ if (firstHeavy === null) {
1004
+ report({ kind: "done", outcome: "ok" });
1005
+ return { cwd, branch };
621
1006
  }
622
- if (depsCmd) {
623
- const result = await execShell(depsCmd, cwd);
624
- if (result.exitCode !== 0) {
625
- throw new ToolError({
626
- code: "execution_failed",
627
- message: `depsCmd '${depsCmd}' failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
628
- });
1007
+ let phase = firstHeavy;
1008
+ let lease;
1009
+ try {
1010
+ lease = await provisionScheduler.acquire({
1011
+ repoKey: resolve(input.repoRoot),
1012
+ ...ctx?.callerKey ? { callerKey: ctx.callerKey } : {},
1013
+ ...signal ? { signal } : {},
1014
+ onQueued: (position) => report({ kind: "queued", position, phase: firstHeavy })
1015
+ });
1016
+ if (signal?.aborted) throw new ProvisionCancelledError();
1017
+ report({ kind: "started", phase });
1018
+ if (cloneGlobs.length > 0) {
1019
+ phase = "clone";
1020
+ report({ kind: "phase", phase });
1021
+ try {
1022
+ await cloneEntries(input.repoRoot, cwd, cloneGlobs, signal);
1023
+ } catch (err) {
1024
+ if (err instanceof ProvisionCancelledError) throw err;
1025
+ throw new ToolError({
1026
+ code: "execution_failed",
1027
+ message: `cloneGlobs failed: ${err instanceof Error ? err.message : String(err)}`
1028
+ });
1029
+ }
629
1030
  }
630
- }
631
- for (const pattern of copyGlobs) {
632
- const matches = await expandGlob(input.repoRoot, pattern);
633
- for (const rel of matches) {
634
- const dest = join(cwd, rel);
635
- await mkdir(dirname(dest), { recursive: true });
636
- await copyFile(join(input.repoRoot, rel), dest);
1031
+ if (depsCmd) {
1032
+ phase = "deps";
1033
+ report({ kind: "phase", phase });
1034
+ const result = await execShell(depsCmd, cwd, signal ? { signal } : {});
1035
+ if (signal?.aborted) throw new ProvisionCancelledError();
1036
+ if (result.exitCode !== 0) {
1037
+ throw new ToolError({
1038
+ code: "execution_failed",
1039
+ message: `depsCmd '${depsCmd}' failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
1040
+ });
1041
+ }
637
1042
  }
638
- }
639
- if (config) {
640
- try {
641
- await runSetup(
642
- config,
643
- {
644
- sourceCheckoutPath: input.repoRoot,
645
- worktreePath: cwd,
646
- branchName: branch
647
- },
648
- {
649
- ...input.setupLogPath ? { logPath: input.setupLogPath } : {},
650
- ...input.retrySetupOnFailure ? { retryOnFailure: true } : {}
1043
+ if (copyGlobs.length > 0) {
1044
+ phase = "copy";
1045
+ report({ kind: "phase", phase });
1046
+ for (const pattern of copyGlobs) {
1047
+ const matches = await expandGlob(input.repoRoot, pattern);
1048
+ for (const rel of matches) {
1049
+ if (signal?.aborted) throw new ProvisionCancelledError();
1050
+ const dest = join(cwd, rel);
1051
+ await mkdir(dirname(dest), { recursive: true });
1052
+ await copyFile(join(input.repoRoot, rel), dest);
651
1053
  }
652
- );
653
- } catch (err) {
654
- if (err instanceof HookError) {
655
- throw new ToolError({ code: "execution_failed", message: err.message });
656
1054
  }
657
- throw err;
658
1055
  }
1056
+ if (config && hasSetup) {
1057
+ phase = "setup";
1058
+ report({ kind: "phase", phase });
1059
+ try {
1060
+ await runSetup(
1061
+ config,
1062
+ {
1063
+ sourceCheckoutPath: input.repoRoot,
1064
+ worktreePath: cwd,
1065
+ branchName: branch
1066
+ },
1067
+ {
1068
+ ...input.setupLogPath ? { logPath: input.setupLogPath } : {},
1069
+ ...input.retrySetupOnFailure ? { retryOnFailure: true } : {},
1070
+ ...signal ? { signal } : {}
1071
+ }
1072
+ );
1073
+ } catch (err) {
1074
+ if (err instanceof HookError) {
1075
+ throw new ToolError({ code: "execution_failed", message: err.message });
1076
+ }
1077
+ throw err;
1078
+ }
1079
+ }
1080
+ } catch (err) {
1081
+ const cancelled = err instanceof ProvisionCancelledError;
1082
+ report({ kind: "done", outcome: cancelled ? "cancelled" : "failed" });
1083
+ if (cancelled) await discardCancelledWorktree(input.repoRoot, cwd, branch);
1084
+ throw err;
1085
+ } finally {
1086
+ lease?.release();
659
1087
  }
1088
+ report({ kind: "done", outcome: "ok" });
660
1089
  return { cwd, branch };
661
1090
  }
662
1091
  );
663
- var WORKTREE_TRASH_DIRNAME = ".trash";
664
- function realOrResolved(path) {
665
- try {
666
- return realpathSync(path);
667
- } catch {
668
- return path;
669
- }
670
- }
671
- async function removeWorktreeFast(repoRoot, path, options = {}) {
672
- const force = options.force === true;
673
- if (!force) {
674
- const status = await execArgv(
675
- "git",
676
- ["-C", path, "status", "--porcelain=v2", "--ignored=matching"],
677
- repoRoot
678
- );
679
- if (status.exitCode !== 0) {
680
- throw new Error(
681
- `git status failed in ${path} (exit ${status.exitCode}): ${status.stderr.trim() || status.stdout.trim()}`
682
- );
683
- }
684
- if (status.stdout.trim().length > 0) {
685
- const probe = await execArgv("git", ["-C", repoRoot, "worktree", "remove", path], repoRoot);
686
- if (probe.exitCode !== 0) {
687
- throw new Error(
688
- `git worktree remove failed (exit ${probe.exitCode}): ${probe.stderr.trim() || probe.stdout.trim()}`
689
- );
690
- }
691
- await execGit(repoRoot, ["worktree", "prune"]);
692
- return;
693
- }
694
- }
695
- const list = await execArgv("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], repoRoot);
696
- if (list.exitCode !== 0) {
697
- throw new Error(
698
- `git worktree list failed (exit ${list.exitCode}): ${list.stderr.trim() || list.stdout.trim()}`
699
- );
700
- }
701
- const registered = new Set(
702
- list.stdout.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length).trim())
703
- );
704
- const registeredReal = new Set([...registered].map(realOrResolved));
705
- if (!registeredReal.has(realOrResolved(path))) {
706
- throw new Error(`not a linked worktree of ${repoRoot}: ${path}`);
707
- }
708
- const poolDir = join(path, "..");
709
- const stamp = (options.now ?? Date.now)();
710
- const trashDir = join(poolDir, WORKTREE_TRASH_DIRNAME, `${basename(path)}-${stamp}`);
711
- try {
712
- if (!existsSync(join(poolDir, WORKTREE_TRASH_DIRNAME))) {
713
- mkdirSync(join(poolDir, WORKTREE_TRASH_DIRNAME));
714
- }
715
- await rename(path, trashDir);
716
- } catch {
717
- await execGit(repoRoot, force ? ["worktree", "remove", "--force", path] : ["worktree", "remove", path]);
718
- return;
719
- }
720
- await execGit(repoRoot, ["worktree", "prune"]);
721
- if (options.spawnRemoval) {
722
- options.spawnRemoval(trashDir);
723
- } else {
724
- spawn("rm", ["-rf", trashDir], { detached: true, stdio: "ignore" }).unref();
725
- }
726
- }
727
- function sweepWorktreeTrash(root, options = {}) {
728
- let names;
729
- try {
730
- names = readdirSync(join(root, WORKTREE_TRASH_DIRNAME));
731
- } catch {
732
- return;
733
- }
734
- for (const name of names) {
735
- const trashDir = join(root, WORKTREE_TRASH_DIRNAME, name);
736
- if (options.spawnRemoval) {
737
- options.spawnRemoval(trashDir);
738
- } else {
739
- spawn("rm", ["-rf", trashDir], { detached: true, stdio: "ignore" }).unref();
740
- }
741
- }
742
- }
743
1092
  function isPortFree(port) {
744
- return new Promise((resolve6) => {
1093
+ return new Promise((resolve7) => {
745
1094
  const server = createServer();
746
- server.once("error", () => resolve6(false));
1095
+ server.once("error", () => resolve7(false));
747
1096
  server.once("listening", () => {
748
- server.close(() => resolve6(true));
1097
+ server.close(() => resolve7(true));
749
1098
  });
750
1099
  server.listen(port, "127.0.0.1");
751
1100
  });
752
1101
  }
753
1102
  function ephemeralPort() {
754
- return new Promise((resolve6, reject) => {
1103
+ return new Promise((resolve7, reject) => {
755
1104
  const server = createServer();
756
1105
  server.once("error", reject);
757
1106
  server.listen(0, "127.0.0.1", () => {
758
1107
  const address = server.address();
759
1108
  if (address && typeof address === "object") {
760
1109
  const port = address.port;
761
- server.close(() => resolve6(port));
1110
+ server.close(() => resolve7(port));
762
1111
  } else {
763
1112
  server.close(() => reject(new Error("could not determine an ephemeral port")));
764
1113
  }
@@ -893,10 +1242,10 @@ var ServiceSupervisor = class _ServiceSupervisor {
893
1242
  this.opts.proxyTable?.delete(t.runtime.hostname);
894
1243
  const child = t.child;
895
1244
  if (!child || t.status !== "running") return false;
896
- return new Promise((resolve6) => {
1245
+ return new Promise((resolve7) => {
897
1246
  const done = () => {
898
1247
  t.status = "exited";
899
- resolve6(true);
1248
+ resolve7(true);
900
1249
  };
901
1250
  child.once("exit", done);
902
1251
  child.kill("SIGTERM");
@@ -1561,6 +1910,6 @@ var worktreeProvider = defineDriver({
1561
1910
  ]
1562
1911
  });
1563
1912
 
1564
- export { CONFIG_FILENAME, ConfigError, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, GlobTraversalError, HookError, InMemoryVerdictMemoStore, LOCAL_WORKTREE_CONFIG_REL, ProxyTable, SESSIONS_BUCKETS_ROOT, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WORKTREES_TURBO_CACHE_DIR_ENV, WORKTREE_TRASH_DIRNAME, WorktreeNotRemovableError, allocatePort, applySlugPlaceholder, classify, cloneEntries, computeBaseDivergence, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandCloneGlob, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, loadLocalWorktreeConfig, normalizeHook, parseConfig, parseLocalWorktreeConfig, peerEnv, peerEnvKey, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, removeWorktreeFast, repoLabel, resolveLocalWriteFiles, resolveSupervisor, resolveWorktreesTurboCacheDir, runSetup, runTeardown, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, sweepWorktreeTrash, worktreeProvider, writeWorktreeMarker };
1565
- //# sourceMappingURL=chunk-MYH5XOX6.mjs.map
1566
- //# sourceMappingURL=chunk-MYH5XOX6.mjs.map
1913
+ export { CONFIG_FILENAME, ConfigError, DEFAULT_PROVISION_CONCURRENCY, DEFAULT_PROVISION_LIMITS, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, GlobTraversalError, HookError, InMemoryVerdictMemoStore, LOCAL_WORKTREE_CONFIG_REL, PROVISION_CONCURRENCY_ENV, ProvisionCancelledError, ProvisionScheduler, ProxyTable, SESSIONS_BUCKETS_ROOT, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WORKTREES_TURBO_CACHE_DIR_ENV, WORKTREE_TRASH_DIRNAME, WorktreeNotRemovableError, allocatePort, applySlugPlaceholder, classify, cloneEntries, computeBaseDivergence, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, currentProvisionContext, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandCloneGlob, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, loadLocalWorktreeConfig, normalizeHook, parseConfig, parseLocalWorktreeConfig, parseProvisionLimits, peerEnv, peerEnvKey, provisionScheduler, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, removeWorktreeFast, repoLabel, resolveLocalWriteFiles, resolveSupervisor, resolveWorktreesTurboCacheDir, runSetup, runTeardown, runWithProvisionContext, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, sweepWorktreeTrash, worktreeProvider, writeWorktreeMarker };
1914
+ //# sourceMappingURL=chunk-IEW2O3PF.mjs.map
1915
+ //# sourceMappingURL=chunk-IEW2O3PF.mjs.map