praxis-agent 0.60.1 → 0.62.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.
Files changed (31) hide show
  1. package/README.md +21 -4
  2. package/dist/application/agent-worktree-owner.d.ts +12 -0
  3. package/dist/application/agent-worktree-owner.js +35 -0
  4. package/dist/application/agent-worktree.d.ts +19 -0
  5. package/dist/application/agent-worktree.js +96 -0
  6. package/dist/application/managed-worktree-hooks.d.ts +11 -0
  7. package/dist/application/managed-worktree-hooks.js +23 -0
  8. package/dist/application/managed-worktree.d.ts +88 -0
  9. package/dist/application/managed-worktree.js +1129 -106
  10. package/dist/application/subagent-service.d.ts +3 -1
  11. package/dist/application/subagent-service.js +117 -23
  12. package/dist/application/team-capability.d.ts +4 -0
  13. package/dist/application/team-capability.js +20 -0
  14. package/dist/application/team-manager.d.ts +2 -0
  15. package/dist/application/team-manager.js +207 -52
  16. package/dist/application/team-observability.d.ts +5 -0
  17. package/dist/application/team-observability.js +152 -32
  18. package/dist/application/team-workspace.d.ts +18 -1
  19. package/dist/application/team-workspace.js +217 -71
  20. package/dist/application/workflow-worktree.d.ts +3 -0
  21. package/dist/application/workflow-worktree.js +4 -0
  22. package/dist/cli/tui/doctor-dashboard.js +1 -0
  23. package/dist/cli-runtime.js +5 -0
  24. package/dist/hooks/claude-hooks.js +16 -2
  25. package/dist/maintenance/doctor.d.ts +1 -1
  26. package/dist/maintenance/doctor.js +55 -0
  27. package/dist/persistence/managed-worktree-store.d.ts +16 -1
  28. package/dist/persistence/managed-worktree-store.js +61 -21
  29. package/dist/platform/exclusive-file-lease.d.ts +6 -1
  30. package/dist/platform/exclusive-file-lease.js +48 -7
  31. package/package.json +1 -1
@@ -5,11 +5,18 @@ import { lstat, mkdir, open, realpath } from 'node:fs/promises';
5
5
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { setTimeout as sleep } from 'node:timers/promises';
7
7
  import { promisify } from 'node:util';
8
- import { ManagedWorktreeStore, } from '../persistence/managed-worktree-store.js';
8
+ import { ManagedWorktreeStore, inspectManagedWorktreeRegistry, } from '../persistence/managed-worktree-store.js';
9
9
  import { resolveProjectIdentity } from '../platform/project-identity.js';
10
+ import { sanitizeProjectPath } from '../platform/project-path-key.js';
10
11
  import { writeFileAtomically } from '../platform/atomic-write.js';
11
12
  import { ExclusiveFileLease, } from '../platform/exclusive-file-lease.js';
13
+ import { isTerminalLifecycleState } from '../core/agent-orchestration.js';
14
+ import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.js';
15
+ import { parseAgentWorktreeOwner } from './agent-worktree-owner.js';
16
+ import { isWorkflowAgentId, isWorkflowRunId } from '../native/workflow.js';
17
+ import { parseTeamId } from '../core/team-ownership.js';
12
18
  const execFileAsync = promisify(execFile);
19
+ const reconciliationCache = new Map();
13
20
  async function gitRaw(cwd, args) {
14
21
  return (await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' })).stdout;
15
22
  }
@@ -26,6 +33,52 @@ async function gitOptional(cwd, args) {
26
33
  throw error;
27
34
  }
28
35
  }
36
+ async function ownedBranchRef(repositoryRoot, branch, baseCommit) {
37
+ try {
38
+ const checked = await git(repositoryRoot, [
39
+ 'check-ref-format',
40
+ '--branch',
41
+ branch,
42
+ ]);
43
+ if (checked !== branch)
44
+ return 'ambiguous';
45
+ }
46
+ catch {
47
+ return 'ambiguous';
48
+ }
49
+ try {
50
+ const value = await git(repositoryRoot, [
51
+ 'rev-parse',
52
+ '--verify',
53
+ '--quiet',
54
+ `refs/heads/${branch}`,
55
+ ]);
56
+ return value === baseCommit ? 'match' : 'ambiguous';
57
+ }
58
+ catch (error) {
59
+ return Number(error.code) === 1
60
+ ? 'absent'
61
+ : 'ambiguous';
62
+ }
63
+ }
64
+ async function removeOwnedBranch(repositoryRoot, branch, baseCommit) {
65
+ const ref = await ownedBranchRef(repositoryRoot, branch, baseCommit);
66
+ if (ref === 'absent')
67
+ return;
68
+ if (ref === 'ambiguous')
69
+ throw new Error('owned branch moved or is ambiguous');
70
+ try {
71
+ await git(repositoryRoot, [
72
+ 'update-ref',
73
+ '-d',
74
+ `refs/heads/${branch}`,
75
+ baseCommit,
76
+ ]);
77
+ }
78
+ catch (error) {
79
+ throw new Error(`could not safely remove owned branch: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ }
29
82
  async function registeredWorktrees(root) {
30
83
  const output = await gitRaw(root, ['worktree', 'list', '--porcelain', '-z']);
31
84
  return new Set(output
@@ -49,6 +102,19 @@ function validOwnerId(ownerId) {
49
102
  return code <= 0x1f || code === 0x7f;
50
103
  }));
51
104
  }
105
+ function validRetentionReason(reason) {
106
+ return (typeof reason === 'string' &&
107
+ reason.trim().length > 0 &&
108
+ reason.length <= 1024 &&
109
+ !Array.from(reason).some((character) => {
110
+ const code = character.codePointAt(0) ?? 0;
111
+ return code <= 0x1f || code === 0x7f;
112
+ }));
113
+ }
114
+ function assertRetentionReason(reason) {
115
+ if (!validRetentionReason(reason))
116
+ throw new Error('Managed worktree retention reason is invalid');
117
+ }
52
118
  function worktreeError(options, message) {
53
119
  return new Error(`${options.label} worktree ${message}`);
54
120
  }
@@ -186,6 +252,13 @@ async function writeMarker(path, record) {
186
252
  throw new Error('Managed worktree marker already exists');
187
253
  }
188
254
  async function readMarker(worktreePath, record) {
255
+ const marker = await readMarkerIdentity(worktreePath);
256
+ if (marker.worktreeId !== record.worktreeId ||
257
+ marker.repositoryRoot !== record.repositoryRoot) {
258
+ throw new Error('worktree marker does not match ownership record');
259
+ }
260
+ }
261
+ async function readMarkerIdentity(worktreePath) {
189
262
  const markerPath = join(await linkedGitDirectory(worktreePath), 'PRAXIS_WORKTREE');
190
263
  let marker;
191
264
  try {
@@ -200,10 +273,20 @@ async function readMarker(worktreePath, record) {
200
273
  Object.keys(marker).length !== MARKER_FIELDS.size ||
201
274
  Object.keys(marker).some((key) => !MARKER_FIELDS.has(key)) ||
202
275
  marker.version !== 1 ||
203
- marker.worktreeId !== record.worktreeId ||
204
- marker.repositoryRoot !== record.repositoryRoot) {
205
- throw new Error('worktree marker does not match ownership record');
276
+ typeof marker.worktreeId !== 'string' ||
277
+ typeof marker.repositoryRoot !== 'string') {
278
+ throw new Error('worktree marker is invalid');
279
+ }
280
+ const fields = marker;
281
+ if (!/^[a-f0-9]{32,64}$/u.test(String(fields.worktreeId)) ||
282
+ !isAbsolute(String(fields.repositoryRoot)) ||
283
+ resolve(String(fields.repositoryRoot)) !== String(fields.repositoryRoot)) {
284
+ throw new Error('worktree marker is invalid');
206
285
  }
286
+ return {
287
+ worktreeId: fields.worktreeId,
288
+ repositoryRoot: fields.repositoryRoot,
289
+ };
207
290
  }
208
291
  async function inspectOwnedCheckout(record, registered) {
209
292
  const registrations = registered ?? (await registeredWorktrees(record.repositoryRoot));
@@ -238,8 +321,13 @@ async function inspectOwnedCheckout(record, registered) {
238
321
  '--short',
239
322
  'HEAD',
240
323
  ]);
241
- if (branch)
242
- throw new Error('worktree is not detached');
324
+ if (record.branch === null) {
325
+ if (branch)
326
+ throw new Error('worktree is not detached');
327
+ }
328
+ else if (branch !== record.branch) {
329
+ throw new Error('worktree branch does not match ownership record');
330
+ }
243
331
  return { status, head };
244
332
  }
245
333
  function sameOwnership(left, right) {
@@ -275,7 +363,650 @@ async function retain(store, record, reason) {
275
363
  };
276
364
  }
277
365
  }
366
+ async function reconciliationRetain(store, record, base, reason) {
367
+ const result = await retain(store, record, reason);
368
+ return {
369
+ ...base,
370
+ disposition: 'retained',
371
+ reason: result.reason ?? reason,
372
+ };
373
+ }
374
+ async function reconcileAgentLifecycle(stateRoot, record) {
375
+ const owner = parseAgentWorktreeOwner(record.ownerId);
376
+ if (!owner)
377
+ return { safe: false, reason: 'Agent owner ID is malformed' };
378
+ try {
379
+ const lifecycleStore = new SubagentLifecycleStore(stateRoot, owner.sessionId, owner.agentId);
380
+ const ownership = await lifecycleStore.reconcileOwnerLoss();
381
+ if (ownership.owned)
382
+ return { safe: false, reason: 'Agent lifecycle owner is live' };
383
+ const lifecycle = await lifecycleStore.read();
384
+ const classification = classifyAgentLifecycle(lifecycle, owner);
385
+ return classification.status === 'safely-releasable'
386
+ ? { safe: true }
387
+ : { safe: false, reason: classification.reason };
388
+ }
389
+ catch (error) {
390
+ return {
391
+ safe: false,
392
+ reason: `Agent lifecycle state is unavailable: ${error instanceof Error ? error.message : String(error)}`,
393
+ };
394
+ }
395
+ }
396
+ function classifyAgentLifecycle(lifecycle, owner) {
397
+ if (!lifecycle)
398
+ return { status: 'unsafe', reason: 'Agent lifecycle state is missing' };
399
+ const snapshot = lifecycle.lifecycle;
400
+ const matchingToken = isTerminalLifecycleState(snapshot.state)
401
+ ? snapshot.previousOwnerToken === owner.executionToken
402
+ : snapshot.owner?.token === owner.executionToken;
403
+ if (!matchingToken)
404
+ return {
405
+ status: 'unsafe',
406
+ reason: 'Agent lifecycle owner token does not match worktree owner',
407
+ };
408
+ if (snapshot.state === 'failed' || snapshot.state === 'cancelled')
409
+ return {
410
+ status: 'retained',
411
+ reason: `Agent lifecycle is ${snapshot.state}; evidence was retained`,
412
+ };
413
+ if (snapshot.state === 'orphaned')
414
+ return {
415
+ status: 'retained',
416
+ reason: 'Agent lifecycle is orphaned; evidence was retained',
417
+ };
418
+ if (!isTerminalLifecycleState(snapshot.state))
419
+ return {
420
+ status: 'unsafe',
421
+ reason: `Agent lifecycle remains ${snapshot.state}; owner state is unavailable`,
422
+ };
423
+ if (snapshot.state !== 'completed')
424
+ return { status: 'unsafe', reason: `Agent lifecycle is ${snapshot.state}` };
425
+ return {
426
+ status: 'safely-releasable',
427
+ reason: 'ownership and Git state are clean',
428
+ };
429
+ }
430
+ async function inspectAgentLifecycle(stateRoot, record) {
431
+ const owner = parseAgentWorktreeOwner(record.ownerId);
432
+ if (!owner)
433
+ return { status: 'unsafe', reason: 'Agent owner ID is malformed' };
434
+ let lifecycle;
435
+ try {
436
+ lifecycle = await new SubagentLifecycleStore(stateRoot, owner.sessionId, owner.agentId).read();
437
+ }
438
+ catch {
439
+ return {
440
+ status: 'unsafe',
441
+ reason: 'Agent lifecycle state is unavailable',
442
+ };
443
+ }
444
+ return classifyAgentLifecycle(lifecycle, owner);
445
+ }
446
+ function healthEntry(recordPath, record, present, status, reason) {
447
+ return {
448
+ recordPath,
449
+ worktreeId: record.worktreeId,
450
+ kind: record.kind,
451
+ policy: record.policy,
452
+ recordState: record.state,
453
+ worktreePath: record.worktreePath,
454
+ branch: record.branch,
455
+ present,
456
+ status,
457
+ reason: boundedHealthReason(reason),
458
+ };
459
+ }
460
+ const HEALTH_STABLE_REASONS = new Set([
461
+ 'managed worktree path must not be a symlink',
462
+ 'managed worktree path must be a directory',
463
+ 'worktree path is not a real directory',
464
+ 'worktree path is not canonical',
465
+ 'worktree is not registered',
466
+ 'registered worktree root does not match',
467
+ 'repository identity does not match',
468
+ 'worktree marker does not match ownership record',
469
+ 'worktree marker is invalid',
470
+ 'worktree is not detached',
471
+ 'worktree branch does not match ownership record',
472
+ ]);
473
+ function boundedHealthReason(reason) {
474
+ const codePoints = Array.from(reason);
475
+ return codePoints.length <= 256 ? reason : codePoints.slice(0, 256).join('');
476
+ }
477
+ function healthErrorReason(error, fallback) {
478
+ const message = error instanceof Error ? error.message : '';
479
+ return HEALTH_STABLE_REASONS.has(message) ? message : fallback;
480
+ }
481
+ function validHealthOwner(record) {
482
+ const parts = record.ownerId.split(':');
483
+ if (record.kind === 'workflow')
484
+ return (parts.length === 3 &&
485
+ parts[0] === 'workflow' &&
486
+ isWorkflowRunId(parts[1] ?? '') &&
487
+ isWorkflowAgentId(parts[2] ?? ''));
488
+ if (record.kind === 'agent')
489
+ return parseAgentWorktreeOwner(record.ownerId) !== null;
490
+ if (parts.length !== 5 || parts[0] !== 'team')
491
+ return false;
492
+ try {
493
+ parseTeamId(parts[1]);
494
+ }
495
+ catch {
496
+ return false;
497
+ }
498
+ return (/^[1-9]\d*$/u.test(parts[2] ?? '') &&
499
+ Number.isSafeInteger(Number(parts[2])) &&
500
+ Number(parts[2]) > 0 &&
501
+ /^[a-f0-9]{24}$/u.test(parts[3] ?? '') &&
502
+ /^[A-Za-z0-9_-]{1,128}$/u.test(parts[4] ?? ''));
503
+ }
504
+ async function preflightHealthPath(record) {
505
+ let entry;
506
+ try {
507
+ entry = await lstat(record.worktreePath);
508
+ }
509
+ catch (error) {
510
+ if (error.code !== 'ENOENT')
511
+ throw error;
512
+ await assertRealDirectory(record.worktreePath, 'managed worktree path');
513
+ return false;
514
+ }
515
+ if (entry.isSymbolicLink() || !entry.isDirectory())
516
+ throw new Error('worktree path is not a real directory');
517
+ await assertRealDirectory(record.worktreePath, 'managed worktree path');
518
+ if ((await realpath(record.worktreePath)) !== resolve(record.worktreePath))
519
+ throw new Error('worktree path is not canonical');
520
+ return true;
521
+ }
522
+ function classifyCleanHealthRecord(record, absent, agentLifecycle) {
523
+ if (record.state === 'retained')
524
+ return {
525
+ status: 'retained',
526
+ reason: record.retentionReason ?? 'record is retained',
527
+ };
528
+ if (record.policy === 'durable' || record.kind === 'team')
529
+ return {
530
+ status: 'retained',
531
+ reason: record.policy === 'durable'
532
+ ? 'durable retention policy'
533
+ : 'team worktree is retained',
534
+ };
535
+ if (record.kind === 'agent')
536
+ return (agentLifecycle ?? {
537
+ status: 'unsafe',
538
+ reason: 'Agent lifecycle state is missing',
539
+ });
540
+ return {
541
+ status: 'safely-releasable',
542
+ reason: absent
543
+ ? 'checkout is absent and remaining ownership evidence is releasable'
544
+ : 'ownership and Git state are clean',
545
+ };
546
+ }
547
+ export async function inspectManagedWorktreeHealth(options) {
548
+ const limit = options.limit ?? 64;
549
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64)
550
+ throw new Error('Managed worktree health limit must be an integer from 1 to 64');
551
+ const repositoryRoot = await resolveProjectIdentity(options.cwd);
552
+ const registryDirectory = resolve(options.stateRoot, 'managed-worktrees', sanitizeProjectPath(repositoryRoot));
553
+ const stateRoot = resolve(options.stateRoot);
554
+ const managedRoot = resolve(stateRoot, 'managed-worktrees');
555
+ for (const [path, description] of [
556
+ [stateRoot, 'managed worktree state root'],
557
+ [managedRoot, 'managed worktree registry root'],
558
+ ]) {
559
+ try {
560
+ const info = await lstat(path);
561
+ if (info.isSymbolicLink())
562
+ throw new Error(`${description} must not be a symlink`);
563
+ if (!info.isDirectory())
564
+ throw new Error(`${description} must be a directory`);
565
+ }
566
+ catch (error) {
567
+ if (error.code !== 'ENOENT')
568
+ throw error;
569
+ }
570
+ }
571
+ try {
572
+ const info = await lstat(registryDirectory);
573
+ if (info.isSymbolicLink() || !info.isDirectory())
574
+ throw new Error('managed worktree registry must be a real directory');
575
+ }
576
+ catch (error) {
577
+ if (error.code !== 'ENOENT')
578
+ throw error;
579
+ return {
580
+ repositoryRoot,
581
+ inspected: 0,
582
+ truncated: false,
583
+ counts: {
584
+ active: 0,
585
+ retained: 0,
586
+ safelyReleasable: 0,
587
+ released: 0,
588
+ unsafe: 0,
589
+ },
590
+ entries: [],
591
+ };
592
+ }
593
+ const snapshot = await inspectManagedWorktreeRegistry({
594
+ stateRoot: options.stateRoot,
595
+ repositoryRoot,
596
+ limit,
597
+ });
598
+ const entries = [];
599
+ for (const item of snapshot.entries) {
600
+ if ('error' in item) {
601
+ entries.push({
602
+ recordPath: item.path,
603
+ worktreeId: null,
604
+ kind: null,
605
+ policy: null,
606
+ recordState: null,
607
+ worktreePath: null,
608
+ branch: null,
609
+ present: null,
610
+ status: 'unsafe',
611
+ reason: 'managed worktree record is invalid',
612
+ });
613
+ continue;
614
+ }
615
+ const original = item.record;
616
+ if (!validHealthOwner(original)) {
617
+ entries.push(healthEntry(item.path, original, null, 'unsafe', `${original.kind[0]?.toUpperCase() ?? ''}${original.kind.slice(1)} owner ID is malformed`));
618
+ continue;
619
+ }
620
+ let preflightPresent;
621
+ try {
622
+ preflightPresent = await preflightHealthPath(original);
623
+ }
624
+ catch (error) {
625
+ entries.push(healthEntry(item.path, original, true, 'unsafe', healthErrorReason(error, 'worktree path could not be inspected safely')));
626
+ continue;
627
+ }
628
+ const base = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, original.worktreeId);
629
+ const lease = await base.acquireLease();
630
+ if (!lease) {
631
+ const unavailableStatus = original.state === 'released'
632
+ ? 'unsafe'
633
+ : ['creating', 'active', 'releasing'].includes(original.state)
634
+ ? 'active'
635
+ : 'retained';
636
+ entries.push(healthEntry(item.path, original, preflightPresent, unavailableStatus, original.state === 'released'
637
+ ? 'released record has a live or unavailable lease'
638
+ : unavailableStatus === 'active'
639
+ ? 'worktree lease is live or unavailable'
640
+ : (original.retentionReason ?? 'record is retained')));
641
+ continue;
642
+ }
643
+ try {
644
+ let record;
645
+ try {
646
+ record = await base.read();
647
+ }
648
+ catch {
649
+ entries.push(healthEntry(item.path, original, null, 'unsafe', 'managed worktree record could not be reread safely'));
650
+ continue;
651
+ }
652
+ if (!sameOwnership(record, original)) {
653
+ entries.push(healthEntry(item.path, record, null, 'unsafe', 'ownership record changed during inspection'));
654
+ continue;
655
+ }
656
+ const registered = await registeredWorktrees(record.repositoryRoot).catch(() => null);
657
+ if (!registered) {
658
+ entries.push(healthEntry(item.path, record, null, 'unsafe', 'could not inspect registered worktrees'));
659
+ continue;
660
+ }
661
+ let present = false;
662
+ try {
663
+ const info = await lstat(record.worktreePath);
664
+ present = true;
665
+ if (info.isSymbolicLink() || !info.isDirectory())
666
+ throw new Error('worktree path is not a real directory');
667
+ }
668
+ catch (error) {
669
+ if (error.code !== 'ENOENT') {
670
+ entries.push(healthEntry(item.path, record, true, 'unsafe', healthErrorReason(error, 'worktree ownership and Git state could not be validated')));
671
+ continue;
672
+ }
673
+ }
674
+ const registeredPath = registered.has(resolve(record.worktreePath));
675
+ if (record.state === 'released') {
676
+ let branchEvidence = 'absent';
677
+ if (record.branch !== null)
678
+ branchEvidence = await ownedBranchRef(record.repositoryRoot, record.branch, record.baseCommit);
679
+ if (present || registeredPath || branchEvidence !== 'absent') {
680
+ entries.push(healthEntry(item.path, record, present, 'unsafe', 'released record has contradictory checkout or branch evidence'));
681
+ }
682
+ else {
683
+ entries.push(healthEntry(item.path, record, false, 'released', 'record is released'));
684
+ }
685
+ continue;
686
+ }
687
+ if (!present) {
688
+ if (registeredPath) {
689
+ entries.push(healthEntry(item.path, record, false, 'unsafe', 'worktree is missing but remains registered'));
690
+ continue;
691
+ }
692
+ if (record.branch !== null) {
693
+ const branchEvidence = await ownedBranchRef(record.repositoryRoot, record.branch, record.baseCommit);
694
+ if (branchEvidence === 'ambiguous') {
695
+ entries.push(healthEntry(item.path, record, false, 'unsafe', 'owned branch is missing or moved'));
696
+ continue;
697
+ }
698
+ }
699
+ const agentLifecycle = record.kind === 'agent'
700
+ ? await inspectAgentLifecycle(options.stateRoot, record)
701
+ : null;
702
+ if (agentLifecycle?.status === 'unsafe') {
703
+ entries.push(healthEntry(item.path, record, false, 'unsafe', agentLifecycle.reason));
704
+ continue;
705
+ }
706
+ const clean = classifyCleanHealthRecord(record, true, agentLifecycle);
707
+ entries.push(healthEntry(item.path, record, false, clean.status, clean.reason));
708
+ continue;
709
+ }
710
+ let inspection;
711
+ try {
712
+ inspection = await inspectOwnedCheckout(record, registered);
713
+ }
714
+ catch (error) {
715
+ entries.push(healthEntry(item.path, record, true, 'unsafe', healthErrorReason(error, 'worktree ownership and Git state could not be validated')));
716
+ continue;
717
+ }
718
+ const agentLifecycle = record.kind === 'agent'
719
+ ? await inspectAgentLifecycle(options.stateRoot, record)
720
+ : null;
721
+ if (agentLifecycle?.status === 'unsafe') {
722
+ entries.push(healthEntry(item.path, record, true, 'unsafe', agentLifecycle.reason));
723
+ continue;
724
+ }
725
+ if (agentLifecycle?.status === 'retained') {
726
+ entries.push(healthEntry(item.path, record, true, 'retained', agentLifecycle.reason));
727
+ continue;
728
+ }
729
+ if (inspection.status) {
730
+ entries.push(healthEntry(item.path, record, true, 'retained', 'worktree has uncommitted changes'));
731
+ continue;
732
+ }
733
+ if (inspection.head !== record.baseCommit) {
734
+ entries.push(healthEntry(item.path, record, true, 'retained', 'worktree HEAD does not match base commit'));
735
+ continue;
736
+ }
737
+ const clean = classifyCleanHealthRecord(record, false, agentLifecycle);
738
+ entries.push(healthEntry(item.path, record, true, clean.status, clean.reason));
739
+ }
740
+ finally {
741
+ await lease.release();
742
+ }
743
+ }
744
+ const counts = {
745
+ active: entries.filter((entry) => entry.status === 'active').length,
746
+ retained: entries.filter((entry) => entry.status === 'retained').length,
747
+ safelyReleasable: entries.filter((entry) => entry.status === 'safely-releasable').length,
748
+ released: entries.filter((entry) => entry.status === 'released').length,
749
+ unsafe: entries.filter((entry) => entry.status === 'unsafe').length,
750
+ };
751
+ return {
752
+ repositoryRoot,
753
+ inspected: snapshot.entries.length,
754
+ truncated: snapshot.truncated,
755
+ counts,
756
+ entries,
757
+ };
758
+ }
759
+ export async function reconcileManagedWorktrees(options) {
760
+ const repositoryRoot = await resolveProjectIdentity(options.cwd);
761
+ const snapshot = await inspectManagedWorktreeRegistry({
762
+ stateRoot: options.stateRoot,
763
+ repositoryRoot,
764
+ limit: 64,
765
+ });
766
+ const entries = [];
767
+ for (const item of snapshot.entries) {
768
+ if ('error' in item) {
769
+ entries.push({
770
+ recordPath: item.path,
771
+ disposition: 'invalid',
772
+ reason: item.error,
773
+ });
774
+ continue;
775
+ }
776
+ const original = item.record;
777
+ const baseEntry = { recordPath: item.path, worktreeId: original.worktreeId };
778
+ const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, original.worktreeId);
779
+ const lease = await store.acquireLease();
780
+ if (!lease) {
781
+ entries.push({
782
+ ...baseEntry,
783
+ disposition: 'skipped',
784
+ reason: 'worktree lease is live or unavailable',
785
+ });
786
+ continue;
787
+ }
788
+ try {
789
+ let record;
790
+ try {
791
+ record = await store.read();
792
+ }
793
+ catch (error) {
794
+ entries.push({
795
+ ...baseEntry,
796
+ disposition: 'invalid',
797
+ reason: `record could not be reread: ${error instanceof Error ? error.message : String(error)}`,
798
+ });
799
+ continue;
800
+ }
801
+ if (!sameOwnership(record, original)) {
802
+ entries.push({
803
+ ...baseEntry,
804
+ disposition: 'retained',
805
+ reason: 'ownership record changed during reconciliation',
806
+ });
807
+ continue;
808
+ }
809
+ if (record.state === 'released') {
810
+ entries.push({
811
+ ...baseEntry,
812
+ disposition: 'skipped',
813
+ reason: 'already released',
814
+ });
815
+ continue;
816
+ }
817
+ if (record.state === 'retained') {
818
+ entries.push({
819
+ ...baseEntry,
820
+ disposition: 'retained',
821
+ reason: record.retentionReason ?? 'record is retained',
822
+ });
823
+ continue;
824
+ }
825
+ if (record.policy === 'durable' || record.kind === 'team') {
826
+ entries.push(await reconciliationRetain(store, record, baseEntry, record.policy === 'durable'
827
+ ? 'durable retention policy'
828
+ : 'team worktree is retained'));
829
+ continue;
830
+ }
831
+ if (!['workflow', 'agent'].includes(record.kind) ||
832
+ !record.ownerId.startsWith(`${record.kind}:`) ||
833
+ !['creating', 'active', 'releasing'].includes(record.state)) {
834
+ entries.push(await reconciliationRetain(store, record, baseEntry, 'record is not an interrupted ephemeral owner'));
835
+ continue;
836
+ }
837
+ if (record.kind === 'agent') {
838
+ const lifecycle = await reconcileAgentLifecycle(options.stateRoot, record);
839
+ if (!lifecycle.safe) {
840
+ entries.push(await reconciliationRetain(store, record, baseEntry, lifecycle.reason));
841
+ continue;
842
+ }
843
+ }
844
+ const releasing = record.state === 'creating' ? record : nextRecord(record, 'releasing');
845
+ if (record.state !== 'creating') {
846
+ try {
847
+ await store.update(releasing);
848
+ }
849
+ catch (error) {
850
+ entries.push({
851
+ ...baseEntry,
852
+ disposition: 'retained',
853
+ reason: `could not mark releasing: ${error instanceof Error ? error.message : String(error)}`,
854
+ });
855
+ continue;
856
+ }
857
+ }
858
+ const registered = await registeredWorktrees(repositoryRoot).catch(() => null);
859
+ if (!registered) {
860
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, 'could not inspect registered worktrees'));
861
+ continue;
862
+ }
863
+ let exists = false;
864
+ try {
865
+ const info = await lstat(record.worktreePath);
866
+ exists = true;
867
+ if (info.isSymbolicLink())
868
+ throw new Error('worktree path is a symlink');
869
+ }
870
+ catch (error) {
871
+ if (error.code !== 'ENOENT') {
872
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, error.message));
873
+ continue;
874
+ }
875
+ }
876
+ if (!exists) {
877
+ if (registered.has(resolve(record.worktreePath))) {
878
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, 'worktree is missing but remains registered'));
879
+ continue;
880
+ }
881
+ if (record.branch !== null) {
882
+ const ref = await ownedBranchRef(repositoryRoot, record.branch, record.baseCommit);
883
+ if (ref === 'ambiguous') {
884
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, 'owned branch is missing or moved'));
885
+ continue;
886
+ }
887
+ try {
888
+ if (ref === 'match')
889
+ await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
890
+ }
891
+ catch (error) {
892
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, `could not remove owned branch: ${error.message}`));
893
+ continue;
894
+ }
895
+ }
896
+ try {
897
+ await store.update(nextRecord(releasing, 'released'));
898
+ }
899
+ catch (error) {
900
+ entries.push({
901
+ ...baseEntry,
902
+ disposition: 'released',
903
+ reason: `released; state finalization warning: ${error.message}`,
904
+ });
905
+ continue;
906
+ }
907
+ entries.push({
908
+ ...baseEntry,
909
+ disposition: 'released',
910
+ reason: 'checkout already absent',
911
+ });
912
+ continue;
913
+ }
914
+ let inspection;
915
+ try {
916
+ inspection = await inspectOwnedCheckout(record, registered);
917
+ }
918
+ catch (error) {
919
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, error.message));
920
+ continue;
921
+ }
922
+ if (inspection.status || inspection.head !== record.baseCommit) {
923
+ const reason = inspection.status
924
+ ? 'worktree has uncommitted changes'
925
+ : 'worktree HEAD does not match base commit';
926
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
927
+ continue;
928
+ }
929
+ if (options.hooks) {
930
+ try {
931
+ const outcome = await options.hooks.beforeRemove({
932
+ worktreePath: record.worktreePath,
933
+ worktreeKind: record.kind,
934
+ worktreeId: record.worktreeId,
935
+ ownerId: record.ownerId,
936
+ baseCommit: record.baseCommit,
937
+ reason: 'reconcile',
938
+ });
939
+ if (outcome.blockedReason)
940
+ throw new Error(`hook blocked: ${outcome.blockedReason}`);
941
+ }
942
+ catch (error) {
943
+ const reason = `WorktreeRemove hook failed: ${error instanceof Error ? error.message : String(error)}`;
944
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
945
+ continue;
946
+ }
947
+ try {
948
+ const post = await inspectOwnedCheckout(record);
949
+ if (post.status || post.head !== record.baseCommit)
950
+ throw new Error('hook left worktree unsafe');
951
+ }
952
+ catch (error) {
953
+ const reason = error.message;
954
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
955
+ continue;
956
+ }
957
+ }
958
+ try {
959
+ if (record.branch !== null &&
960
+ (await ownedBranchRef(repositoryRoot, record.branch, record.baseCommit)) === 'ambiguous')
961
+ throw new Error('owned branch moved');
962
+ await git(repositoryRoot, ['worktree', 'remove', record.worktreePath]);
963
+ }
964
+ catch (error) {
965
+ const reason = error.message;
966
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
967
+ continue;
968
+ }
969
+ try {
970
+ if (record.branch !== null)
971
+ await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
972
+ }
973
+ catch (error) {
974
+ const reason = `checkout removed but owned branch was preserved: ${error.message}`;
975
+ entries.push(await reconciliationRetain(store, releasing, baseEntry, reason));
976
+ continue;
977
+ }
978
+ try {
979
+ await store.update(nextRecord(releasing, 'released'));
980
+ entries.push({
981
+ ...baseEntry,
982
+ disposition: 'released',
983
+ reason: 'reconciled abandoned worktree',
984
+ });
985
+ }
986
+ catch (error) {
987
+ entries.push({
988
+ ...baseEntry,
989
+ disposition: 'released',
990
+ reason: `released; state finalization warning: ${error.message}`,
991
+ });
992
+ }
993
+ }
994
+ finally {
995
+ await lease.release();
996
+ }
997
+ }
998
+ return {
999
+ repositoryRoot,
1000
+ inspected: snapshot.entries.length,
1001
+ truncated: snapshot.truncated,
1002
+ entries,
1003
+ };
1004
+ }
278
1005
  export async function createOwnedManagedWorktree(options) {
1006
+ if (options.parentDirectoryName !== undefined &&
1007
+ !COMPONENT_PATTERN.test(options.parentDirectoryName)) {
1008
+ throw worktreeError(options, 'parent name is invalid');
1009
+ }
279
1010
  if (!COMPONENT_PATTERN.test(options.directoryName)) {
280
1011
  throw worktreeError(options, 'name is invalid');
281
1012
  }
@@ -297,8 +1028,36 @@ export async function createOwnedManagedWorktree(options) {
297
1028
  catch {
298
1029
  throw worktreeError(options, 'isolation requires a Git repository');
299
1030
  }
1031
+ if (options.branch !== undefined) {
1032
+ try {
1033
+ const checked = await git(repositoryRoot, [
1034
+ 'check-ref-format',
1035
+ '--branch',
1036
+ options.branch,
1037
+ ]);
1038
+ if (checked !== options.branch)
1039
+ throw new Error('branch was normalized');
1040
+ }
1041
+ catch {
1042
+ throw worktreeError(options, 'branch name is invalid');
1043
+ }
1044
+ }
1045
+ const reconciliationKey = repositoryRoot;
1046
+ let reconciliation = reconciliationCache.get(reconciliationKey);
1047
+ if (!reconciliation) {
1048
+ reconciliation = reconcileManagedWorktrees({
1049
+ cwd: repositoryRoot,
1050
+ stateRoot: options.stateRoot,
1051
+ ...(options.hooks ? { hooks: options.hooks } : {}),
1052
+ });
1053
+ reconciliationCache.set(reconciliationKey, reconciliation);
1054
+ }
1055
+ await reconciliation;
300
1056
  const kindRoot = join(repositoryRoot, '.praxis', 'worktrees', options.kind);
301
- const worktreePath = join(kindRoot, options.directoryName);
1057
+ const worktreeParent = options.parentDirectoryName
1058
+ ? join(kindRoot, options.parentDirectoryName)
1059
+ : kindRoot;
1060
+ const worktreePath = join(worktreeParent, options.directoryName);
302
1061
  const rootRelative = relative(kindRoot, worktreePath);
303
1062
  if (!rootRelative ||
304
1063
  rootRelative === '..' ||
@@ -309,6 +1068,8 @@ export async function createOwnedManagedWorktree(options) {
309
1068
  await assertRealDirectory(join(repositoryRoot, '.praxis'), 'managed worktree parent');
310
1069
  await assertRealDirectory(join(repositoryRoot, '.praxis', 'worktrees'), 'managed worktree parent');
311
1070
  await assertRealDirectory(kindRoot, 'managed worktree kind root');
1071
+ if (options.parentDirectoryName)
1072
+ await assertRealDirectory(worktreeParent, 'managed worktree parent');
312
1073
  await ensureManagedRootIgnored(repositoryRoot);
313
1074
  const worktreeId = managedWorktreeId(repositoryRoot, options.kind, options.ownerId);
314
1075
  const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, worktreeId);
@@ -324,7 +1085,7 @@ export async function createOwnedManagedWorktree(options) {
324
1085
  ownerId: options.ownerId,
325
1086
  repositoryRoot,
326
1087
  worktreePath,
327
- branch: null,
1088
+ branch: options.branch ?? null,
328
1089
  baseCommit,
329
1090
  state: 'creating',
330
1091
  createdAt: now,
@@ -333,6 +1094,7 @@ export async function createOwnedManagedWorktree(options) {
333
1094
  let recordCreated = false;
334
1095
  let gitCreated = false;
335
1096
  let markerCreated = false;
1097
+ let branchBeforeAttempt;
336
1098
  let created = false;
337
1099
  try {
338
1100
  await store.create(record);
@@ -348,18 +1110,40 @@ export async function createOwnedManagedWorktree(options) {
348
1110
  if (error.code !== 'ENOENT')
349
1111
  throw error;
350
1112
  }
351
- await mkdir(kindRoot, { recursive: true });
352
- await assertRealDirectory(kindRoot, 'managed worktree kind root');
353
- await git(repositoryRoot, [
354
- 'worktree',
355
- 'add',
356
- '--detach',
357
- worktreePath,
358
- baseCommit,
359
- ]);
1113
+ await mkdir(worktreeParent, { recursive: true });
1114
+ await assertRealDirectory(worktreeParent, 'managed worktree parent');
1115
+ if (options.branch !== undefined) {
1116
+ branchBeforeAttempt = await gitOptional(repositoryRoot, [
1117
+ 'rev-parse',
1118
+ '--verify',
1119
+ '--quiet',
1120
+ `refs/heads/${options.branch}`,
1121
+ ]);
1122
+ }
1123
+ await git(repositoryRoot, options.branch === undefined
1124
+ ? ['worktree', 'add', '--detach', worktreePath, baseCommit]
1125
+ : ['worktree', 'add', '-b', options.branch, worktreePath, baseCommit]);
360
1126
  gitCreated = true;
361
1127
  await writeMarker(worktreePath, record);
362
1128
  markerCreated = true;
1129
+ if (options.hooks) {
1130
+ let outcome;
1131
+ try {
1132
+ outcome = await options.hooks.afterCreate({
1133
+ worktreePath,
1134
+ worktreeKind: record.kind,
1135
+ worktreeId: record.worktreeId,
1136
+ ownerId: record.ownerId,
1137
+ baseCommit: record.baseCommit,
1138
+ });
1139
+ }
1140
+ catch (error) {
1141
+ throw new Error(`WorktreeCreate hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1142
+ }
1143
+ if (outcome.blockedReason) {
1144
+ throw new Error(`WorktreeCreate hook blocked: ${outcome.blockedReason}`);
1145
+ }
1146
+ }
363
1147
  await store.update(nextRecord(record, 'active'));
364
1148
  created = true;
365
1149
  }
@@ -379,6 +1163,9 @@ export async function createOwnedManagedWorktree(options) {
379
1163
  throw new Error('created worktree HEAD no longer matches its base');
380
1164
  }
381
1165
  await git(repositoryRoot, ['worktree', 'remove', worktreePath]);
1166
+ if (record.branch !== null) {
1167
+ await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
1168
+ }
382
1169
  }
383
1170
  catch (removeError) {
384
1171
  rollbackError = removeError;
@@ -396,6 +1183,20 @@ export async function createOwnedManagedWorktree(options) {
396
1183
  throw pathError;
397
1184
  });
398
1185
  uncertainArtifact = pathExists || registered.has(resolve(worktreePath));
1186
+ if (record.branch !== null) {
1187
+ const branchAfterAttempt = await gitOptional(repositoryRoot, [
1188
+ 'rev-parse',
1189
+ '--verify',
1190
+ '--quiet',
1191
+ `refs/heads/${record.branch}`,
1192
+ ]);
1193
+ if (branchBeforeAttempt === undefined ||
1194
+ (branchBeforeAttempt === null
1195
+ ? branchAfterAttempt !== null
1196
+ : branchAfterAttempt !== branchBeforeAttempt)) {
1197
+ uncertainArtifact = true;
1198
+ }
1199
+ }
399
1200
  }
400
1201
  catch {
401
1202
  uncertainArtifact = true;
@@ -424,37 +1225,14 @@ export async function createOwnedManagedWorktree(options) {
424
1225
  if (!created)
425
1226
  await lease.release();
426
1227
  }
427
- let executionLease = lease;
428
- let removedResult;
429
- let cleanupInFlight;
430
- return {
431
- cwd: worktreePath,
432
- cleanup: async () => {
433
- if (removedResult)
434
- return removedResult;
435
- if (cleanupInFlight)
436
- return cleanupInFlight;
437
- const heldLease = executionLease;
438
- executionLease = undefined;
439
- cleanupInFlight = cleanupOwnedManagedWorktree(store, options, record, heldLease);
440
- try {
441
- const result = await cleanupInFlight;
442
- if (!result.retained)
443
- removedResult = result;
444
- return result;
445
- }
446
- finally {
447
- cleanupInFlight = undefined;
448
- }
449
- },
450
- };
1228
+ return createManagedWorktreeHandle(store, options, record, lease);
451
1229
  }
452
- async function cleanupOwnedManagedWorktree(store, options, original, heldLease) {
1230
+ async function settleOwnedManagedWorktree(store, options, original, mode, heldLease) {
453
1231
  const lease = heldLease ?? (await store.acquireLease());
454
1232
  if (!lease)
455
1233
  return {
456
1234
  retained: true,
457
- reason: `${options.label} worktree cleanup is already in progress`,
1235
+ reason: `${options.label} worktree ${mode === 'release' ? 'release' : 'cleanup'} is already in progress`,
458
1236
  };
459
1237
  try {
460
1238
  let record;
@@ -483,7 +1261,8 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
483
1261
  reason: `${options.label} worktree is not releasable`,
484
1262
  };
485
1263
  }
486
- if (record.policy === 'durable') {
1264
+ const explicitDurableRelease = mode === 'release' && record.policy === 'durable';
1265
+ if (mode === 'cleanup' && record.policy === 'durable') {
487
1266
  return retain(store, record, `${options.label} worktree uses durable retention policy`);
488
1267
  }
489
1268
  const releasing = nextRecord(record, 'releasing');
@@ -510,6 +1289,18 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
510
1289
  if (registered.has(resolve(record.worktreePath))) {
511
1290
  return retain(store, releasing, `${options.label} worktree is missing but remains registered at ${record.worktreePath}`);
512
1291
  }
1292
+ if (record.branch !== null) {
1293
+ const ref = await ownedBranchRef(record.repositoryRoot, record.branch, record.baseCommit);
1294
+ if (ref === 'ambiguous')
1295
+ return retain(store, releasing, `${options.label} worktree owned branch moved or is ambiguous`);
1296
+ try {
1297
+ if (ref === 'match')
1298
+ await removeOwnedBranch(record.repositoryRoot, record.branch, record.baseCommit);
1299
+ }
1300
+ catch (error) {
1301
+ return retain(store, releasing, `${options.label} worktree owned branch could not be removed: ${error.message}`);
1302
+ }
1303
+ }
513
1304
  try {
514
1305
  await store.update(nextRecord(releasing, 'released'));
515
1306
  return { retained: false };
@@ -529,25 +1320,79 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
529
1320
  const reason = `Could not verify ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
530
1321
  return retain(store, releasing, reason);
531
1322
  }
532
- if (inspection.status.length > 0) {
1323
+ if (!explicitDurableRelease && inspection.status.length > 0) {
533
1324
  const reason = `${options.label} worktree has uncommitted changes and was retained at ${record.worktreePath}`;
534
1325
  return retain(store, releasing, reason);
535
1326
  }
536
- if (inspection.head !== record.baseCommit) {
1327
+ if (!explicitDurableRelease && inspection.head !== record.baseCommit) {
537
1328
  const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
538
1329
  return retain(store, releasing, reason);
539
1330
  }
1331
+ if (options.hooks) {
1332
+ let outcome;
1333
+ try {
1334
+ outcome = await options.hooks.beforeRemove({
1335
+ worktreePath: record.worktreePath,
1336
+ worktreeKind: record.kind,
1337
+ worktreeId: record.worktreeId,
1338
+ ownerId: record.ownerId,
1339
+ baseCommit: record.baseCommit,
1340
+ reason: 'normal',
1341
+ });
1342
+ }
1343
+ catch (error) {
1344
+ return retain(store, releasing, `WorktreeRemove hook failed for ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
1345
+ }
1346
+ if (outcome.blockedReason) {
1347
+ return retain(store, releasing, `WorktreeRemove hook blocked for ${record.worktreePath}: ${outcome.blockedReason}`);
1348
+ }
1349
+ try {
1350
+ const postHookRegistered = await registeredWorktrees(record.repositoryRoot);
1351
+ const postHookInspection = await inspectOwnedCheckout(record, postHookRegistered);
1352
+ inspection = postHookInspection;
1353
+ if (!explicitDurableRelease && postHookInspection.status.length > 0) {
1354
+ return retain(store, releasing, `WorktreeRemove hook left uncommitted changes in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
1355
+ }
1356
+ if (!explicitDurableRelease &&
1357
+ postHookInspection.head !== record.baseCommit) {
1358
+ return retain(store, releasing, `WorktreeRemove hook created commits in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
1359
+ }
1360
+ }
1361
+ catch (error) {
1362
+ return retain(store, releasing, `WorktreeRemove hook left worktree unsafe at ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
1363
+ }
1364
+ }
540
1365
  try {
541
- await git(record.repositoryRoot, [
542
- 'worktree',
543
- 'remove',
544
- record.worktreePath,
545
- ]);
1366
+ if (record.branch !== null &&
1367
+ (await ownedBranchRef(record.repositoryRoot, record.branch, explicitDurableRelease ? inspection.head : record.baseCommit)) === 'ambiguous') {
1368
+ return retain(store, releasing, `${options.label} worktree owned branch moved or is ambiguous`);
1369
+ }
1370
+ await git(record.repositoryRoot, explicitDurableRelease
1371
+ ? ['worktree', 'remove', '--force', record.worktreePath]
1372
+ : ['worktree', 'remove', record.worktreePath]);
546
1373
  }
547
1374
  catch (error) {
548
1375
  const reason = `Could not remove ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
549
1376
  return retain(store, releasing, reason);
550
1377
  }
1378
+ if (record.branch !== null) {
1379
+ try {
1380
+ await removeOwnedBranch(record.repositoryRoot, record.branch, explicitDurableRelease ? inspection.head : record.baseCommit);
1381
+ }
1382
+ catch (error) {
1383
+ let stateWarning = '';
1384
+ try {
1385
+ await store.update(nextRecord(releasing, 'released'));
1386
+ }
1387
+ catch (stateError) {
1388
+ stateWarning = `; could not finalize release state: ${stateError instanceof Error ? stateError.message : String(stateError)}`;
1389
+ }
1390
+ return {
1391
+ retained: false,
1392
+ reason: `checkout removed but owned branch was preserved: ${error.message}${stateWarning}`,
1393
+ };
1394
+ }
1395
+ }
551
1396
  try {
552
1397
  await store.update(nextRecord(releasing, 'released'));
553
1398
  }
@@ -563,6 +1408,181 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
563
1408
  await lease.release();
564
1409
  }
565
1410
  }
1411
+ async function cleanupOwnedManagedWorktree(store, options, original, heldLease) {
1412
+ return settleOwnedManagedWorktree(store, options, original, 'cleanup', heldLease);
1413
+ }
1414
+ async function releaseOwnedManagedWorktree(store, options, original, heldLease) {
1415
+ return settleOwnedManagedWorktree(store, options, original, 'release', heldLease);
1416
+ }
1417
+ async function retainOwnedManagedWorktree(store, options, original, reason, heldLease) {
1418
+ assertRetentionReason(reason);
1419
+ const lease = heldLease ?? (await store.acquireLease());
1420
+ if (!lease)
1421
+ return {
1422
+ retained: true,
1423
+ reason: `${options.label} worktree retention is already in progress`,
1424
+ };
1425
+ try {
1426
+ let record;
1427
+ try {
1428
+ record = await store.read();
1429
+ }
1430
+ catch (error) {
1431
+ return {
1432
+ retained: true,
1433
+ reason: `Could not inspect ${options.label.toLowerCase()} worktree record: ${error instanceof Error ? error.message : String(error)}`,
1434
+ };
1435
+ }
1436
+ if (!sameOwnership(record, original))
1437
+ return {
1438
+ retained: true,
1439
+ reason: `${options.label} worktree ownership record does not match`,
1440
+ };
1441
+ if (record.state === 'released')
1442
+ return { retained: false };
1443
+ try {
1444
+ await store.update(nextRecord(record, 'retained', reason));
1445
+ }
1446
+ catch (error) {
1447
+ return {
1448
+ retained: true,
1449
+ reason: `${reason}; could not persist retention state: ${error instanceof Error ? error.message : String(error)}`,
1450
+ };
1451
+ }
1452
+ return { retained: true, reason };
1453
+ }
1454
+ finally {
1455
+ await lease.release();
1456
+ }
1457
+ }
1458
+ function createManagedWorktreeSettlement(initialLease) {
1459
+ let executionLease = initialLease;
1460
+ let removedResult;
1461
+ let operationInFlight;
1462
+ return (operation) => {
1463
+ if (removedResult)
1464
+ return Promise.resolve(removedResult);
1465
+ if (operationInFlight)
1466
+ return operationInFlight;
1467
+ const heldLease = executionLease;
1468
+ executionLease = undefined;
1469
+ operationInFlight = operation(heldLease);
1470
+ return operationInFlight
1471
+ .then((result) => {
1472
+ if (!result.retained)
1473
+ removedResult = result;
1474
+ return result;
1475
+ })
1476
+ .finally(() => {
1477
+ operationInFlight = undefined;
1478
+ });
1479
+ };
1480
+ }
1481
+ function createManagedWorktreeHandle(store, options, record, lease) {
1482
+ const settle = createManagedWorktreeSettlement(lease);
1483
+ return {
1484
+ cwd: record.worktreePath,
1485
+ cleanup: () => settle((heldLease) => cleanupOwnedManagedWorktree(store, options, record, heldLease)),
1486
+ retain: async (reason) => {
1487
+ assertRetentionReason(reason);
1488
+ return settle((heldLease) => retainOwnedManagedWorktree(store, options, record, reason, heldLease));
1489
+ },
1490
+ release: () => settle((heldLease) => releaseOwnedManagedWorktree(store, options, record, heldLease)),
1491
+ };
1492
+ }
1493
+ /** Restore a checkout only when its complete managed ownership proof matches. */
1494
+ export async function restoreOwnedManagedWorktree(options) {
1495
+ if (options.parentDirectoryName !== undefined &&
1496
+ !COMPONENT_PATTERN.test(options.parentDirectoryName)) {
1497
+ throw worktreeError(options, 'parent name is invalid');
1498
+ }
1499
+ if (!COMPONENT_PATTERN.test(options.directoryName)) {
1500
+ throw worktreeError(options, 'name is invalid');
1501
+ }
1502
+ if (!options.path ||
1503
+ !isAbsolute(options.path) ||
1504
+ options.path.includes('\0')) {
1505
+ throw worktreeError(options, 'path is invalid');
1506
+ }
1507
+ if (!options.ownerPrefix || !validOwnerId(options.ownerPrefix)) {
1508
+ throw worktreeError(options, 'owner prefix is invalid');
1509
+ }
1510
+ if (options.ownerId !== undefined && !validOwnerId(options.ownerId)) {
1511
+ throw worktreeError(options, 'owner ID is invalid');
1512
+ }
1513
+ let repositoryRoot;
1514
+ try {
1515
+ repositoryRoot = await resolveProjectIdentity(options.cwd);
1516
+ }
1517
+ catch {
1518
+ throw worktreeError(options, 'restore requires a Git repository');
1519
+ }
1520
+ if (options.branch !== undefined) {
1521
+ try {
1522
+ const checked = await git(repositoryRoot, [
1523
+ 'check-ref-format',
1524
+ '--branch',
1525
+ options.branch,
1526
+ ]);
1527
+ if (checked !== options.branch)
1528
+ throw new Error('branch was normalized');
1529
+ }
1530
+ catch {
1531
+ throw worktreeError(options, 'branch name is invalid');
1532
+ }
1533
+ }
1534
+ const kindRoot = join(repositoryRoot, '.praxis', 'worktrees', options.kind);
1535
+ const expectedParent = options.parentDirectoryName
1536
+ ? join(kindRoot, options.parentDirectoryName)
1537
+ : kindRoot;
1538
+ const expectedPath = join(expectedParent, options.directoryName);
1539
+ const path = resolve(options.path);
1540
+ if (path !== expectedPath) {
1541
+ throw worktreeError(options, 'path does not match its managed checkout');
1542
+ }
1543
+ try {
1544
+ const entry = await lstat(path);
1545
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
1546
+ throw new Error('worktree path is not a real directory');
1547
+ }
1548
+ if ((await realpath(path)) !== path) {
1549
+ throw new Error('worktree path is not canonical');
1550
+ }
1551
+ }
1552
+ catch (error) {
1553
+ throw worktreeError(options, `could not inspect checkout: ${error.message}`);
1554
+ }
1555
+ const marker = await readMarkerIdentity(path);
1556
+ if (marker.repositoryRoot !== repositoryRoot) {
1557
+ throw worktreeError(options, 'marker repository identity does not match');
1558
+ }
1559
+ const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, marker.worktreeId);
1560
+ const lease = await store.acquireLease();
1561
+ if (!lease)
1562
+ throw worktreeError(options, 'restore is already owned');
1563
+ let accepted = false;
1564
+ try {
1565
+ const record = await store.read();
1566
+ if (record.repositoryRoot !== repositoryRoot ||
1567
+ record.worktreeId !== marker.worktreeId ||
1568
+ record.worktreePath !== expectedPath ||
1569
+ record.kind !== options.kind ||
1570
+ record.policy !== options.policy ||
1571
+ !['active', 'retained', 'releasing'].includes(record.state) ||
1572
+ !record.ownerId.startsWith(options.ownerPrefix) ||
1573
+ (options.ownerId !== undefined && record.ownerId !== options.ownerId) ||
1574
+ (options.branch !== undefined && record.branch !== options.branch)) {
1575
+ throw worktreeError(options, 'ownership evidence does not match');
1576
+ }
1577
+ await inspectOwnedCheckout(record);
1578
+ accepted = true;
1579
+ return createManagedWorktreeHandle(store, options, record, lease);
1580
+ }
1581
+ finally {
1582
+ if (!accepted)
1583
+ await lease.release();
1584
+ }
1585
+ }
566
1586
  export async function createManagedWorktree(options) {
567
1587
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u.test(options.directoryName)) {
568
1588
  throw new Error(`Invalid ${options.label.toLowerCase()} worktree name`);
@@ -614,63 +1634,56 @@ export async function createManagedWorktree(options) {
614
1634
  throw new Error(`Could not create ${options.label.toLowerCase()} worktree: ${error.message}`);
615
1635
  }
616
1636
  }
617
- let removedResult;
618
- let cleanupInFlight;
1637
+ const settle = createManagedWorktreeSettlement();
1638
+ const cleanup = () => settle(async () => {
1639
+ let status;
1640
+ let head;
1641
+ try {
1642
+ ;
1643
+ [status, head] = await Promise.all([
1644
+ git(path, ['status', '--porcelain']),
1645
+ git(path, ['rev-parse', 'HEAD']),
1646
+ ]);
1647
+ }
1648
+ catch (error) {
1649
+ return {
1650
+ retained: true,
1651
+ reason: `Could not inspect ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
1652
+ };
1653
+ }
1654
+ if (status.length > 0) {
1655
+ return {
1656
+ retained: true,
1657
+ reason: `${options.label} worktree has uncommitted changes and was retained at ${path}`,
1658
+ };
1659
+ }
1660
+ if (head !== initialHead) {
1661
+ return {
1662
+ retained: true,
1663
+ reason: `${options.label} worktree has commits and was retained at ${path}`,
1664
+ };
1665
+ }
1666
+ try {
1667
+ await git(root, ['worktree', 'remove', path]);
1668
+ return { retained: false };
1669
+ }
1670
+ catch (error) {
1671
+ return {
1672
+ retained: true,
1673
+ reason: `Could not remove ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
1674
+ };
1675
+ }
1676
+ });
619
1677
  return {
620
1678
  cwd: path,
621
- cleanup: async () => {
622
- if (removedResult)
623
- return removedResult;
624
- if (cleanupInFlight)
625
- return cleanupInFlight;
626
- cleanupInFlight = (async () => {
627
- let status;
628
- let head;
629
- try {
630
- ;
631
- [status, head] = await Promise.all([
632
- git(path, ['status', '--porcelain']),
633
- git(path, ['rev-parse', 'HEAD']),
634
- ]);
635
- }
636
- catch (error) {
637
- return {
638
- retained: true,
639
- reason: `Could not inspect ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
640
- };
641
- }
642
- if (status.length > 0) {
643
- return {
644
- retained: true,
645
- reason: `${options.label} worktree has uncommitted changes and was retained at ${path}`,
646
- };
647
- }
648
- if (head !== initialHead) {
649
- return {
650
- retained: true,
651
- reason: `${options.label} worktree has commits and was retained at ${path}`,
652
- };
653
- }
654
- try {
655
- await git(root, ['worktree', 'remove', path]);
656
- return { retained: false };
657
- }
658
- catch (error) {
659
- return {
660
- retained: true,
661
- reason: `Could not remove ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
662
- };
663
- }
664
- })();
665
- try {
666
- const result = await cleanupInFlight;
667
- if (!result.retained)
668
- removedResult = result;
669
- return result;
670
- }
671
- finally {
672
- cleanupInFlight = undefined;
673
- }
1679
+ cleanup,
1680
+ release: cleanup,
1681
+ retain: async (reason) => {
1682
+ assertRetentionReason(reason);
1683
+ return settle(async () => ({
1684
+ retained: true,
1685
+ reason,
1686
+ }));
674
1687
  },
675
1688
  };
676
1689
  }
@@ -724,6 +1737,16 @@ export async function restoreManagedWorktree(options) {
724
1737
  reason: `${options.label} worktree was restored and retained at ${path}`,
725
1738
  };
726
1739
  },
1740
+ async release() {
1741
+ return {
1742
+ retained: true,
1743
+ reason: `${options.label} worktree was restored and retained at ${path}`,
1744
+ };
1745
+ },
1746
+ async retain(reason) {
1747
+ assertRetentionReason(reason);
1748
+ return { retained: true, reason };
1749
+ },
727
1750
  };
728
1751
  }
729
1752
  //# sourceMappingURL=managed-worktree.js.map