dsh-git-worktree 0.1.2 → 0.3.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 (136) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +21 -21
  3. package/README.md +187 -43
  4. package/README.zh.md +187 -41
  5. package/cordis.patch.yml +7 -7
  6. package/lib/adapters/lookup.d.ts +4 -2
  7. package/lib/adapters/lookup.js +31 -3
  8. package/lib/adapters/lookup.js.map +1 -1
  9. package/lib/adapters/registry.js +41 -0
  10. package/lib/adapters/registry.js.map +1 -1
  11. package/lib/adapters/safe-file.js +34 -4
  12. package/lib/adapters/safe-file.js.map +1 -1
  13. package/lib/client/WorktreeCreateRow.d.ts +7 -0
  14. package/lib/client/WorktreeCreateRow.js +26 -0
  15. package/lib/client/WorktreeCreateRow.js.map +1 -0
  16. package/lib/client/WorktreeReviewRow.d.ts +11 -0
  17. package/lib/client/WorktreeReviewRow.js +67 -0
  18. package/lib/client/WorktreeReviewRow.js.map +1 -0
  19. package/lib/client/actions.d.ts +86 -0
  20. package/lib/client/actions.js +35 -0
  21. package/lib/client/actions.js.map +1 -0
  22. package/lib/client/console-remote/adapter.d.ts +3 -0
  23. package/lib/client/console-remote/adapter.js +61 -0
  24. package/lib/client/console-remote/adapter.js.map +1 -0
  25. package/lib/client/console-remote/index.d.ts +18 -0
  26. package/lib/client/console-remote/index.js +17 -0
  27. package/lib/client/console-remote/index.js.map +1 -0
  28. package/lib/client/index.d.ts +17 -0
  29. package/lib/client/index.js +44 -0
  30. package/lib/client/index.js.map +1 -0
  31. package/lib/client/model.d.ts +68 -0
  32. package/lib/client/model.js +127 -0
  33. package/lib/client/model.js.map +1 -0
  34. package/lib/client/pre-session/PreSessionWorktreeToggle.d.ts +16 -0
  35. package/lib/client/pre-session/PreSessionWorktreeToggle.js +128 -0
  36. package/lib/client/pre-session/PreSessionWorktreeToggle.js.map +1 -0
  37. package/lib/client/pre-session/controller.d.ts +42 -0
  38. package/lib/client/pre-session/controller.js +149 -0
  39. package/lib/client/pre-session/controller.js.map +1 -0
  40. package/lib/client/pre-session/index.d.ts +15 -0
  41. package/lib/client/pre-session/index.js +16 -0
  42. package/lib/client/pre-session/index.js.map +1 -0
  43. package/lib/client/review-console/ReviewActions.d.ts +15 -0
  44. package/lib/client/review-console/ReviewActions.js +234 -0
  45. package/lib/client/review-console/ReviewActions.js.map +1 -0
  46. package/lib/client/review-console/WorktreeReviewPanel.d.ts +20 -0
  47. package/lib/client/review-console/WorktreeReviewPanel.js +75 -0
  48. package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -0
  49. package/lib/client/review-console/index.d.ts +8 -0
  50. package/lib/client/review-console/index.js +22 -0
  51. package/lib/client/review-console/index.js.map +1 -0
  52. package/lib/client/review-console/review-console.styles.d.ts +1 -0
  53. package/lib/client/review-console/review-console.styles.js +81 -0
  54. package/lib/client/review-console/review-console.styles.js.map +1 -0
  55. package/lib/client/review-console/status-events.d.ts +3 -0
  56. package/lib/client/review-console/status-events.js +6 -0
  57. package/lib/client/review-console/status-events.js.map +1 -0
  58. package/lib/client/styles.d.ts +1 -0
  59. package/lib/client/styles.js +78 -0
  60. package/lib/client/styles.js.map +1 -0
  61. package/lib/client/target-console/TargetStatusAction.d.ts +7 -0
  62. package/lib/client/target-console/TargetStatusAction.js +46 -0
  63. package/lib/client/target-console/TargetStatusAction.js.map +1 -0
  64. package/lib/client/target-console/WorktreeConsoleView.d.ts +9 -0
  65. package/lib/client/target-console/WorktreeConsoleView.js +362 -0
  66. package/lib/client/target-console/WorktreeConsoleView.js.map +1 -0
  67. package/lib/client/target-console/WorktreeReviewStatus.d.ts +9 -0
  68. package/lib/client/target-console/WorktreeReviewStatus.js +125 -0
  69. package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -0
  70. package/lib/client/target-console/index.d.ts +14 -0
  71. package/lib/client/target-console/index.js +23 -0
  72. package/lib/client/target-console/index.js.map +1 -0
  73. package/lib/client/target-console/target-console.styles.d.ts +1 -0
  74. package/lib/client/target-console/target-console.styles.js +130 -0
  75. package/lib/client/target-console/target-console.styles.js.map +1 -0
  76. package/lib/client.js +6902 -0
  77. package/lib/commands.d.ts +2 -4
  78. package/lib/commands.js +69 -28
  79. package/lib/commands.js.map +1 -1
  80. package/lib/console-contract.d.ts +253 -0
  81. package/lib/console-contract.js +86 -0
  82. package/lib/console-contract.js.map +1 -0
  83. package/lib/console-host/control-plane.d.ts +31 -0
  84. package/lib/console-host/control-plane.js +502 -0
  85. package/lib/console-host/control-plane.js.map +1 -0
  86. package/lib/console-host/errors.d.ts +6 -0
  87. package/lib/console-host/errors.js +31 -0
  88. package/lib/console-host/errors.js.map +1 -0
  89. package/lib/console-host/projection.d.ts +12 -0
  90. package/lib/console-host/projection.js +136 -0
  91. package/lib/console-host/projection.js.map +1 -0
  92. package/lib/console-host/review-diff.d.ts +18 -0
  93. package/lib/console-host/review-diff.js +176 -0
  94. package/lib/console-host/review-diff.js.map +1 -0
  95. package/lib/console-host/service.d.ts +26 -0
  96. package/lib/console-host/service.js +143 -0
  97. package/lib/console-host/service.js.map +1 -0
  98. package/lib/console-remote/descriptors.d.ts +3 -0
  99. package/lib/console-remote/descriptors.js +105 -0
  100. package/lib/console-remote/descriptors.js.map +1 -0
  101. package/lib/console-remote/remote.d.ts +29 -0
  102. package/lib/console-remote/remote.js +4 -0
  103. package/lib/console-remote/remote.js.map +1 -0
  104. package/lib/console-remote/schemas.d.ts +29 -0
  105. package/lib/console-remote/schemas.js +145 -0
  106. package/lib/console-remote/schemas.js.map +1 -0
  107. package/lib/console-remote/typert.d.ts +3 -0
  108. package/lib/console-remote/typert.js +14 -0
  109. package/lib/console-remote/typert.js.map +1 -0
  110. package/lib/index.d.ts +26 -8
  111. package/lib/index.js +28 -10
  112. package/lib/index.js.map +1 -1
  113. package/lib/managed-worktree-path.d.ts +14 -13
  114. package/lib/managed-worktree-path.js +32 -22
  115. package/lib/managed-worktree-path.js.map +1 -1
  116. package/lib/ports.d.ts +39 -1
  117. package/lib/session-checkout-apply.d.ts +54 -1
  118. package/lib/session-checkout-apply.js +318 -28
  119. package/lib/session-checkout-apply.js.map +1 -1
  120. package/lib/session-checkout-module.js +1098 -36
  121. package/lib/session-checkout-module.js.map +1 -1
  122. package/lib/session-target-context.d.ts +6 -0
  123. package/lib/session-target-context.js +16 -0
  124. package/lib/session-target-context.js.map +1 -0
  125. package/lib/tools.d.ts +6 -6
  126. package/lib/tools.js +107 -218
  127. package/lib/tools.js.map +1 -1
  128. package/lib/types.d.ts +57 -11
  129. package/lib/types.js +6 -6
  130. package/lib/types.js.map +1 -1
  131. package/package.json +116 -69
  132. package/scripts/check-publish.mjs +193 -0
  133. package/scripts/clean-lib.mjs +7 -0
  134. package/scripts/dev-dsh-lib.mjs +509 -0
  135. package/scripts/dev-dsh.mjs +95 -0
  136. package/scripts/dsh-source-runner.mjs +14 -0
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { SessionCheckoutError } from './index.js';
4
+ import { createManagedWorktreePathCandidates, createManagedWorktreeRepositoryKey, } from './managed-worktree-path.js';
4
5
  const UNVERSIONED_OID = 'unversioned';
5
6
  const UNVERSIONED_REF = 'WORKING_TREE';
6
7
  const RETENTION_24H_MS = 24 * 60 * 60 * 1000;
@@ -154,6 +155,19 @@ export function createSessionCheckoutModule(dependencies) {
154
155
  return delivery;
155
156
  if (delivery.state === 'ready_for_review')
156
157
  return { state: delivery.state, review: delivery.review };
158
+ if (delivery.state === 'preview_active') {
159
+ return { state: delivery.state, review: delivery.review, previewedAt: delivery.preview.previewedAt };
160
+ }
161
+ if (delivery.state === 'preview_detached') {
162
+ return {
163
+ state: delivery.state,
164
+ review: delivery.review,
165
+ previewedAt: delivery.preview.previewedAt,
166
+ detachedAt: delivery.detachedAt,
167
+ reason: delivery.reason,
168
+ attemptedAction: delivery.attemptedAction,
169
+ };
170
+ }
157
171
  if (delivery.state === 'finalized') {
158
172
  return {
159
173
  state: delivery.state,
@@ -546,6 +560,12 @@ export function createSessionCheckoutModule(dependencies) {
546
560
  }
547
561
  const { snapshot, status } = validation.checkout;
548
562
  const delivery = projectDelivery(record);
563
+ const activePreview = delivery?.state === 'ready_for_review'
564
+ ? Object.values(registry.managedCheckouts).find((candidate) => (candidate.checkoutId !== record.checkoutId
565
+ && candidate.phase !== 'discarded'
566
+ && pathsEqual(candidate.localRoot, record.localRoot)
567
+ && holdsProjectAcceptanceSlot(candidate)))
568
+ : undefined;
549
569
  return {
550
570
  project: { id: record.projectId, name: record.projectName },
551
571
  checkout: {
@@ -560,17 +580,125 @@ export function createSessionCheckoutModule(dependencies) {
560
580
  dirty: status.dirty,
561
581
  revision: record.revision,
562
582
  delivery,
583
+ ...(delivery?.state === 'ready_for_review'
584
+ ? {
585
+ reviewSlot: activePreview ? 'waiting' : 'available',
586
+ ...(activePreview ? { reviewSlotOwnerSessionId: activePreview.ownerSessionId } : {}),
587
+ }
588
+ : {}),
563
589
  };
564
590
  }
565
591
  function getPersistedBinding(sessionId) {
566
592
  return dependencies.registry.read().sessionBindings[sessionId];
567
593
  }
594
+ function runtimeContext(sessionId) {
595
+ const registry = dependencies.registry.read();
596
+ const binding = registry.sessionBindings[sessionId];
597
+ if (!binding)
598
+ return '';
599
+ if (binding.target.kind === 'local') {
600
+ const projectPreview = Object.values(registry.managedCheckouts)
601
+ .filter((record) => record.projectId === binding.projectId && record.phase !== 'discarded')
602
+ .filter((record) => record.delivery.state === 'preview_active'
603
+ || record.delivery.state === 'preview_detached')
604
+ .sort((left, right) => managedUpdatedAt(right) - managedUpdatedAt(left))[0];
605
+ const pending = projectPreview ?? Object.values(registry.managedCheckouts)
606
+ .filter((record) => record.sourceSessionId === sessionId && record.phase !== 'discarded')
607
+ .filter((record) => record.delivery.state === 'working'
608
+ || record.delivery.state === 'ready_for_review'
609
+ || record.delivery.state === 'preview_active'
610
+ || record.delivery.state === 'preview_detached'
611
+ || record.phase === 'preparing'
612
+ || record.phase === 'mutating'
613
+ || record.phase === 'recovery_required')
614
+ .sort((left, right) => managedUpdatedAt(right) - managedUpdatedAt(left))[0];
615
+ if (!pending) {
616
+ return 'Session Target: Local Checkout. No isolated target is active for this Session.';
617
+ }
618
+ if (pending.delivery.state === 'preview_active') {
619
+ return [
620
+ 'Session Target: Local Checkout (Worktree Preview active).',
621
+ `Managed checkout ${pending.checkoutId} has a reversible Preview in this Local boundary.`,
622
+ 'Do not modify project files or run repository-changing commands. Only the user may accept, rollback, or discard the Preview through the Worktree acceptance UI.',
623
+ ].join('\n');
624
+ }
625
+ if (pending.delivery.state === 'preview_detached') {
626
+ return [
627
+ 'Session Target: Local Checkout (Worktree Preview recovery required).',
628
+ `Managed checkout ${pending.checkoutId} still has preserved Preview recovery evidence.`,
629
+ 'Do not modify project files or attempt automatic cleanup. The user must retry rollback; the Worktree stays preserved until rollback succeeds.',
630
+ ].join('\n');
631
+ }
632
+ return [
633
+ 'Session Target: Local Checkout (handoff pending).',
634
+ `Managed checkout ${pending.checkoutId} is reserved for Session ${pending.ownerSessionId}.`,
635
+ 'Stop code modifications in this Local Session. Ask the user to open the isolated Session from the Worktree card.',
636
+ ].join('\n');
637
+ }
638
+ if (binding.target.kind !== 'isolated')
639
+ return 'Session Target is unselected. Do not mutate project files until a target is chosen.';
640
+ const record = registry.managedCheckouts[binding.target.checkoutId];
641
+ if (!record)
642
+ return 'Session Target: Isolated Checkout record missing. Stop mutations and request recovery.';
643
+ if (record.phase === 'recovery_required') {
644
+ return `Session Target: Isolated Checkout ${record.checkoutId} requires recovery. Do not modify Local or clean up automatically.`;
645
+ }
646
+ const lines = [
647
+ `Session Target: Isolated Checkout ${record.checkoutId}.`,
648
+ `Authoritative cwd: ${record.managedRoot}`,
649
+ `Original Local boundary: ${record.localRoot}`,
650
+ 'Make task changes only in the authoritative cwd; never write directly to the Original Local boundary.',
651
+ ];
652
+ if (record.delivery.state === 'ready_for_review') {
653
+ lines.push('Delivery state: Ready for Review, not yet synchronized to Local. Continue ordinary discussion and answer questions without changing this Review. If the user requests new code or file changes, call worktree_resume_revision first, then continue the requested work in this same Session; do not ask the user to click a recovery control and do not synchronize Local. Until that tool succeeds, treat the Worktree as read-only. The user may still preview, directly finish, or discard through the Worktree acceptance UI.');
654
+ }
655
+ else if (record.delivery.state === 'preview_active') {
656
+ lines.push('Delivery state: Local Preview active. The model must remain read-only; only the user may accept and commit, rollback, or discard through the Worktree acceptance UI.');
657
+ }
658
+ else if (record.delivery.state === 'preview_detached') {
659
+ lines.push('Delivery state: Preview detached after Local drift. Do not mutate Local or the Worktree; the user must retry rollback, and Discard remains blocked until recovery succeeds.');
660
+ }
661
+ else if (record.delivery.state === 'working') {
662
+ lines.push('Delivery state: Working. When implementation and validation are complete, put the complete report only in worktree_ready_for_review and call it as the final model tool. Do not duplicate that report in ordinary assistant prose; at most one short sentence may point the user to the bottom acceptance bar.');
663
+ }
664
+ else if (record.delivery.state === 'delivered') {
665
+ lines.push('Delivery state: Delivered and cleaned. If the user requests new code or file changes in this conversation, call worktree_begin_next_iteration first; it safely recreates this Session immutable cwd for the next iteration. Do not call worktree_create.');
666
+ }
667
+ else if (record.delivery.state === 'retained') {
668
+ lines.push('Delivery state: Retained. This frozen environment must be cleaned through the user controls before a next iteration can start.');
669
+ }
670
+ else {
671
+ lines.push(`Delivery state: ${record.delivery.state}. Treat this Session as terminal until cleanup or recovery completes.`);
672
+ }
673
+ return lines.join('\n');
674
+ }
568
675
  async function resolveBinding(sessionId) {
569
676
  const session = requireSession(sessionId);
570
677
  const persisted = getPersistedBinding(sessionId);
571
678
  if (persisted) {
572
679
  if (session.projectId !== persisted.projectId) {
573
- throw new SessionCheckoutError('project_mismatch', '会话当前项目与已绑定 Session Target 不一致,已停止访问 checkout');
680
+ const record = persisted.target.kind === 'isolated'
681
+ ? dependencies.registry.read().managedCheckouts[persisted.target.checkoutId]
682
+ : undefined;
683
+ const workspace = session.projectId ? dependencies.lookup.getProject(session.projectId) : undefined;
684
+ let isolatedWorkspaceMatches = Boolean(record
685
+ && workspace
686
+ && record.phase === 'discarded'
687
+ && record.delivery.state === 'delivered'
688
+ && !dependencies.files.exists(workspace.root)
689
+ && resolvedPathsEqual(workspace.root, record.managedRoot));
690
+ if (!isolatedWorkspaceMatches && record && workspace && dependencies.files.exists(workspace.root)) {
691
+ try {
692
+ const workspaceRoot = await dependencies.files.canonicalize(workspace.root);
693
+ isolatedWorkspaceMatches = pathsEqual(workspaceRoot, record.managedRoot);
694
+ }
695
+ catch {
696
+ isolatedWorkspaceMatches = false;
697
+ }
698
+ }
699
+ if (!isolatedWorkspaceMatches) {
700
+ throw new SessionCheckoutError('project_mismatch', '会话当前 Workspace 与已绑定 Session Target 不一致,已停止访问 checkout');
701
+ }
574
702
  }
575
703
  return persisted;
576
704
  }
@@ -684,6 +812,30 @@ export function createSessionCheckoutModule(dependencies) {
684
812
  for (const [checkoutId, current] of Object.entries(registry.managedCheckouts)) {
685
813
  if (current.phase === 'discarded')
686
814
  continue;
815
+ if (current.predecessorCheckoutId
816
+ && current.journal?.operation === 'create'
817
+ && (current.phase === 'preparing' || current.phase === 'recovery_required')
818
+ && !dependencies.files.exists(current.managedGitRoot)
819
+ && !dependencies.files.exists(current.managedRoot)) {
820
+ const predecessor = registry.managedCheckouts[current.predecessorCheckoutId];
821
+ const binding = registry.sessionBindings[current.ownerSessionId];
822
+ if (predecessor?.phase === 'discarded'
823
+ && predecessor.delivery.state === 'delivered'
824
+ && binding?.target.kind === 'isolated'
825
+ && binding.target.checkoutId === current.checkoutId) {
826
+ delete registry.managedCheckouts[checkoutId];
827
+ registry.sessionBindings[current.ownerSessionId] = {
828
+ ...binding,
829
+ target: { kind: 'isolated', checkoutId: predecessor.checkoutId },
830
+ sourceRef: predecessor.sourceRef,
831
+ sourceOid: predecessor.baseOid,
832
+ revision: binding.revision + 1,
833
+ };
834
+ registry.revision += 1;
835
+ changed = true;
836
+ continue;
837
+ }
838
+ }
687
839
  let record = current;
688
840
  if (current.delivery.state === 'finalized') {
689
841
  // 兼容旧版本曾把 cleanup 残余错误持久化为 recovery_required;Commit 事实优先,继续收口资源即可。
@@ -691,9 +843,109 @@ export function createSessionCheckoutModule(dependencies) {
691
843
  continue;
692
844
  }
693
845
  if (current.phase === 'mutating'
694
- && (current.journal?.operation === 'apply' || current.journal?.operation === 'finish')
846
+ && (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
847
+ && current.journal.step === 'updating_ref'
848
+ && typeof current.journal.commitOid === 'string'
849
+ && current.delivery.state === 'preview_active') {
850
+ const local = await dependencies.git.inspect(current.localRoot);
851
+ if (local?.headOid === current.journal.commitOid) {
852
+ const retention = current.journal.retention ?? 'cleanup';
853
+ const recoveredAt = Date.now();
854
+ record = retention === 'cleanup'
855
+ ? {
856
+ ...current,
857
+ phase: 'finalized',
858
+ delivery: {
859
+ state: 'finalized',
860
+ review: current.delivery.review,
861
+ commitOid: current.journal.commitOid,
862
+ proof: {
863
+ localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
864
+ ? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
865
+ : null,
866
+ localHeadBefore: current.delivery.preview.localHeadOid,
867
+ localHeadAfter: current.journal.commitOid,
868
+ changedFiles: [...current.delivery.preview.changedFiles],
869
+ },
870
+ isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
871
+ finalizedAt: recoveredAt,
872
+ cleanup: 'blocked',
873
+ cleanupMessage: 'Commit 已创建,但进程在 Local index 完成前中断;请确认 Local 状态后再处理 Worktree。',
874
+ },
875
+ journal: null,
876
+ revision: current.revision + 1,
877
+ }
878
+ : {
879
+ ...current,
880
+ phase: 'retained',
881
+ delivery: {
882
+ state: 'retained',
883
+ review: current.delivery.review,
884
+ commitOid: current.journal.commitOid,
885
+ proof: {
886
+ localBranch: current.delivery.preview.localHeadRef?.startsWith('refs/heads/')
887
+ ? current.delivery.preview.localHeadRef.slice('refs/heads/'.length)
888
+ : null,
889
+ localHeadBefore: current.delivery.preview.localHeadOid,
890
+ localHeadAfter: current.journal.commitOid,
891
+ changedFiles: [...current.delivery.preview.changedFiles],
892
+ },
893
+ isolatedFingerprint: current.delivery.preview.isolatedFingerprint,
894
+ retention,
895
+ retainedAt: recoveredAt,
896
+ expiresAt: retentionExpiresAt(retention, recoveredAt),
897
+ cleanup: 'blocked',
898
+ cleanupMessage: 'Commit 已创建并保留 Worktree,但进程在 Local index 完成前中断;请确认 Local 状态。',
899
+ },
900
+ journal: null,
901
+ revision: current.revision + 1,
902
+ };
903
+ }
904
+ else {
905
+ record = {
906
+ ...current,
907
+ phase: 'ready',
908
+ journal: null,
909
+ revision: current.revision + 1,
910
+ };
911
+ }
912
+ registry.managedCheckouts[checkoutId] = record;
913
+ registry.revision += 1;
914
+ changed = true;
915
+ }
916
+ else if (current.phase === 'mutating'
917
+ && current.journal?.operation === 'preview'
918
+ && (current.journal.step === 'planning'
919
+ || (current.journal.step === 'writing_local' && current.delivery.state === 'ready_for_review'))) {
920
+ // Preview receipt 尚未保留,apply patch 也尚未执行;可证明 Local 未被触碰。
921
+ record = {
922
+ ...current,
923
+ phase: 'ready',
924
+ journal: null,
925
+ revision: current.revision + 1,
926
+ };
927
+ registry.managedCheckouts[checkoutId] = record;
928
+ registry.revision += 1;
929
+ changed = true;
930
+ }
931
+ else if (current.phase === 'mutating'
932
+ && (current.journal?.operation === 'finalize_preview' || current.journal?.operation === 'finish')
695
933
  && current.journal.step === 'planning') {
696
- // planning 阶段尚未触碰 Local,可安全重试。
934
+ // Finalize 的 branch/index 写入发生在 updating_ref 之后;planning 中断可安全重试。
935
+ record = {
936
+ ...current,
937
+ phase: 'ready',
938
+ journal: null,
939
+ revision: current.revision + 1,
940
+ };
941
+ registry.managedCheckouts[checkoutId] = record;
942
+ registry.revision += 1;
943
+ changed = true;
944
+ }
945
+ else if (current.phase === 'mutating'
946
+ && (current.journal?.operation === 'preview' || current.journal?.operation === 'finish')
947
+ && current.journal.step === 'artifacts_retained'
948
+ && current.delivery.state === 'preview_active') {
697
949
  record = {
698
950
  ...current,
699
951
  phase: 'ready',
@@ -780,7 +1032,7 @@ export function createSessionCheckoutModule(dependencies) {
780
1032
  const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
781
1033
  if (!record)
782
1034
  throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
783
- if (record.phase !== 'ready') {
1035
+ if (record.phase !== 'ready' || record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
784
1036
  throw new SessionCheckoutError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能准备验收`);
785
1037
  }
786
1038
  const inspected = await inspectIsolated(binding);
@@ -827,6 +1079,43 @@ export function createSessionCheckoutModule(dependencies) {
827
1079
  }));
828
1080
  return inspectIsolated(binding);
829
1081
  }
1082
+ async function resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId) {
1083
+ const binding = await resolveBinding(sessionId);
1084
+ if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
1085
+ throw new SessionCheckoutError('not_owner', '只有 owner Isolated 会话可以恢复编辑');
1086
+ }
1087
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1088
+ if (!record)
1089
+ throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
1090
+ if (record.revision !== expectedRevision) {
1091
+ throw new SessionCheckoutError('stale_target', 'Session Target 已变化,请刷新后重试');
1092
+ }
1093
+ if (record.phase !== 'ready' || record.delivery.state !== 'ready_for_review') {
1094
+ throw new SessionCheckoutError('operation_not_allowed', '当前 Worktree 没有可恢复编辑的未同步验收稿');
1095
+ }
1096
+ if (record.delivery.review.reviewId !== expectedReviewId) {
1097
+ throw new SessionCheckoutError('stale_target', '该验收卡已不是当前 Review,请刷新后重试');
1098
+ }
1099
+ const inspected = await inspectIsolated(binding);
1100
+ if (inspected.checkout.phase !== 'ready') {
1101
+ throw new SessionCheckoutError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复');
1102
+ }
1103
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1104
+ if (!record
1105
+ || record.revision !== expectedRevision
1106
+ || record.phase !== 'ready'
1107
+ || record.delivery.state !== 'ready_for_review'
1108
+ || record.delivery.review.reviewId !== expectedReviewId) {
1109
+ throw new SessionCheckoutError('stale_target', '验收状态在恢复编辑前发生变化,请刷新后重试');
1110
+ }
1111
+ const iteration = record.delivery.review.iteration;
1112
+ updateManagedCheckout(record.checkoutId, (current) => ({
1113
+ ...current,
1114
+ delivery: { state: 'working', iteration },
1115
+ revision: current.revision + 1,
1116
+ }));
1117
+ return inspectIsolated(binding);
1118
+ }
830
1119
  async function operateApply(input, binding) {
831
1120
  if (binding.ownerSessionId !== input.sessionId) {
832
1121
  return operationError('not_owner', '继承 Session Target 的会话不能执行 Apply');
@@ -964,7 +1253,11 @@ export function createSessionCheckoutModule(dependencies) {
964
1253
  return { status: 'applied', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
965
1254
  }
966
1255
  function holdsProjectAcceptanceSlot(record) {
967
- return record.journal?.operation === 'apply' || record.journal?.operation === 'finish';
1256
+ return record.delivery.state === 'preview_active'
1257
+ || record.journal?.operation === 'preview'
1258
+ || record.journal?.operation === 'rollback_preview'
1259
+ || record.journal?.operation === 'finalize_preview'
1260
+ || record.journal?.operation === 'finish';
968
1261
  }
969
1262
  function findProjectAcceptanceHolder(record) {
970
1263
  return Object.values(dependencies.registry.read().managedCheckouts).find((candidate) => (candidate.checkoutId !== record.checkoutId
@@ -972,6 +1265,21 @@ export function createSessionCheckoutModule(dependencies) {
972
1265
  && pathsEqual(candidate.localRoot, record.localRoot)
973
1266
  && holdsProjectAcceptanceSlot(candidate)));
974
1267
  }
1268
+ async function retainPreviewArtifacts(record, receipt) {
1269
+ const prefix = `previews/${receipt.previewId}`;
1270
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/local-working`, receipt.localWorkingTreeOid);
1271
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/local-index`, receipt.localIndexTreeOid);
1272
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/preview-working`, receipt.previewWorkingTreeOid);
1273
+ await dependencies.git.retainInternalArtifact(record.localRoot, record.checkoutId, `${prefix}/isolated-snapshot`, receipt.isolatedSnapshotOid);
1274
+ }
1275
+ async function releasePreviewArtifactsBestEffort(record, previewId) {
1276
+ try {
1277
+ await dependencies.git.releaseInternalArtifacts(record.localRoot, record.checkoutId, `previews/${previewId}`);
1278
+ }
1279
+ catch {
1280
+ console.warn('[session-checkout] 清理 Preview refs 失败,已保守保留不可见引用');
1281
+ }
1282
+ }
975
1283
  function blockedPreflight(record, reason, message) {
976
1284
  return {
977
1285
  status: 'blocked',
@@ -1051,6 +1359,244 @@ export function createSessionCheckoutModule(dependencies) {
1051
1359
  : 'ready';
1052
1360
  return { ...common, status };
1053
1361
  }
1362
+ async function operatePreview(input, binding) {
1363
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1364
+ return operationError('not_owner', '只有 owner Isolated 会话可以同步验收');
1365
+ }
1366
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1367
+ if (!record)
1368
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1369
+ if (record.revision !== input.expectedRevision) {
1370
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1371
+ }
1372
+ if (record.phase !== 'ready' || record.delivery.state !== 'ready_for_review') {
1373
+ return operationError('operation_not_allowed', '当前 Worktree 尚未处于可验收状态', await inspectIsolated(binding));
1374
+ }
1375
+ const holder = findProjectAcceptanceHolder(record);
1376
+ if (holder) {
1377
+ return operationError('project_acceptance_busy', `同一项目已有验收任务正在占用 Local:${holder.projectName}`, await inspectIsolated(binding));
1378
+ }
1379
+ const inspected = await inspectIsolated(binding);
1380
+ if (inspected.checkout.phase !== 'ready') {
1381
+ return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
1382
+ }
1383
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1384
+ if (!record || record.delivery.state !== 'ready_for_review') {
1385
+ return operationError('stale_target', '验收状态已变化,请刷新后重试');
1386
+ }
1387
+ const review = record.delivery.review;
1388
+ const operationId = dependencies.createCheckoutId();
1389
+ const previewId = dependencies.createCheckoutId();
1390
+ const startedAt = Date.now();
1391
+ const applyBaseOid = record.applyBaseOid ?? record.baseOid;
1392
+ const mutating = updateManagedCheckout(record.checkoutId, (current) => ({
1393
+ ...current,
1394
+ phase: 'mutating',
1395
+ journal: {
1396
+ operation: 'preview',
1397
+ operationId,
1398
+ step: 'planning',
1399
+ startedAt,
1400
+ baseOid: applyBaseOid,
1401
+ previewId,
1402
+ reviewId: review.reviewId,
1403
+ },
1404
+ revision: current.revision + 1,
1405
+ }));
1406
+ if (!mutating)
1407
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1408
+ const planResult = await dependencies.applyEngine.plan({
1409
+ baseOid: applyBaseOid,
1410
+ isolatedPath: mutating.managedRoot,
1411
+ localPath: mutating.localRoot,
1412
+ });
1413
+ if (planResult.status === 'conflict') {
1414
+ updateManagedCheckout(mutating.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1415
+ return {
1416
+ status: 'conflict',
1417
+ code: 'apply_conflict',
1418
+ reason: 'content_conflict',
1419
+ target: await inspectIsolated(binding),
1420
+ baseStrategy: planResult.baseStrategy,
1421
+ effectiveBaseOid: planResult.effectiveBaseOid,
1422
+ localHeadOid: planResult.localHeadOid,
1423
+ isolatedHeadOid: planResult.isolatedHeadOid,
1424
+ canRetryAfterRefresh: false,
1425
+ conflictingFiles: planResult.conflictingFiles,
1426
+ };
1427
+ }
1428
+ if (planResult.status === 'error') {
1429
+ updateManagedCheckout(mutating.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1430
+ return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
1431
+ }
1432
+ if (planResult.plan.isolatedFingerprint !== review.isolatedFingerprint) {
1433
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1434
+ ...current,
1435
+ phase: 'ready',
1436
+ delivery: { state: 'working', iteration: review.iteration },
1437
+ journal: null,
1438
+ revision: current.revision + 1,
1439
+ }));
1440
+ return operationError('stale_isolated', 'Worktree 在标记可验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
1441
+ }
1442
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1443
+ ...current,
1444
+ journal: {
1445
+ operation: 'preview',
1446
+ operationId,
1447
+ step: 'writing_local',
1448
+ startedAt,
1449
+ baseOid: applyBaseOid,
1450
+ previewId,
1451
+ reviewId: review.reviewId,
1452
+ planRevision: planResult.plan.revision,
1453
+ localFingerprint: planResult.plan.localFingerprint,
1454
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
1455
+ effectiveBaseOid: planResult.plan.effectiveBaseOid,
1456
+ baseStrategy: planResult.plan.baseStrategy,
1457
+ localHeadOid: planResult.plan.localHeadOid,
1458
+ isolatedHeadOid: planResult.plan.isolatedHeadOid,
1459
+ changedFiles: [...planResult.plan.changedFiles],
1460
+ },
1461
+ revision: current.revision + 1,
1462
+ }));
1463
+ const previewResult = await dependencies.applyEngine.preview(planResult.plan, {
1464
+ previewId,
1465
+ reviewId: review.reviewId,
1466
+ iteration: review.iteration,
1467
+ beforeWrite: async (receipt) => {
1468
+ await retainPreviewArtifacts(mutating, receipt);
1469
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1470
+ ...current,
1471
+ delivery: { state: 'preview_active', review, preview: receipt },
1472
+ journal: current.journal?.operation === 'preview'
1473
+ ? { ...current.journal, step: 'artifacts_retained' }
1474
+ : current.journal,
1475
+ revision: current.revision + 1,
1476
+ }));
1477
+ },
1478
+ });
1479
+ if (previewResult.status === 'error') {
1480
+ const current = dependencies.registry.read().managedCheckouts[mutating.checkoutId];
1481
+ const definitelyUnchanged = previewResult.error.code === 'stale_local'
1482
+ || previewResult.error.code === 'stale_isolated'
1483
+ || previewResult.error.code === 'invalid_plan'
1484
+ || previewResult.error.code === 'invalid_input'
1485
+ || current?.journal?.step === 'writing_local';
1486
+ if (current?.journal?.step === 'writing_local')
1487
+ await releasePreviewArtifactsBestEffort(mutating, previewId);
1488
+ if (definitelyUnchanged || current?.journal?.step === 'artifacts_retained') {
1489
+ updateManagedCheckout(mutating.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
1490
+ }
1491
+ return operationError(previewResult.error.code, previewResult.error.message, await inspectIsolated(binding));
1492
+ }
1493
+ const receipt = previewResult.receipt;
1494
+ updateManagedCheckout(mutating.checkoutId, (current) => ({
1495
+ ...current,
1496
+ phase: 'ready',
1497
+ delivery: { state: 'preview_active', review, preview: receipt },
1498
+ journal: null,
1499
+ revision: current.revision + 1,
1500
+ }));
1501
+ return { status: 'previewed', target: await inspectIsolated(binding), changedFiles: previewResult.changedFiles };
1502
+ }
1503
+ async function detachPreviewAfterLocalDrift(record, binding, reason, attemptedAction) {
1504
+ if (record.delivery.state !== 'preview_active') {
1505
+ return operationError('preview_not_active', '当前没有可解除的 Local Preview', await inspectIsolated(binding));
1506
+ }
1507
+ const { review, preview } = record.delivery;
1508
+ const detached = updateManagedCheckout(record.checkoutId, (current) => {
1509
+ if (current.delivery.state !== 'preview_active')
1510
+ return current;
1511
+ return {
1512
+ ...current,
1513
+ phase: 'ready',
1514
+ delivery: {
1515
+ state: 'preview_detached',
1516
+ review: current.delivery.review,
1517
+ preview: current.delivery.preview,
1518
+ detachedAt: Date.now(),
1519
+ reason,
1520
+ attemptedAction,
1521
+ },
1522
+ journal: null,
1523
+ revision: current.revision + 1,
1524
+ };
1525
+ });
1526
+ if (!detached || detached.delivery.state !== 'preview_detached') {
1527
+ return operationError('stale_target', 'Preview 状态已变化,请刷新后重试', await inspectIsolated(binding));
1528
+ }
1529
+ return {
1530
+ status: 'preview_detached',
1531
+ target: await inspectIsolated(binding),
1532
+ changedFiles: [...preview.changedFiles],
1533
+ reason,
1534
+ attemptedAction,
1535
+ };
1536
+ }
1537
+ async function operateRollbackPreview(input, binding) {
1538
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1539
+ return operationError('not_owner', '只有 owner Isolated 会话可以撤回验收');
1540
+ }
1541
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1542
+ if (!record)
1543
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1544
+ if (record.revision !== input.expectedRevision) {
1545
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1546
+ }
1547
+ const retryingRecovery = record.phase === 'recovery_required'
1548
+ && record.delivery.state === 'preview_active'
1549
+ && record.journal?.operation === 'rollback_preview';
1550
+ if ((record.phase !== 'ready' && !retryingRecovery)
1551
+ || (record.delivery.state !== 'preview_active' && record.delivery.state !== 'preview_detached')) {
1552
+ return operationError('preview_not_active', '当前没有可撤回的 Local Preview', await inspectIsolated(binding));
1553
+ }
1554
+ const retryingDetached = record.delivery.state === 'preview_detached';
1555
+ const resumeRevision = input.resumeRevision ?? (retryingRecovery && record.journal?.operation === 'rollback_preview'
1556
+ ? record.journal.resumeRevision ?? false
1557
+ : false);
1558
+ const { preview, review } = record.delivery;
1559
+ const operationId = dependencies.createCheckoutId();
1560
+ const startedAt = Date.now();
1561
+ updateManagedCheckout(record.checkoutId, (current) => ({
1562
+ ...current,
1563
+ phase: 'mutating',
1564
+ journal: {
1565
+ operation: 'rollback_preview',
1566
+ operationId,
1567
+ step: 'planning',
1568
+ startedAt,
1569
+ previewId: preview.previewId,
1570
+ reviewId: review.reviewId,
1571
+ resumeRevision,
1572
+ },
1573
+ revision: current.revision + 1,
1574
+ }));
1575
+ const result = await dependencies.applyEngine.rollback({ localPath: record.localRoot, receipt: preview });
1576
+ if (result.status === 'error') {
1577
+ if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
1578
+ if (!retryingDetached) {
1579
+ return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'rollback_preview');
1580
+ }
1581
+ updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1582
+ }
1583
+ else if (result.error.code === 'invalid_input') {
1584
+ updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1585
+ }
1586
+ return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
1587
+ }
1588
+ updateManagedCheckout(record.checkoutId, (current) => ({
1589
+ ...current,
1590
+ phase: 'ready',
1591
+ delivery: resumeRevision
1592
+ ? { state: 'working', iteration: review.iteration }
1593
+ : { state: 'ready_for_review', review },
1594
+ journal: null,
1595
+ revision: current.revision + 1,
1596
+ }));
1597
+ await releasePreviewArtifactsBestEffort(record, preview.previewId);
1598
+ return { status: 'preview_rolled_back', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
1599
+ }
1054
1600
  function retainFinalized(record, retention) {
1055
1601
  if (record.delivery.state !== 'finalized')
1056
1602
  return undefined;
@@ -1254,6 +1800,100 @@ export function createSessionCheckoutModule(dependencies) {
1254
1800
  return { cleaned: false, message, reason };
1255
1801
  }
1256
1802
  }
1803
+ async function operateFinalizePreview(input, binding) {
1804
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1805
+ return operationError('not_owner', '只有 owner Isolated 会话可以完成验收提交');
1806
+ }
1807
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1808
+ if (!record)
1809
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1810
+ if (record.revision !== input.expectedRevision) {
1811
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1812
+ }
1813
+ if (record.phase !== 'ready' || record.delivery.state !== 'preview_active') {
1814
+ return operationError('preview_not_active', '当前没有等待验收的 Local Preview', await inspectIsolated(binding));
1815
+ }
1816
+ // Harness plugin does not expose Domi collaborator checkout inheritance.
1817
+ const { preview, review } = record.delivery;
1818
+ const operationId = dependencies.createCheckoutId();
1819
+ const startedAt = Date.now();
1820
+ updateManagedCheckout(record.checkoutId, (current) => ({
1821
+ ...current,
1822
+ phase: 'mutating',
1823
+ journal: { operation: 'finalize_preview', operationId, step: 'planning', startedAt, previewId: preview.previewId, reviewId: review.reviewId, retention: input.retention ?? 'cleanup' },
1824
+ revision: current.revision + 1,
1825
+ }));
1826
+ const result = await dependencies.applyEngine.finalize({
1827
+ localPath: record.localRoot,
1828
+ receipt: preview,
1829
+ commitMessage: input.commitMessage,
1830
+ beforeCommit: async (commitOid) => {
1831
+ updateManagedCheckout(record.checkoutId, (current) => ({
1832
+ ...current,
1833
+ journal: current.journal?.operation === 'finalize_preview'
1834
+ ? { ...current.journal, step: 'updating_ref', commitOid }
1835
+ : current.journal,
1836
+ revision: current.revision + 1,
1837
+ }));
1838
+ },
1839
+ });
1840
+ if (result.status === 'error') {
1841
+ if (result.error.code === 'stale_local' || result.error.code === 'preview_modified') {
1842
+ return detachPreviewAfterLocalDrift(record, binding, result.error.code, 'finalize_preview');
1843
+ }
1844
+ if (result.error.code === 'commit_isolation_conflict' || result.error.code === 'operation_not_allowed' || result.error.code === 'invalid_input') {
1845
+ updateManagedCheckout(record.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1846
+ }
1847
+ return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
1848
+ }
1849
+ const finalized = updateManagedCheckout(record.checkoutId, (current) => ({
1850
+ ...current,
1851
+ phase: 'finalized',
1852
+ delivery: {
1853
+ state: 'finalized',
1854
+ review,
1855
+ commitOid: result.commitOid,
1856
+ proof: {
1857
+ localBranch: preview.localHeadRef?.startsWith('refs/heads/')
1858
+ ? preview.localHeadRef.slice('refs/heads/'.length)
1859
+ : null,
1860
+ localHeadBefore: preview.localHeadOid,
1861
+ localHeadAfter: result.commitOid ?? preview.localHeadOid,
1862
+ changedFiles: [...result.changedFiles],
1863
+ },
1864
+ isolatedFingerprint: preview.isolatedFingerprint,
1865
+ finalizedAt: Date.now(),
1866
+ cleanup: 'pending',
1867
+ },
1868
+ journal: null,
1869
+ revision: current.revision + 1,
1870
+ }));
1871
+ if (!finalized)
1872
+ return operationError('checkout_missing', '提交已创建,但 Checkout 记录丢失');
1873
+ const retention = input.retention ?? 'cleanup';
1874
+ if (retention !== 'cleanup') {
1875
+ const retained = retainFinalized(finalized, retention);
1876
+ if (!retained)
1877
+ return operationError('checkout_missing', '提交已创建,但保留 Worktree 状态写入失败');
1878
+ return {
1879
+ status: 'finished',
1880
+ target: await inspectIsolated(binding),
1881
+ changedFiles: result.changedFiles,
1882
+ commitOid: result.commitOid,
1883
+ cleanup: 'retained',
1884
+ };
1885
+ }
1886
+ const cleanup = await cleanupFinalized(finalized);
1887
+ return {
1888
+ status: 'finished',
1889
+ target: await inspectIsolated(binding),
1890
+ changedFiles: result.changedFiles,
1891
+ commitOid: result.commitOid,
1892
+ cleanup: cleanup.cleaned ? 'discarded' : 'pending',
1893
+ ...(cleanup.message ? { cleanupMessage: cleanup.message } : {}),
1894
+ ...(cleanup.reason ? { cleanupReason: cleanup.reason } : {}),
1895
+ };
1896
+ }
1257
1897
  async function operateRetryCleanup(input, binding) {
1258
1898
  if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1259
1899
  return operationError('not_owner', '只有 owner Isolated 会话可以重试清理');
@@ -1291,7 +1931,14 @@ export function createSessionCheckoutModule(dependencies) {
1291
1931
  if (record.revision !== input.expectedRevision) {
1292
1932
  return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1293
1933
  }
1294
- if (record.phase !== 'ready') {
1934
+ if (input.expectedReviewId !== undefined && (record.delivery.state !== 'ready_for_review'
1935
+ || record.delivery.review.reviewId !== input.expectedReviewId)) {
1936
+ return operationError('stale_target', '该验收卡已不是当前 Review,请刷新并确认最新交付', await inspectIsolated(binding));
1937
+ }
1938
+ if (record.applyBaseOid && (record.delivery.state === 'working' || record.delivery.state === 'ready_for_review')) {
1939
+ return operationError('operation_not_allowed', '该历史 Worktree 已通过旧版 Apply 写入 Local;为避免遗漏或重复提交,已禁止自动 Finish,请先人工核对 Local 后再清理记录。', await inspectIsolated(binding));
1940
+ }
1941
+ if (record.phase !== 'ready' || record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
1295
1942
  return operationError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能直接 Finish`, await inspectIsolated(binding));
1296
1943
  }
1297
1944
  const holder = findProjectAcceptanceHolder(record);
@@ -1357,6 +2004,21 @@ export function createSessionCheckoutModule(dependencies) {
1357
2004
  }));
1358
2005
  return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
1359
2006
  }
2007
+ if (input.expectedReviewId !== undefined
2008
+ && applying.delivery.state === 'ready_for_review'
2009
+ && (applying.delivery.review.reviewId !== input.expectedReviewId
2010
+ || planResult.plan.isolatedFingerprint !== applying.delivery.review.isolatedFingerprint
2011
+ || planResult.plan.isolatedHeadOid !== applying.delivery.review.isolatedHeadOid)) {
2012
+ const reviewIteration = applying.delivery.review.iteration;
2013
+ updateManagedCheckout(applying.checkoutId, (current) => ({
2014
+ ...current,
2015
+ phase: 'ready',
2016
+ delivery: { state: 'working', iteration: reviewIteration },
2017
+ journal: null,
2018
+ revision: current.revision + 1,
2019
+ }));
2020
+ return operationError('stale_isolated', 'Worktree 在验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
2021
+ }
1360
2022
  const iteration = applying.delivery.state === 'working'
1361
2023
  ? applying.delivery.iteration
1362
2024
  : applying.delivery.state === 'delivered'
@@ -1376,6 +2038,7 @@ export function createSessionCheckoutModule(dependencies) {
1376
2038
  isolatedFingerprint: planResult.plan.isolatedFingerprint,
1377
2039
  isolatedHeadOid: planResult.plan.isolatedHeadOid,
1378
2040
  };
2041
+ const previewId = dependencies.createCheckoutId();
1379
2042
  updateManagedCheckout(applying.checkoutId, (current) => ({
1380
2043
  ...current,
1381
2044
  delivery: { state: 'ready_for_review', review },
@@ -1396,38 +2059,89 @@ export function createSessionCheckoutModule(dependencies) {
1396
2059
  },
1397
2060
  revision: current.revision + 1,
1398
2061
  }));
1399
- const finishResult = await dependencies.applyEngine.finish(planResult.plan, {
2062
+ const previewResult = await dependencies.applyEngine.preview(planResult.plan, {
2063
+ previewId,
2064
+ reviewId: review.reviewId,
2065
+ iteration: review.iteration,
2066
+ beforeWrite: async (receipt) => {
2067
+ await retainPreviewArtifacts(applying, receipt);
2068
+ updateManagedCheckout(applying.checkoutId, (current) => ({
2069
+ ...current,
2070
+ delivery: { state: 'preview_active', review, preview: receipt },
2071
+ journal: current.journal?.operation === 'finish'
2072
+ ? { ...current.journal, step: 'artifacts_retained', previewId, reviewId: review.reviewId }
2073
+ : current.journal,
2074
+ revision: current.revision + 1,
2075
+ }));
2076
+ },
2077
+ });
2078
+ if (previewResult.status === 'error') {
2079
+ const current = dependencies.registry.read().managedCheckouts[applying.checkoutId];
2080
+ if (current?.journal?.step === 'writing_local') {
2081
+ await releasePreviewArtifactsBestEffort(applying, previewId);
2082
+ updateManagedCheckout(applying.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
2083
+ }
2084
+ return operationError(previewResult.error.code, previewResult.error.message, await inspectIsolated(binding));
2085
+ }
2086
+ const receipt = previewResult.receipt;
2087
+ updateManagedCheckout(applying.checkoutId, (current) => ({
2088
+ ...current,
2089
+ phase: 'mutating',
2090
+ delivery: { state: 'preview_active', review, preview: receipt },
2091
+ journal: {
2092
+ operation: 'finish',
2093
+ operationId,
2094
+ step: 'planning',
2095
+ startedAt,
2096
+ previewId,
2097
+ reviewId: review.reviewId,
2098
+ isolatedFingerprint: receipt.isolatedFingerprint,
2099
+ retention: input.retention ?? 'cleanup',
2100
+ changedFiles: [...receipt.changedFiles],
2101
+ },
2102
+ revision: current.revision + 1,
2103
+ }));
2104
+ const finishResult = await dependencies.applyEngine.finalize({
2105
+ localPath: applying.localRoot,
2106
+ receipt,
1400
2107
  commitMessage: input.commitMessage,
2108
+ beforeCommit: async (commitOid) => {
2109
+ updateManagedCheckout(applying.checkoutId, (current) => ({
2110
+ ...current,
2111
+ journal: current.journal?.operation === 'finish'
2112
+ ? { ...current.journal, step: 'updating_ref', commitOid }
2113
+ : current.journal,
2114
+ revision: current.revision + 1,
2115
+ }));
2116
+ },
1401
2117
  });
1402
2118
  if (finishResult.status === 'error') {
1403
- if (finishResult.error.code === 'invalid_plan'
1404
- || finishResult.error.code === 'stale_local'
1405
- || finishResult.error.code === 'stale_isolated'
2119
+ const safePreview = finishResult.error.code === 'stale_local'
2120
+ || finishResult.error.code === 'preview_modified'
1406
2121
  || finishResult.error.code === 'invalid_input'
1407
- || finishResult.error.code === 'operation_not_allowed'
1408
- || finishResult.error.code === 'commit_isolation_conflict') {
1409
- // 这些错误码可证明未触碰 Local,恢复 ready;git_error 交由 reconcile 兜底 recovery_required。
2122
+ || finishResult.error.code === 'commit_isolation_conflict'
2123
+ || finishResult.error.code === 'operation_not_allowed';
2124
+ if (safePreview) {
1410
2125
  updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1411
2126
  }
1412
2127
  return operationError(finishResult.error.code, finishResult.error.message, await inspectIsolated(binding));
1413
2128
  }
1414
2129
  const finalized = updateManagedCheckout(applying.checkoutId, (current) => ({
1415
2130
  ...current,
1416
- applyBaseOid: finishResult.nextBaseOid,
1417
2131
  phase: 'finalized',
1418
2132
  delivery: {
1419
2133
  state: 'finalized',
1420
2134
  review,
1421
2135
  commitOid: finishResult.commitOid,
1422
2136
  proof: {
1423
- localBranch: planResult.plan.localHeadRef?.startsWith('refs/heads/')
1424
- ? planResult.plan.localHeadRef.slice('refs/heads/'.length)
2137
+ localBranch: receipt.localHeadRef?.startsWith('refs/heads/')
2138
+ ? receipt.localHeadRef.slice('refs/heads/'.length)
1425
2139
  : null,
1426
- localHeadBefore: planResult.plan.localHeadOid,
1427
- localHeadAfter: finishResult.commitOid ?? planResult.plan.localHeadOid,
2140
+ localHeadBefore: receipt.localHeadOid,
2141
+ localHeadAfter: finishResult.commitOid ?? receipt.localHeadOid,
1428
2142
  changedFiles: [...finishResult.changedFiles],
1429
2143
  },
1430
- isolatedFingerprint: planResult.plan.isolatedFingerprint,
2144
+ isolatedFingerprint: receipt.isolatedFingerprint,
1431
2145
  finalizedAt: Date.now(),
1432
2146
  cleanup: 'pending',
1433
2147
  },
@@ -1484,6 +2198,40 @@ export function createSessionCheckoutModule(dependencies) {
1484
2198
  if (record.revision !== input.expectedRevision) {
1485
2199
  return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1486
2200
  }
2201
+ if (record.delivery.state === 'preview_detached') {
2202
+ return operationError('preview_modified', 'Local Preview 已因漂移进入 detached 恢复态;为保留恢复证据,不能删除 Worktree。请先成功撤回 Preview。', await inspectIsolated(binding));
2203
+ }
2204
+ if (record.applyBaseOid && (record.delivery.state === 'working' || record.delivery.state === 'ready_for_review')) {
2205
+ return operationError('operation_not_allowed', '该历史 Worktree 已通过旧版 Apply 写入 Local;不会自动 Discard,请先人工核对 Local 的未提交修改。', await inspectIsolated(binding));
2206
+ }
2207
+ if (record.delivery.state === 'preview_active') {
2208
+ if (!input.rollbackPreview) {
2209
+ return operationError('preview_not_active', 'Local 正在验收本任务;放弃任务前必须先安全撤回 Preview', await inspectIsolated(binding));
2210
+ }
2211
+ const rollback = await dependencies.applyEngine.rollback({
2212
+ localPath: record.localRoot,
2213
+ receipt: record.delivery.preview,
2214
+ });
2215
+ if (rollback.status === 'error') {
2216
+ if (rollback.error.code === 'stale_local' || rollback.error.code === 'preview_modified') {
2217
+ return detachPreviewAfterLocalDrift(record, binding, rollback.error.code, 'discard');
2218
+ }
2219
+ return operationError(rollback.error.code, rollback.error.message, await inspectIsolated(binding));
2220
+ }
2221
+ const previewId = record.delivery.preview.previewId;
2222
+ const iteration = record.delivery.review.iteration;
2223
+ const rolledBack = updateManagedCheckout(record.checkoutId, (current) => ({
2224
+ ...current,
2225
+ delivery: { state: 'working', iteration },
2226
+ journal: null,
2227
+ revision: current.revision + 1,
2228
+ }));
2229
+ if (!rolledBack)
2230
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
2231
+ await releasePreviewArtifactsBestEffort(record, previewId);
2232
+ record = rolledBack;
2233
+ }
2234
+ // Harness plugin has no inherited collaborator checkout ownership.
1487
2235
  if (record.phase === 'recovery_required' && !dependencies.files.exists(record.managedRoot)) {
1488
2236
  await releaseApplyBaseBestEffort(record);
1489
2237
  updateManagedCheckout(record.checkoutId, (current) => ({
@@ -1579,6 +2327,12 @@ export function createSessionCheckoutModule(dependencies) {
1579
2327
  return await operateApply(input, binding);
1580
2328
  if (input.action === 'finish')
1581
2329
  return await operateFinish(input, binding);
2330
+ if (input.action === 'preview')
2331
+ return await operatePreview(input, binding);
2332
+ if (input.action === 'rollback_preview')
2333
+ return await operateRollbackPreview(input, binding);
2334
+ if (input.action === 'finalize_preview')
2335
+ return await operateFinalizePreview(input, binding);
1582
2336
  if (input.action === 'retry_cleanup')
1583
2337
  return await operateRetryCleanup(input, binding);
1584
2338
  if (input.action === 'discard')
@@ -1604,6 +2358,10 @@ export function createSessionCheckoutModule(dependencies) {
1604
2358
  return record.journal?.startedAt ?? 0;
1605
2359
  if (record.delivery.state === 'ready_for_review')
1606
2360
  return record.delivery.review.preparedAt;
2361
+ if (record.delivery.state === 'preview_active')
2362
+ return record.delivery.preview.previewedAt;
2363
+ if (record.delivery.state === 'preview_detached')
2364
+ return record.delivery.detachedAt;
1607
2365
  if (record.delivery.state === 'finalized')
1608
2366
  return record.delivery.finalizedAt;
1609
2367
  if (record.delivery.state === 'retained')
@@ -1664,11 +2422,15 @@ export function createSessionCheckoutModule(dependencies) {
1664
2422
  ? delivery.cleanup === 'blocked' ? 'needs_attention' : 'retained'
1665
2423
  : delivery.state === 'finalized'
1666
2424
  ? delivery.cleanup === 'blocked' ? 'needs_attention' : 'cleanup_pending'
1667
- : delivery.state === 'ready_for_review'
1668
- ? 'ready_for_review'
1669
- : delivery.state === 'delivered'
1670
- ? 'delivered'
1671
- : 'working';
2425
+ : delivery.state === 'preview_active'
2426
+ ? 'preview_active'
2427
+ : delivery.state === 'preview_detached'
2428
+ ? 'needs_attention'
2429
+ : delivery.state === 'ready_for_review'
2430
+ ? 'ready_for_review'
2431
+ : delivery.state === 'delivered'
2432
+ ? 'delivered'
2433
+ : 'working';
1672
2434
  const commitOid = delivery.state === 'finalized' || delivery.state === 'retained' || delivery.state === 'delivered'
1673
2435
  ? delivery.commitOid
1674
2436
  : null;
@@ -1709,6 +2471,9 @@ export function createSessionCheckoutModule(dependencies) {
1709
2471
  return cleanupBlocked('working', '当前轮次仍在修改,尚未形成可清理的交付环境。', record.revision);
1710
2472
  if (record.delivery.state === 'ready_for_review')
1711
2473
  return cleanupBlocked('review_pending', '当前轮次正在等待验收,不能清理。', record.revision);
2474
+ if (record.delivery.state === 'preview_active' || record.delivery.state === 'preview_detached') {
2475
+ return cleanupBlocked('preview_active', 'Local Preview 尚未完成安全收口,不能清理。', record.revision);
2476
+ }
1712
2477
  if (record.delivery.state === 'delivered' || record.phase === 'discarded') {
1713
2478
  return cleanupBlocked('unknown', 'Worktree 已交付并解除管理,无需再次清理。', record.revision);
1714
2479
  }
@@ -1835,6 +2600,37 @@ export function createSessionCheckoutModule(dependencies) {
1835
2600
  .filter((summary) => !input.needsAttention || summary.state === 'needs_attention' || summary.state === 'cleanup_pending')
1836
2601
  .sort((left, right) => right.updatedAt - left.updatedAt);
1837
2602
  }
2603
+ async function listManagedWorktreesForSession(sessionId, input = {}) {
2604
+ const session = requireSession(sessionId);
2605
+ const persisted = getPersistedBinding(sessionId);
2606
+ const projectId = persisted?.projectId ?? session.projectId;
2607
+ if (!projectId)
2608
+ throw new SessionCheckoutError('project_not_found', '会话尚未关联项目');
2609
+ const allowedCheckoutIds = new Set(Object.values(dependencies.registry.read().managedCheckouts)
2610
+ .filter((record) => record.projectId === projectId)
2611
+ .filter((record) => record.ownerSessionId === sessionId || record.sourceSessionId === sessionId)
2612
+ .map((record) => record.checkoutId));
2613
+ const summaries = await listManagedWorktrees({ ...input, projectId });
2614
+ return summaries.filter((summary) => allowedCheckoutIds.has(summary.checkoutId));
2615
+ }
2616
+ async function manageManagedWorktreeForSession(sessionId, input) {
2617
+ requireSession(sessionId);
2618
+ const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
2619
+ if (!record)
2620
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
2621
+ if (record.ownerSessionId !== sessionId && record.sourceSessionId !== sessionId) {
2622
+ throw new SessionCheckoutError('not_owner', '当前 Session 无权管理该 Worktree');
2623
+ }
2624
+ if (record.ownerSessionId !== sessionId && dependencies.lookup.getSession(record.ownerSessionId)) {
2625
+ throw new SessionCheckoutError('not_owner', 'Owner Session 已接管该 Worktree,只有 owner 可以管理');
2626
+ }
2627
+ const persisted = getPersistedBinding(sessionId);
2628
+ const callerProjectId = persisted?.projectId ?? dependencies.lookup.getSession(sessionId)?.projectId;
2629
+ if (callerProjectId !== record.projectId) {
2630
+ throw new SessionCheckoutError('project_mismatch', '当前 Session 与 Worktree 不属于同一原始项目');
2631
+ }
2632
+ return manageManagedWorktree(input);
2633
+ }
1838
2634
  async function manageManagedWorktree(input) {
1839
2635
  const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
1840
2636
  if (!record)
@@ -1842,12 +2638,15 @@ export function createSessionCheckoutModule(dependencies) {
1842
2638
  if (record.revision !== input.expectedRevision)
1843
2639
  throw new SessionCheckoutError('stale_target', 'Worktree 状态已变化,请刷新后重试');
1844
2640
  if (input.action === 'discard') {
1845
- const result = await operateTarget({
2641
+ // Caller authorization is performed by the scoped wrapper (or a trusted Host manager).
2642
+ // The reserved owner Session may not exist yet, so do not re-resolve it through lookup.
2643
+ const result = await operateDiscard({
1846
2644
  action: 'discard',
1847
2645
  sessionId: record.ownerSessionId,
1848
2646
  expectedRevision: input.expectedRevision,
1849
2647
  confirmDirty: input.confirmDirty === true,
1850
- });
2648
+ ...(input.rollbackPreview === undefined ? {} : { rollbackPreview: input.rollbackPreview }),
2649
+ }, bindingForManagedRecord(record));
1851
2650
  if (result.status === 'error')
1852
2651
  throw new SessionCheckoutError(result.code, result.message);
1853
2652
  const updated = dependencies.registry.read().managedCheckouts[record.checkoutId];
@@ -1918,8 +2717,180 @@ export function createSessionCheckoutModule(dependencies) {
1918
2717
  }
1919
2718
  return cleaned;
1920
2719
  }
1921
- async function bindTarget(sessionId, choice, createAttempt = 0, requestStartedAt = Date.now()) {
2720
+ async function beginNextIterationTarget(sessionId, expectedRevision) {
1922
2721
  const session = requireSession(sessionId);
2722
+ const registry = dependencies.registry.read();
2723
+ const previousBinding = registry.sessionBindings[sessionId];
2724
+ if (!previousBinding || previousBinding.target.kind !== 'isolated') {
2725
+ throw new SessionCheckoutError('operation_not_allowed', '只有已交付的 Isolated Session 可以开始下一轮');
2726
+ }
2727
+ const predecessor = registry.managedCheckouts[previousBinding.target.checkoutId];
2728
+ if (!predecessor)
2729
+ throw new SessionCheckoutError('checkout_missing', '上一轮 Worktree 记录不存在');
2730
+ if (predecessor.ownerSessionId !== sessionId || previousBinding.ownerSessionId !== sessionId) {
2731
+ throw new SessionCheckoutError('not_owner', '只有 owner Session 可以开始下一轮');
2732
+ }
2733
+ if (predecessor.revision !== expectedRevision) {
2734
+ throw new SessionCheckoutError('stale_target', 'Worktree 状态已变化,请刷新后再开始下一轮');
2735
+ }
2736
+ if (predecessor.phase !== 'discarded' || predecessor.delivery.state !== 'delivered') {
2737
+ throw new SessionCheckoutError('operation_not_allowed', '只有已成功清理的交付状态可以开始下一轮');
2738
+ }
2739
+ if (!session.projectId)
2740
+ throw new SessionCheckoutError('project_not_found', '当前 Session 尚未关联 Workspace');
2741
+ const ownerWorkspace = dependencies.lookup.getProject(session.projectId);
2742
+ if (!ownerWorkspace || !resolvedPathsEqual(ownerWorkspace.root, predecessor.managedRoot)) {
2743
+ throw new SessionCheckoutError('project_mismatch', '当前 Session 的 immutable cwd 与上一轮 Worktree 不一致');
2744
+ }
2745
+ if (dependencies.files.exists(predecessor.managedGitRoot) || dependencies.files.exists(predecessor.managedRoot)) {
2746
+ throw new SessionCheckoutError('checkout_mismatch', '上一轮 Worktree 路径已重新出现,拒绝覆盖未知内容');
2747
+ }
2748
+ const localProject = dependencies.lookup.getProject(predecessor.projectId);
2749
+ if (!localProject || !dependencies.files.exists(localProject.root) || !dependencies.files.exists(predecessor.localRoot)) {
2750
+ throw new SessionCheckoutError('project_root_missing', '原始 Local 项目已不可用,不能开始下一轮');
2751
+ }
2752
+ const canonicalProjectRoot = await dependencies.files.canonicalize(localProject.root);
2753
+ const canonicalLocalRoot = await dependencies.files.canonicalize(predecessor.localRoot);
2754
+ if (!pathsEqual(canonicalProjectRoot, canonicalLocalRoot)) {
2755
+ throw new SessionCheckoutError('project_mismatch', '原始 Local 项目身份已变化');
2756
+ }
2757
+ const snapshot = await dependencies.git.inspect(canonicalLocalRoot);
2758
+ if (!snapshot || !pathsEqual(snapshot.commonDir, predecessor.gitCommonDir)) {
2759
+ throw new SessionCheckoutError('checkout_mismatch', '原始 Local Git 身份已变化');
2760
+ }
2761
+ const projectRelativePath = relative(snapshot.root, canonicalLocalRoot);
2762
+ if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath)) {
2763
+ throw new SessionCheckoutError('checkout_mismatch', '项目根目录不在其 Git checkout 内');
2764
+ }
2765
+ if (!resolvedPathsEqual(resolve(predecessor.managedGitRoot, projectRelativePath), predecessor.managedRoot)) {
2766
+ throw new SessionCheckoutError('checkout_mismatch', '上一轮 managed project 路径无法从 Local 身份重建');
2767
+ }
2768
+ const managedContainer = dirname(predecessor.managedGitRoot);
2769
+ if (!dependencies.files.exists(managedContainer))
2770
+ dependencies.files.ensureDirectory(managedContainer);
2771
+ const containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
2772
+ if (!containerIdentity)
2773
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree 容器不是可信目录');
2774
+ const canonicalContainer = await dependencies.files.canonicalize(managedContainer);
2775
+ if (!resolvedPathsEqual(canonicalContainer, managedContainer)) {
2776
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree 容器路径已被重定向');
2777
+ }
2778
+ const checkoutId = dependencies.createCheckoutId();
2779
+ const iteration = predecessor.delivery.iteration + 1;
2780
+ const record = {
2781
+ checkoutId,
2782
+ predecessorCheckoutId: predecessor.checkoutId,
2783
+ projectId: predecessor.projectId,
2784
+ projectName: predecessor.projectName,
2785
+ ownerSessionId: sessionId,
2786
+ ...(predecessor.sourceSessionId ? { sourceSessionId: predecessor.sourceSessionId } : {}),
2787
+ localRoot: canonicalLocalRoot,
2788
+ managedRoot: predecessor.managedRoot,
2789
+ managedGitRoot: predecessor.managedGitRoot,
2790
+ gitCommonDir: snapshot.commonDir,
2791
+ gitDir: '',
2792
+ baseOid: snapshot.headOid,
2793
+ sourceRef: snapshot.headRef,
2794
+ phase: 'preparing',
2795
+ delivery: { state: 'working', iteration },
2796
+ journal: {
2797
+ operation: 'create',
2798
+ operationId: dependencies.createCheckoutId(),
2799
+ step: 'creating_worktree',
2800
+ startedAt: Date.now(),
2801
+ },
2802
+ revision: predecessor.revision + 1,
2803
+ };
2804
+ const binding = {
2805
+ sessionId,
2806
+ projectId: predecessor.projectId,
2807
+ projectName: predecessor.projectName,
2808
+ target: { kind: 'isolated', checkoutId },
2809
+ ownerSessionId: sessionId,
2810
+ sourceRef: snapshot.headRef,
2811
+ sourceOid: snapshot.headOid,
2812
+ revision: previousBinding.revision + 1,
2813
+ };
2814
+ registry.sessionBindings[sessionId] = binding;
2815
+ registry.managedCheckouts[checkoutId] = record;
2816
+ registry.revision += 1;
2817
+ dependencies.registry.write(registry);
2818
+ try {
2819
+ await dependencies.git.createDetachedWorktree(snapshot.root, predecessor.managedGitRoot, snapshot.headOid);
2820
+ const canonicalManagedGitRoot = await dependencies.files.canonicalize(predecessor.managedGitRoot);
2821
+ const canonicalManagedRoot = await dependencies.files.canonicalize(predecessor.managedRoot);
2822
+ const containerAfterCreate = await dependencies.files.inspectDirectoryIdentity(managedContainer);
2823
+ const created = await dependencies.git.inspect(canonicalManagedRoot);
2824
+ if (!containerAfterCreate
2825
+ || !directoryIdentitiesEqual(containerIdentity, containerAfterCreate)
2826
+ || !resolvedPathsEqual(canonicalManagedGitRoot, predecessor.managedGitRoot)
2827
+ || !resolvedPathsEqual(canonicalManagedRoot, predecessor.managedRoot)
2828
+ || !created
2829
+ || !pathsEqual(created.root, canonicalManagedGitRoot)
2830
+ || !pathsEqual(created.commonDir, snapshot.commonDir)
2831
+ || created.headOid !== snapshot.headOid) {
2832
+ throw new SessionCheckoutError('checkout_mismatch', '下一轮 checkout 的 Git 身份不匹配');
2833
+ }
2834
+ const readyRegistry = dependencies.registry.read();
2835
+ const current = readyRegistry.managedCheckouts[checkoutId];
2836
+ if (!current || current.phase !== 'preparing') {
2837
+ throw new SessionCheckoutError('stale_target', '下一轮 Worktree 创建期间状态已变化');
2838
+ }
2839
+ readyRegistry.managedCheckouts[checkoutId] = {
2840
+ ...current,
2841
+ managedRoot: canonicalManagedRoot,
2842
+ managedGitRoot: canonicalManagedGitRoot,
2843
+ gitDir: created.gitDir,
2844
+ phase: 'ready',
2845
+ journal: null,
2846
+ revision: current.revision + 1,
2847
+ };
2848
+ readyRegistry.revision += 1;
2849
+ dependencies.registry.write(readyRegistry);
2850
+ return inspectIsolated(binding);
2851
+ }
2852
+ catch (error) {
2853
+ let partialCheckout = null;
2854
+ try {
2855
+ if (dependencies.files.exists(join(predecessor.managedGitRoot, '.git'))) {
2856
+ partialCheckout = await dependencies.git.inspect(predecessor.managedRoot);
2857
+ }
2858
+ }
2859
+ catch {
2860
+ partialCheckout = null;
2861
+ }
2862
+ if (partialCheckout) {
2863
+ const current = dependencies.registry.read().managedCheckouts[checkoutId];
2864
+ if (current)
2865
+ markRecoveryRequired(current);
2866
+ throw error;
2867
+ }
2868
+ let residueRemoved = false;
2869
+ try {
2870
+ residueRemoved = dependencies.files.removeEmptyDirectoryTree(predecessor.managedGitRoot);
2871
+ }
2872
+ catch {
2873
+ residueRemoved = false;
2874
+ }
2875
+ if (!residueRemoved) {
2876
+ const current = dependencies.registry.read().managedCheckouts[checkoutId];
2877
+ if (current)
2878
+ markRecoveryRequired(current);
2879
+ throw new SessionCheckoutError('recovery_required', '下一轮 Worktree 创建失败且残余目录包含未知内容,已保留现场');
2880
+ }
2881
+ const failedRegistry = dependencies.registry.read();
2882
+ delete failedRegistry.managedCheckouts[checkoutId];
2883
+ const currentBinding = failedRegistry.sessionBindings[sessionId];
2884
+ if (currentBinding?.target.kind === 'isolated' && currentBinding.target.checkoutId === checkoutId) {
2885
+ failedRegistry.sessionBindings[sessionId] = { ...previousBinding, revision: previousBinding.revision + 1 };
2886
+ }
2887
+ failedRegistry.revision += 1;
2888
+ dependencies.registry.write(failedRegistry);
2889
+ throw new SessionCheckoutError('git_operation_failed', error instanceof Error ? error.message : String(error));
2890
+ }
2891
+ }
2892
+ async function bindTarget(sessionId, choice, createAttempt = 0, requestStartedAt = Date.now(), sourceSessionId = sessionId) {
2893
+ const session = requireSession(sourceSessionId);
1923
2894
  let nextIteration = 1;
1924
2895
  let replacedDeliveredBinding;
1925
2896
  const existing = getPersistedBinding(sessionId);
@@ -1953,7 +2924,7 @@ export function createSessionCheckoutModule(dependencies) {
1953
2924
  throw new SessionCheckoutError('target_already_bound', '会话已经绑定 Session Target,不能切换');
1954
2925
  }
1955
2926
  }
1956
- const { project } = await resolveSessionProject(sessionId);
2927
+ const { project } = await resolveSessionProject(sourceSessionId);
1957
2928
  const snapshot = await dependencies.git.inspect(project.root);
1958
2929
  if (choice.kind === 'local') {
1959
2930
  const binding = {
@@ -1979,9 +2950,75 @@ export function createSessionCheckoutModule(dependencies) {
1979
2950
  const checkoutId = dependencies.createCheckoutId();
1980
2951
  const localRoot = await dependencies.files.canonicalize(project.root);
1981
2952
  const localGitRoot = await dependencies.files.canonicalize(snapshot.root);
1982
- // DSH workspace-write 沙箱只允许项目根目录内写入,worktree 必须放在仓库内部。
1983
- const managedGitRoot = join(localGitRoot, '.dsh-worktrees');
1984
- dependencies.files.ensureDirectory(dirname(managedGitRoot));
2953
+ const repositoryKey = createManagedWorktreeRepositoryKey(localGitRoot);
2954
+ const pathCandidates = [8, 12, 32].map(identityLength => createManagedWorktreePathCandidates({
2955
+ localGitRoot,
2956
+ managedCheckoutsRoot: dependencies.managedCheckoutsRoot,
2957
+ repositoryKey,
2958
+ checkoutId,
2959
+ identityLength,
2960
+ }));
2961
+ // Prefer one repository-owned sibling container so Local never sees its
2962
+ // managed worktrees as untracked. A container that is a file/symlink is
2963
+ // untrusted and falls back without modification. Existing child paths
2964
+ // are never reused or cleaned: extend the trusted checkout identity.
2965
+ const outerContainingRoot = await dependencies.git.findContainingWorktreeRoot(dirname(localGitRoot));
2966
+ const siblingWouldPolluteOuter = outerContainingRoot !== null && !pathsEqual(outerContainingRoot, localGitRoot);
2967
+ const usingSibling = createAttempt === 0 && !siblingWouldPolluteOuter;
2968
+ const managedContainer = usingSibling
2969
+ ? pathCandidates[0].siblingContainer
2970
+ : pathCandidates[0].fallbackContainer;
2971
+ const knownContainer = Object.values(dependencies.registry.read().managedCheckouts)
2972
+ .some(record => pathsEqual(dirname(record.managedGitRoot), managedContainer));
2973
+ const containerExisted = dependencies.files.exists(managedContainer);
2974
+ let containerIdentity = containerExisted
2975
+ ? await dependencies.files.inspectDirectoryIdentity(managedContainer)
2976
+ : null;
2977
+ if (containerExisted && containerIdentity !== null && !knownContainer) {
2978
+ // A same-named directory with unknown content is not ours. An empty
2979
+ // directory has no user bytes to preserve, so remove/recreate it as the
2980
+ // ownership boundary; a non-empty tree is left untouched and rejected.
2981
+ let reclaimedEmpty = false;
2982
+ try {
2983
+ reclaimedEmpty = dependencies.files.removeEmptyDirectoryTree(managedContainer);
2984
+ }
2985
+ catch { /* fail closed below */ }
2986
+ if (!reclaimedEmpty)
2987
+ containerIdentity = null;
2988
+ else {
2989
+ try {
2990
+ dependencies.files.ensureDirectory(managedContainer);
2991
+ containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
2992
+ }
2993
+ catch {
2994
+ containerIdentity = null;
2995
+ }
2996
+ }
2997
+ }
2998
+ else if (!containerExisted) {
2999
+ try {
3000
+ dependencies.files.ensureDirectory(managedContainer);
3001
+ containerIdentity = await dependencies.files.inspectDirectoryIdentity(managedContainer);
3002
+ }
3003
+ catch {
3004
+ containerIdentity = null;
3005
+ }
3006
+ }
3007
+ if (containerIdentity === null) {
3008
+ if (usingSibling) {
3009
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
3010
+ }
3011
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree 回退容器不是可信目录,未创建或修改任何 checkout');
3012
+ }
3013
+ const managedGitRoot = pathCandidates
3014
+ .map(candidate => usingSibling ? candidate.siblingRoot : candidate.fallbackRoot)
3015
+ .find(candidate => !dependencies.files.exists(candidate));
3016
+ if (managedGitRoot === undefined) {
3017
+ if (usingSibling) {
3018
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
3019
+ }
3020
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree Checkout identity 路径均已存在,拒绝覆盖未知目录');
3021
+ }
1985
3022
  const projectRelativePath = relative(localGitRoot, localRoot);
1986
3023
  if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath)) {
1987
3024
  throw new SessionCheckoutError('checkout_mismatch', '项目根目录不在其 Git checkout 内');
@@ -1992,6 +3029,7 @@ export function createSessionCheckoutModule(dependencies) {
1992
3029
  projectId: project.id,
1993
3030
  projectName: project.name,
1994
3031
  ownerSessionId: sessionId,
3032
+ sourceSessionId,
1995
3033
  localRoot,
1996
3034
  managedRoot: resolve(managedRoot),
1997
3035
  managedGitRoot: resolve(managedGitRoot),
@@ -2090,27 +3128,51 @@ export function createSessionCheckoutModule(dependencies) {
2090
3128
  failedRegistry.revision += 1;
2091
3129
  dependencies.registry.write(failedRegistry);
2092
3130
  if (createAttempt < 1) {
2093
- return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt);
3131
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt, sourceSessionId);
2094
3132
  }
2095
3133
  throw new SessionCheckoutError('git_operation_failed', 'Worktree 创建失败,已安全清理残余目录,可直接重试');
2096
3134
  }
2097
3135
  }
2098
3136
  return {
2099
3137
  inspect: inspectAvailable,
3138
+ runtimeContext,
2100
3139
  preflight: (sessionId, expectedRevision) => withBindingLock(() => preflightTarget(sessionId, expectedRevision)),
2101
3140
  runExclusiveSessionMutation: (sessionId, operation) => withBindingLock(async () => (operation(await inspectTarget(sessionId, true)))),
2102
3141
  bind: (sessionId, choice) => {
2103
3142
  const requestStartedAt = Date.now();
2104
3143
  return withBindingLock(() => bindTarget(sessionId, choice, 0, requestStartedAt));
2105
3144
  },
2106
- beginNextIteration: (sessionId) => {
2107
- const requestStartedAt = Date.now();
2108
- return withBindingLock(() => bindTarget(sessionId, { kind: 'isolated' }, 0, requestStartedAt));
2109
- },
3145
+ createIsolatedTarget: (sourceSessionId, targetSessionId) => withBindingLock(async () => {
3146
+ if (!targetSessionId || targetSessionId === sourceSessionId) {
3147
+ throw new SessionCheckoutError('invalid_input', 'Isolated Target 必须使用独立的预分配 Session ID');
3148
+ }
3149
+ if (dependencies.lookup.getSession(targetSessionId) && !getPersistedBinding(targetSessionId)) {
3150
+ throw new SessionCheckoutError('target_already_bound', '目标 Session ID 已被其他 Workspace 使用');
3151
+ }
3152
+ const sourceBinding = getPersistedBinding(sourceSessionId);
3153
+ if (!sourceBinding) {
3154
+ await bindTarget(sourceSessionId, { kind: 'local' });
3155
+ }
3156
+ else if (sourceBinding.target.kind !== 'local') {
3157
+ throw new SessionCheckoutError('operation_not_allowed', '只能从 Local Session 创建新的 Isolated Target');
3158
+ }
3159
+ const target = await bindTarget(targetSessionId, { kind: 'isolated' }, 0, Date.now(), sourceSessionId);
3160
+ const binding = getPersistedBinding(targetSessionId);
3161
+ const record = binding?.target.kind === 'isolated'
3162
+ ? dependencies.registry.read().managedCheckouts[binding.target.checkoutId]
3163
+ : undefined;
3164
+ if (!record)
3165
+ throw new SessionCheckoutError('checkout_missing', 'Isolated Target 创建后记录缺失');
3166
+ return { targetSessionId, managedRoot: record.managedRoot, target };
3167
+ }),
3168
+ beginNextIteration: (sessionId, expectedRevision) => withBindingLock(() => beginNextIterationTarget(sessionId, expectedRevision)),
3169
+ resumeRevision: (sessionId, expectedRevision, expectedReviewId) => withBindingLock(() => resumeRevisionTarget(sessionId, expectedRevision, expectedReviewId)),
2110
3170
  markReadyForReview: (sessionId, input) => withBindingLock(() => markReadyForReviewTarget(sessionId, input)),
2111
3171
  operate: (input) => withBindingLock(() => operateTarget(input)),
2112
3172
  // 只读管理列表不占用全局 mutation lock;慢速目录诊断与用户操作互不阻塞。
2113
3173
  listManagedWorktrees,
3174
+ listManagedWorktreesForSession,
3175
+ manageManagedWorktreeForSession: (sessionId, input) => withBindingLock(() => manageManagedWorktreeForSession(sessionId, input)),
2114
3176
  inspectManagedWorktreeCleanup,
2115
3177
  bulkCleanupManagedWorktrees: (candidates) => withBindingLock(() => bulkCleanupManagedWorktrees(candidates)),
2116
3178
  manageManagedWorktree: (input) => withBindingLock(() => manageManagedWorktree(input)),