praxis-agent 0.61.0 → 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 (30) hide show
  1. package/README.md +21 -6
  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 +70 -0
  9. package/dist/application/managed-worktree.js +1081 -108
  10. package/dist/application/subagent-service.d.ts +3 -1
  11. package/dist/application/subagent-service.js +106 -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 +2 -8
  21. package/dist/application/workflow-worktree.js +2 -23
  22. package/dist/cli/tui/doctor-dashboard.js +1 -0
  23. package/dist/cli-runtime.js +5 -0
  24. package/dist/maintenance/doctor.d.ts +1 -1
  25. package/dist/maintenance/doctor.js +55 -0
  26. package/dist/persistence/managed-worktree-store.d.ts +16 -1
  27. package/dist/persistence/managed-worktree-store.js +61 -21
  28. package/dist/platform/exclusive-file-lease.d.ts +6 -1
  29. package/dist/platform/exclusive-file-lease.js +48 -7
  30. 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');
206
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');
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,15 +1110,19 @@ 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;
@@ -397,6 +1163,9 @@ export async function createOwnedManagedWorktree(options) {
397
1163
  throw new Error('created worktree HEAD no longer matches its base');
398
1164
  }
399
1165
  await git(repositoryRoot, ['worktree', 'remove', worktreePath]);
1166
+ if (record.branch !== null) {
1167
+ await removeOwnedBranch(repositoryRoot, record.branch, record.baseCommit);
1168
+ }
400
1169
  }
401
1170
  catch (removeError) {
402
1171
  rollbackError = removeError;
@@ -414,6 +1183,20 @@ export async function createOwnedManagedWorktree(options) {
414
1183
  throw pathError;
415
1184
  });
416
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
+ }
417
1200
  }
418
1201
  catch {
419
1202
  uncertainArtifact = true;
@@ -442,37 +1225,14 @@ export async function createOwnedManagedWorktree(options) {
442
1225
  if (!created)
443
1226
  await lease.release();
444
1227
  }
445
- let executionLease = lease;
446
- let removedResult;
447
- let cleanupInFlight;
448
- return {
449
- cwd: worktreePath,
450
- cleanup: async () => {
451
- if (removedResult)
452
- return removedResult;
453
- if (cleanupInFlight)
454
- return cleanupInFlight;
455
- const heldLease = executionLease;
456
- executionLease = undefined;
457
- cleanupInFlight = cleanupOwnedManagedWorktree(store, options, record, heldLease);
458
- try {
459
- const result = await cleanupInFlight;
460
- if (!result.retained)
461
- removedResult = result;
462
- return result;
463
- }
464
- finally {
465
- cleanupInFlight = undefined;
466
- }
467
- },
468
- };
1228
+ return createManagedWorktreeHandle(store, options, record, lease);
469
1229
  }
470
- async function cleanupOwnedManagedWorktree(store, options, original, heldLease) {
1230
+ async function settleOwnedManagedWorktree(store, options, original, mode, heldLease) {
471
1231
  const lease = heldLease ?? (await store.acquireLease());
472
1232
  if (!lease)
473
1233
  return {
474
1234
  retained: true,
475
- reason: `${options.label} worktree cleanup is already in progress`,
1235
+ reason: `${options.label} worktree ${mode === 'release' ? 'release' : 'cleanup'} is already in progress`,
476
1236
  };
477
1237
  try {
478
1238
  let record;
@@ -501,7 +1261,8 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
501
1261
  reason: `${options.label} worktree is not releasable`,
502
1262
  };
503
1263
  }
504
- if (record.policy === 'durable') {
1264
+ const explicitDurableRelease = mode === 'release' && record.policy === 'durable';
1265
+ if (mode === 'cleanup' && record.policy === 'durable') {
505
1266
  return retain(store, record, `${options.label} worktree uses durable retention policy`);
506
1267
  }
507
1268
  const releasing = nextRecord(record, 'releasing');
@@ -528,6 +1289,18 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
528
1289
  if (registered.has(resolve(record.worktreePath))) {
529
1290
  return retain(store, releasing, `${options.label} worktree is missing but remains registered at ${record.worktreePath}`);
530
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
+ }
531
1304
  try {
532
1305
  await store.update(nextRecord(releasing, 'released'));
533
1306
  return { retained: false };
@@ -547,11 +1320,11 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
547
1320
  const reason = `Could not verify ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
548
1321
  return retain(store, releasing, reason);
549
1322
  }
550
- if (inspection.status.length > 0) {
1323
+ if (!explicitDurableRelease && inspection.status.length > 0) {
551
1324
  const reason = `${options.label} worktree has uncommitted changes and was retained at ${record.worktreePath}`;
552
1325
  return retain(store, releasing, reason);
553
1326
  }
554
- if (inspection.head !== record.baseCommit) {
1327
+ if (!explicitDurableRelease && inspection.head !== record.baseCommit) {
555
1328
  const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
556
1329
  return retain(store, releasing, reason);
557
1330
  }
@@ -576,10 +1349,12 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
576
1349
  try {
577
1350
  const postHookRegistered = await registeredWorktrees(record.repositoryRoot);
578
1351
  const postHookInspection = await inspectOwnedCheckout(record, postHookRegistered);
579
- if (postHookInspection.status.length > 0) {
1352
+ inspection = postHookInspection;
1353
+ if (!explicitDurableRelease && postHookInspection.status.length > 0) {
580
1354
  return retain(store, releasing, `WorktreeRemove hook left uncommitted changes in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
581
1355
  }
582
- if (postHookInspection.head !== record.baseCommit) {
1356
+ if (!explicitDurableRelease &&
1357
+ postHookInspection.head !== record.baseCommit) {
583
1358
  return retain(store, releasing, `WorktreeRemove hook created commits in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
584
1359
  }
585
1360
  }
@@ -588,16 +1363,36 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
588
1363
  }
589
1364
  }
590
1365
  try {
591
- await git(record.repositoryRoot, [
592
- 'worktree',
593
- 'remove',
594
- record.worktreePath,
595
- ]);
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]);
596
1373
  }
597
1374
  catch (error) {
598
1375
  const reason = `Could not remove ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
599
1376
  return retain(store, releasing, reason);
600
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
+ }
601
1396
  try {
602
1397
  await store.update(nextRecord(releasing, 'released'));
603
1398
  }
@@ -613,6 +1408,181 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
613
1408
  await lease.release();
614
1409
  }
615
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
+ }
616
1586
  export async function createManagedWorktree(options) {
617
1587
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u.test(options.directoryName)) {
618
1588
  throw new Error(`Invalid ${options.label.toLowerCase()} worktree name`);
@@ -664,63 +1634,56 @@ export async function createManagedWorktree(options) {
664
1634
  throw new Error(`Could not create ${options.label.toLowerCase()} worktree: ${error.message}`);
665
1635
  }
666
1636
  }
667
- let removedResult;
668
- 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
+ });
669
1677
  return {
670
1678
  cwd: path,
671
- cleanup: async () => {
672
- if (removedResult)
673
- return removedResult;
674
- if (cleanupInFlight)
675
- return cleanupInFlight;
676
- cleanupInFlight = (async () => {
677
- let status;
678
- let head;
679
- try {
680
- ;
681
- [status, head] = await Promise.all([
682
- git(path, ['status', '--porcelain']),
683
- git(path, ['rev-parse', 'HEAD']),
684
- ]);
685
- }
686
- catch (error) {
687
- return {
688
- retained: true,
689
- reason: `Could not inspect ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
690
- };
691
- }
692
- if (status.length > 0) {
693
- return {
694
- retained: true,
695
- reason: `${options.label} worktree has uncommitted changes and was retained at ${path}`,
696
- };
697
- }
698
- if (head !== initialHead) {
699
- return {
700
- retained: true,
701
- reason: `${options.label} worktree has commits and was retained at ${path}`,
702
- };
703
- }
704
- try {
705
- await git(root, ['worktree', 'remove', path]);
706
- return { retained: false };
707
- }
708
- catch (error) {
709
- return {
710
- retained: true,
711
- reason: `Could not remove ${options.label.toLowerCase()} worktree ${path}: ${error.message}`,
712
- };
713
- }
714
- })();
715
- try {
716
- const result = await cleanupInFlight;
717
- if (!result.retained)
718
- removedResult = result;
719
- return result;
720
- }
721
- finally {
722
- cleanupInFlight = undefined;
723
- }
1679
+ cleanup,
1680
+ release: cleanup,
1681
+ retain: async (reason) => {
1682
+ assertRetentionReason(reason);
1683
+ return settle(async () => ({
1684
+ retained: true,
1685
+ reason,
1686
+ }));
724
1687
  },
725
1688
  };
726
1689
  }
@@ -774,6 +1737,16 @@ export async function restoreManagedWorktree(options) {
774
1737
  reason: `${options.label} worktree was restored and retained at ${path}`,
775
1738
  };
776
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
+ },
777
1750
  };
778
1751
  }
779
1752
  //# sourceMappingURL=managed-worktree.js.map