dsh-git-worktree 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/README.zh.md +37 -0
  4. package/lib/adapters/files.d.ts +10 -0
  5. package/lib/adapters/files.js +101 -0
  6. package/lib/adapters/files.js.map +1 -0
  7. package/lib/adapters/git.d.ts +18 -0
  8. package/lib/adapters/git.js +172 -0
  9. package/lib/adapters/git.js.map +1 -0
  10. package/lib/adapters/lookup.d.ts +28 -0
  11. package/lib/adapters/lookup.js +41 -0
  12. package/lib/adapters/lookup.js.map +1 -0
  13. package/lib/adapters/registry.d.ts +15 -0
  14. package/lib/adapters/registry.js +242 -0
  15. package/lib/adapters/registry.js.map +1 -0
  16. package/lib/adapters/safe-file.d.ts +19 -0
  17. package/lib/adapters/safe-file.js +94 -0
  18. package/lib/adapters/safe-file.js.map +1 -0
  19. package/lib/commands.d.ts +12 -0
  20. package/lib/commands.js +121 -0
  21. package/lib/commands.js.map +1 -0
  22. package/lib/index.d.ts +76 -0
  23. package/lib/index.js +93 -0
  24. package/lib/index.js.map +1 -0
  25. package/lib/managed-worktree-path.d.ts +27 -0
  26. package/lib/managed-worktree-path.js +53 -0
  27. package/lib/managed-worktree-path.js.map +1 -0
  28. package/lib/ports.d.ts +204 -0
  29. package/lib/ports.js +10 -0
  30. package/lib/ports.js.map +1 -0
  31. package/lib/session-checkout-apply.d.ts +110 -0
  32. package/lib/session-checkout-apply.js +753 -0
  33. package/lib/session-checkout-apply.js.map +1 -0
  34. package/lib/session-checkout-module.d.ts +3 -0
  35. package/lib/session-checkout-module.js +2122 -0
  36. package/lib/session-checkout-module.js.map +1 -0
  37. package/lib/tools.d.ts +12 -0
  38. package/lib/tools.js +384 -0
  39. package/lib/tools.js.map +1 -0
  40. package/lib/types.d.ts +275 -0
  41. package/lib/types.js +40 -0
  42. package/lib/types.js.map +1 -0
  43. package/package.json +60 -0
@@ -0,0 +1,2122 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { SessionCheckoutError } from './index.js';
4
+ const UNVERSIONED_OID = 'unversioned';
5
+ const UNVERSIONED_REF = 'WORKING_TREE';
6
+ const RETENTION_24H_MS = 24 * 60 * 60 * 1000;
7
+ const RETENTION_3D_MS = 3 * RETENTION_24H_MS;
8
+ const CLEANUP_IDENTITY_CHANGED_MESSAGE = 'Worktree 的 Git 身份或路径已变化,未执行清理。';
9
+ const CLEANUP_RESIDUE_MESSAGE = 'Git Worktree 已解除注册,仅剩物理目录残余;可重试清理环境。';
10
+ const TRANSIENT_CLEANUP_RETRY_DELAYS_MS = [100, 300, 800];
11
+ /**
12
+ * 单个 checkout 清理/维护操作的启动收敛超时。
13
+ * Windows 上 Worktree 被外部进程(如残留 Agent 运行)占用时,git/fs 操作可能无限期挂起;
14
+ * 启动收敛必须在有限时间内继续,不能因为一个损坏记录卡住整个应用启动。
15
+ */
16
+ const CHECKOUT_CLEANUP_TIMEOUT_MS = 30_000;
17
+ async function withCleanupTimeout(checkoutId, operation) {
18
+ let timeoutHandle;
19
+ try {
20
+ return await Promise.race([
21
+ operation(),
22
+ new Promise((resolve) => {
23
+ timeoutHandle = setTimeout(() => {
24
+ console.warn(`[session-checkout] ${checkoutId.slice(0, 8)} 清理超时(${CHECKOUT_CLEANUP_TIMEOUT_MS}ms),已跳过本次收敛`);
25
+ resolve(null);
26
+ }, CHECKOUT_CLEANUP_TIMEOUT_MS);
27
+ }),
28
+ ]);
29
+ }
30
+ finally {
31
+ if (timeoutHandle)
32
+ clearTimeout(timeoutHandle);
33
+ }
34
+ }
35
+ function cleanupReasonForMessage(message) {
36
+ if (message.includes('提交后出现了新修改'))
37
+ return 'modified_after_finalize';
38
+ if (message.includes('解除注册') || message.includes('目录残余'))
39
+ return 'detached_residue';
40
+ if (message.includes('quarantine'))
41
+ return 'quarantine_busy';
42
+ if (message.includes('身份') || message.includes('记录') || message.includes('Local index'))
43
+ return 'identity_changed';
44
+ return 'directory_busy';
45
+ }
46
+ function isTransientCleanupError(error) {
47
+ const code = typeof error === 'object' && error !== null && 'code' in error
48
+ ? String(error.code ?? '')
49
+ : '';
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ return /^(EBUSY|EPERM|EACCES|ENOTEMPTY)$/i.test(code)
52
+ || /EBUSY|EPERM|EACCES|ENOTEMPTY|being used|access is denied|permission denied/i.test(message);
53
+ }
54
+ async function retryTransientCleanup(operation) {
55
+ let lastError;
56
+ for (let attempt = 0; attempt <= TRANSIENT_CLEANUP_RETRY_DELAYS_MS.length; attempt += 1) {
57
+ try {
58
+ return await operation();
59
+ }
60
+ catch (error) {
61
+ lastError = error;
62
+ if (!isTransientCleanupError(error) || attempt === TRANSIENT_CLEANUP_RETRY_DELAYS_MS.length)
63
+ throw error;
64
+ await new Promise((resolve) => setTimeout(resolve, TRANSIENT_CLEANUP_RETRY_DELAYS_MS[attempt] ?? 0));
65
+ }
66
+ }
67
+ throw lastError;
68
+ }
69
+ function retentionExpiresAt(mode, retainedAt) {
70
+ if (mode === 'retain_24h')
71
+ return retainedAt + RETENTION_24H_MS;
72
+ if (mode === 'retain_3d')
73
+ return retainedAt + RETENTION_3D_MS;
74
+ return null;
75
+ }
76
+ function pathForIdentity(path) {
77
+ try {
78
+ return realpathSync.native(resolve(path)).replace(/\\/g, '/');
79
+ }
80
+ catch {
81
+ return resolve(path).replace(/\\/g, '/');
82
+ }
83
+ }
84
+ function pathsEqual(left, right) {
85
+ const normalizedLeft = pathForIdentity(left);
86
+ const normalizedRight = pathForIdentity(right);
87
+ return process.platform === 'win32'
88
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
89
+ : normalizedLeft === normalizedRight;
90
+ }
91
+ /** canonical 路径与 registry 原始路径比较时不得再次 realpath,否则 Junction 会冒充原目录。 */
92
+ function resolvedPathsEqual(left, right) {
93
+ const normalizedLeft = resolve(left).replace(/\\/g, '/');
94
+ const normalizedRight = resolve(right).replace(/\\/g, '/');
95
+ return process.platform === 'win32'
96
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
97
+ : normalizedLeft === normalizedRight;
98
+ }
99
+ export function createSessionCheckoutModule(dependencies) {
100
+ function requireSession(sessionId) {
101
+ const session = dependencies.lookup.getSession(sessionId);
102
+ if (!session)
103
+ throw new SessionCheckoutError('session_not_found', `会话不存在: ${sessionId}`);
104
+ return session;
105
+ }
106
+ async function resolveSessionProject(sessionId) {
107
+ const session = requireSession(sessionId);
108
+ if (!session.projectId)
109
+ throw new SessionCheckoutError('project_not_found', '会话尚未关联项目');
110
+ const project = dependencies.lookup.getProject(session.projectId);
111
+ if (!project)
112
+ throw new SessionCheckoutError('project_not_found', `项目不存在: ${session.projectId}`);
113
+ if (!dependencies.files.exists(project.root)) {
114
+ throw new SessionCheckoutError('project_root_missing', `项目根目录不存在: ${project.name}`);
115
+ }
116
+ return { session, project };
117
+ }
118
+ async function inspectLocal(binding) {
119
+ const project = dependencies.lookup.getProject(binding.projectId);
120
+ if (!project || !dependencies.files.exists(project.root))
121
+ return recoveryView(binding);
122
+ const snapshot = await dependencies.git.inspect(project.root);
123
+ const current = snapshot
124
+ ? { branch: snapshot.branch, oid: snapshot.headOid }
125
+ : { branch: null, oid: UNVERSIONED_OID };
126
+ const status = snapshot ? await dependencies.git.status(project.root) : { dirty: false };
127
+ return {
128
+ project: { id: binding.projectId, name: project.name },
129
+ checkout: {
130
+ id: `local:${binding.projectId}`,
131
+ kind: 'local',
132
+ label: 'Local Checkout',
133
+ phase: 'ready',
134
+ },
135
+ source: { ref: binding.sourceRef, oid: binding.sourceOid },
136
+ current,
137
+ ownership: binding.inheritedFromSessionId ? 'inherited' : 'owner',
138
+ dirty: status.dirty,
139
+ revision: binding.revision,
140
+ };
141
+ }
142
+ function projectDelivery(record, commitInLocalHistory = null) {
143
+ const delivery = record.delivery;
144
+ const projectProof = (proof) => proof
145
+ ? {
146
+ localBranch: proof.localBranch,
147
+ localHeadBefore: proof.localHeadBefore,
148
+ localHeadAfter: proof.localHeadAfter,
149
+ changedFiles: [...proof.changedFiles],
150
+ commitInLocalHistory,
151
+ }
152
+ : undefined;
153
+ if (delivery.state === 'working')
154
+ return delivery;
155
+ if (delivery.state === 'ready_for_review')
156
+ return { state: delivery.state, review: delivery.review };
157
+ if (delivery.state === 'finalized') {
158
+ return {
159
+ state: delivery.state,
160
+ review: delivery.review,
161
+ commitOid: delivery.commitOid,
162
+ ...(projectProof(delivery.proof) ? { proof: projectProof(delivery.proof) } : {}),
163
+ cleanup: delivery.cleanup,
164
+ ...(delivery.cleanupMessage ? { cleanupMessage: delivery.cleanupMessage } : {}),
165
+ };
166
+ }
167
+ if (delivery.state === 'retained') {
168
+ return {
169
+ state: delivery.state,
170
+ review: delivery.review,
171
+ commitOid: delivery.commitOid,
172
+ ...(projectProof(delivery.proof) ? { proof: projectProof(delivery.proof) } : {}),
173
+ retention: delivery.retention,
174
+ retainedAt: delivery.retainedAt,
175
+ expiresAt: delivery.expiresAt,
176
+ cleanup: delivery.cleanup,
177
+ ...(delivery.cleanupMessage ? { cleanupMessage: delivery.cleanupMessage } : {}),
178
+ };
179
+ }
180
+ if (delivery.state === 'delivered') {
181
+ const deliveredProof = projectProof(delivery.proof);
182
+ return {
183
+ state: delivery.state,
184
+ iteration: delivery.iteration,
185
+ commitOid: delivery.commitOid,
186
+ deliveredAt: delivery.deliveredAt,
187
+ ...(deliveredProof ? { proof: deliveredProof } : {}),
188
+ };
189
+ }
190
+ return delivery;
191
+ }
192
+ function bindingForManagedRecord(record) {
193
+ const persisted = dependencies.registry.read().sessionBindings[record.ownerSessionId];
194
+ if (persisted?.target.kind === 'isolated' && persisted.target.checkoutId === record.checkoutId)
195
+ return persisted;
196
+ return {
197
+ sessionId: record.ownerSessionId,
198
+ projectId: record.projectId,
199
+ projectName: record.projectName,
200
+ target: { kind: 'isolated', checkoutId: record.checkoutId },
201
+ ownerSessionId: record.ownerSessionId,
202
+ sourceRef: record.sourceRef,
203
+ sourceOid: record.baseOid,
204
+ revision: record.revision,
205
+ };
206
+ }
207
+ function recoveryView(binding, record, dirty = false) {
208
+ const phase = record?.phase === 'discarded' ? 'discarded' : 'recovery_required';
209
+ return {
210
+ project: { id: binding.projectId, name: binding.projectName },
211
+ checkout: {
212
+ id: record?.checkoutId ?? (binding.target.kind === 'isolated' ? binding.target.checkoutId : `local:${binding.projectId}`),
213
+ kind: binding.target.kind === 'isolated' ? 'isolated' : 'local',
214
+ label: binding.target.kind === 'isolated' ? 'Isolated Checkout' : 'Local Checkout',
215
+ phase,
216
+ },
217
+ source: { ref: binding.sourceRef, oid: binding.sourceOid },
218
+ current: { branch: null, oid: binding.sourceOid },
219
+ ownership: binding.inheritedFromSessionId ? 'inherited' : 'owner',
220
+ dirty,
221
+ revision: record?.revision ?? binding.revision,
222
+ ...(record ? { delivery: projectDelivery(record) } : {}),
223
+ };
224
+ }
225
+ async function validateCommittedLocalCheckout(binding, record) {
226
+ if (binding.target.kind !== 'isolated'
227
+ || binding.target.checkoutId !== record.checkoutId
228
+ || binding.projectId !== record.projectId
229
+ || binding.ownerSessionId !== record.ownerSessionId)
230
+ return undefined;
231
+ try {
232
+ const project = dependencies.lookup.getProject(record.projectId);
233
+ if (!project || !dependencies.files.exists(project.root) || !dependencies.files.exists(record.localRoot))
234
+ return undefined;
235
+ const canonicalProjectRoot = await dependencies.files.canonicalize(project.root);
236
+ const canonicalLocalRoot = await dependencies.files.canonicalize(record.localRoot);
237
+ if (!pathsEqual(canonicalProjectRoot, canonicalLocalRoot))
238
+ return undefined;
239
+ const snapshot = await dependencies.git.inspect(canonicalLocalRoot);
240
+ if (!snapshot || !pathsEqual(snapshot.commonDir, record.gitCommonDir))
241
+ return undefined;
242
+ return { canonicalLocalRoot, snapshot };
243
+ }
244
+ catch {
245
+ return undefined;
246
+ }
247
+ }
248
+ async function committedFollowupView(binding, record) {
249
+ if (record.delivery.state !== 'finalized'
250
+ && record.delivery.state !== 'retained'
251
+ && record.delivery.state !== 'delivered')
252
+ return recoveryView(binding, record, true);
253
+ const local = await validateCommittedLocalCheckout(binding, record);
254
+ if (!local)
255
+ return recoveryView(binding, record, true);
256
+ const localSnapshot = local.snapshot;
257
+ let commitInLocalHistory = null;
258
+ if (record.delivery.commitOid) {
259
+ try {
260
+ commitInLocalHistory = await dependencies.git.isAncestor(local.canonicalLocalRoot, record.delivery.commitOid, localSnapshot.headOid);
261
+ }
262
+ catch {
263
+ commitInLocalHistory = null;
264
+ }
265
+ }
266
+ const delivery = projectDelivery(record, commitInLocalHistory);
267
+ if (!delivery || (delivery.state !== 'finalized' && delivery.state !== 'retained' && delivery.state !== 'delivered')) {
268
+ return recoveryView(binding, record, true);
269
+ }
270
+ return {
271
+ project: { id: record.projectId, name: record.projectName },
272
+ checkout: {
273
+ id: record.checkoutId,
274
+ kind: 'isolated',
275
+ label: 'Isolated Checkout',
276
+ // Commit 已是权威交付事实;残余环境异常只能影响 cleanup,不能降级整个会话。
277
+ phase: delivery.state === 'retained' ? 'retained' : delivery.state === 'delivered' ? 'discarded' : 'finalized',
278
+ },
279
+ source: { ref: record.sourceRef, oid: record.baseOid },
280
+ current: {
281
+ branch: localSnapshot.branch,
282
+ oid: localSnapshot.headOid,
283
+ },
284
+ ownership: binding.inheritedFromSessionId ? 'inherited' : 'owner',
285
+ dirty: true,
286
+ revision: record.revision,
287
+ delivery,
288
+ };
289
+ }
290
+ function markRecoveryRequired(record) {
291
+ if (record.phase === 'recovery_required' || record.phase === 'discarded')
292
+ return record;
293
+ const registry = dependencies.registry.read();
294
+ const current = registry.managedCheckouts[record.checkoutId];
295
+ if (!current)
296
+ return record;
297
+ if (current.revision !== record.revision || current.phase !== record.phase)
298
+ return current;
299
+ const recovered = {
300
+ ...current,
301
+ phase: 'recovery_required',
302
+ revision: current.revision + 1,
303
+ };
304
+ registry.managedCheckouts[record.checkoutId] = recovered;
305
+ registry.revision += 1;
306
+ dependencies.registry.write(registry);
307
+ return recovered;
308
+ }
309
+ async function validateManagedCheckoutDetailed(binding, record, requireCreateBase) {
310
+ if (binding.target.kind !== 'isolated'
311
+ || binding.target.checkoutId !== record.checkoutId
312
+ || binding.projectId !== record.projectId
313
+ || binding.ownerSessionId !== record.ownerSessionId
314
+ || !dependencies.files.exists(record.managedRoot))
315
+ return { status: 'invalid' };
316
+ try {
317
+ const canonicalManagedRoot = await dependencies.files.canonicalize(record.managedRoot);
318
+ const canonicalManagedGitRoot = await dependencies.files.canonicalize(record.managedGitRoot);
319
+ const canonicalLocalRoot = await dependencies.files.canonicalize(record.localRoot);
320
+ const project = dependencies.lookup.getProject(record.projectId);
321
+ if (project) {
322
+ if (!dependencies.files.exists(project.root))
323
+ return { status: 'invalid' };
324
+ const canonicalProjectRoot = await dependencies.files.canonicalize(project.root);
325
+ if (!pathsEqual(canonicalProjectRoot, canonicalLocalRoot))
326
+ return { status: 'invalid' };
327
+ }
328
+ const snapshot = await dependencies.git.inspect(canonicalManagedRoot);
329
+ const localSnapshot = await dependencies.git.inspect(canonicalLocalRoot);
330
+ if (!snapshot || !localSnapshot)
331
+ return { status: 'invalid' };
332
+ const projectRelativePath = relative(localSnapshot.root, canonicalLocalRoot);
333
+ if (projectRelativePath.startsWith('..')
334
+ || isAbsolute(projectRelativePath)
335
+ || !resolvedPathsEqual(canonicalManagedRoot, record.managedRoot)
336
+ || !resolvedPathsEqual(canonicalManagedGitRoot, record.managedGitRoot)
337
+ || !resolvedPathsEqual(canonicalLocalRoot, record.localRoot)
338
+ || !pathsEqual(snapshot.root, canonicalManagedGitRoot)
339
+ || !pathsEqual(snapshot.commonDir, record.gitCommonDir)
340
+ || !pathsEqual(localSnapshot.commonDir, record.gitCommonDir)
341
+ || !pathsEqual(resolve(snapshot.root, projectRelativePath), canonicalManagedRoot)
342
+ || (!requireCreateBase && !pathsEqual(snapshot.gitDir, record.gitDir))
343
+ || (requireCreateBase && snapshot.headOid !== record.baseOid))
344
+ return { status: 'invalid' };
345
+ const status = await dependencies.git.status(canonicalManagedRoot);
346
+ return {
347
+ status: 'valid',
348
+ checkout: { canonicalManagedRoot, canonicalManagedGitRoot, snapshot, status },
349
+ };
350
+ }
351
+ catch {
352
+ // Git 子进程超时、杀毒软件占用等瞬时 I/O 故障不能永久污染健康 Worktree。
353
+ return { status: 'unavailable' };
354
+ }
355
+ }
356
+ async function validateManagedCheckout(binding, record, requireCreateBase) {
357
+ const result = await validateManagedCheckoutDetailed(binding, record, requireCreateBase);
358
+ return result.status === 'valid' ? result.checkout : undefined;
359
+ }
360
+ function directoryIdentitiesEqual(left, right) {
361
+ return left.device === right.device && left.inode === right.inode && left.birthtimeNs === right.birthtimeNs;
362
+ }
363
+ function hasCleanupRemovalReceipt(record) {
364
+ return record.journal?.operation === 'cleanup'
365
+ && record.journal.step === 'removing_worktree'
366
+ && record.journal.managedDirectoryIdentity !== undefined;
367
+ }
368
+ function hasLegacyCleanupResidueEvidence(record) {
369
+ if (record.journal !== null)
370
+ return false;
371
+ if (record.delivery.state !== 'finalized' && record.delivery.state !== 'retained')
372
+ return false;
373
+ if (record.delivery.cleanup !== 'blocked')
374
+ return false;
375
+ if (record.delivery.cleanupMessage !== CLEANUP_IDENTITY_CHANGED_MESSAGE)
376
+ return false;
377
+ const checkoutIdentity = record.checkoutId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
378
+ return checkoutIdentity.length > 0 && basename(record.managedGitRoot).endsWith(`--${checkoutIdentity}`);
379
+ }
380
+ async function validateCleanupLocalIdentity(record) {
381
+ if (!dependencies.files.exists(record.localRoot))
382
+ return undefined;
383
+ const project = dependencies.lookup.getProject(record.projectId);
384
+ if (!project || !dependencies.files.exists(project.root))
385
+ return undefined;
386
+ try {
387
+ const canonicalLocalRoot = await dependencies.files.canonicalize(record.localRoot);
388
+ const canonicalProjectRoot = await dependencies.files.canonicalize(project.root);
389
+ if (!resolvedPathsEqual(canonicalLocalRoot, record.localRoot) || !pathsEqual(canonicalProjectRoot, canonicalLocalRoot))
390
+ return undefined;
391
+ const localSnapshot = await dependencies.git.inspect(canonicalLocalRoot);
392
+ if (!localSnapshot || !pathsEqual(localSnapshot.commonDir, record.gitCommonDir))
393
+ return undefined;
394
+ const projectRelativePath = relative(localSnapshot.root, canonicalLocalRoot);
395
+ if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath))
396
+ return undefined;
397
+ return localSnapshot;
398
+ }
399
+ catch {
400
+ return undefined;
401
+ }
402
+ }
403
+ async function validateDetachedCleanupResidue(record, allowLegacyResidue = false) {
404
+ if (record.delivery.state !== 'finalized' && record.delivery.state !== 'retained')
405
+ return undefined;
406
+ const receipt = hasCleanupRemovalReceipt(record);
407
+ const legacy = allowLegacyResidue && hasLegacyCleanupResidueEvidence(record);
408
+ if (!receipt && !legacy)
409
+ return undefined;
410
+ if (!dependencies.files.exists(record.managedGitRoot) || dependencies.files.exists(record.gitDir))
411
+ return undefined;
412
+ try {
413
+ const localSnapshot = await validateCleanupLocalIdentity(record);
414
+ if (!localSnapshot)
415
+ return undefined;
416
+ const canonicalManagedGitRoot = await dependencies.files.canonicalize(record.managedGitRoot);
417
+ if (!resolvedPathsEqual(canonicalManagedGitRoot, record.managedGitRoot))
418
+ return undefined;
419
+ const projectRelativePath = relative(localSnapshot.root, record.localRoot);
420
+ const expectedManagedRoot = resolve(canonicalManagedGitRoot, projectRelativePath);
421
+ if (!pathsEqual(expectedManagedRoot, record.managedRoot))
422
+ return undefined;
423
+ if (dependencies.files.exists(record.managedRoot)) {
424
+ const canonicalManagedRoot = await dependencies.files.canonicalize(record.managedRoot);
425
+ if (!resolvedPathsEqual(canonicalManagedRoot, record.managedRoot) || !pathsEqual(canonicalManagedRoot, expectedManagedRoot))
426
+ return undefined;
427
+ }
428
+ const managedSnapshot = await dependencies.git.inspect(canonicalManagedGitRoot);
429
+ if (managedSnapshot)
430
+ return undefined;
431
+ const containingWorktreeRoot = await dependencies.git.findContainingWorktreeRoot(canonicalManagedGitRoot);
432
+ if (containingWorktreeRoot)
433
+ return undefined;
434
+ const directoryIdentity = await dependencies.files.inspectDirectoryIdentity(canonicalManagedGitRoot);
435
+ if (!directoryIdentity)
436
+ return undefined;
437
+ const expectedIdentity = record.journal?.operation === 'cleanup'
438
+ ? record.journal.managedDirectoryIdentity
439
+ : undefined;
440
+ if (expectedIdentity && !directoryIdentitiesEqual(directoryIdentity, expectedIdentity))
441
+ return undefined;
442
+ return { canonicalManagedGitRoot, directoryIdentity };
443
+ }
444
+ catch {
445
+ return undefined;
446
+ }
447
+ }
448
+ async function validateCleanupQuarantine(record) {
449
+ const journal = record.journal?.operation === 'cleanup' ? record.journal : undefined;
450
+ const quarantinePath = journal?.cleanupQuarantinePath;
451
+ const expectedIdentity = journal?.managedDirectoryIdentity;
452
+ if (!quarantinePath || !expectedIdentity || !dependencies.files.exists(quarantinePath))
453
+ return undefined;
454
+ const expectedName = `.dsh-wt-cleanup--${record.checkoutId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)}--${journal.operationId}`;
455
+ if (!resolvedPathsEqual(dirname(quarantinePath), dirname(record.managedGitRoot)) || basename(quarantinePath) !== expectedName)
456
+ return undefined;
457
+ try {
458
+ if (!await validateCleanupLocalIdentity(record))
459
+ return undefined;
460
+ const canonicalQuarantinePath = await dependencies.files.canonicalize(quarantinePath);
461
+ if (!resolvedPathsEqual(canonicalQuarantinePath, quarantinePath))
462
+ return undefined;
463
+ const identity = await dependencies.files.inspectDirectoryIdentity(canonicalQuarantinePath);
464
+ if (!identity || !directoryIdentitiesEqual(identity, expectedIdentity))
465
+ return undefined;
466
+ return canonicalQuarantinePath;
467
+ }
468
+ catch {
469
+ return undefined;
470
+ }
471
+ }
472
+ async function inspectIsolated(binding, persistRecovery = true) {
473
+ if (binding.target.kind !== 'isolated')
474
+ return recoveryView(binding);
475
+ const registry = dependencies.registry.read();
476
+ let record = registry.managedCheckouts[binding.target.checkoutId];
477
+ if (!record)
478
+ return recoveryView(binding);
479
+ if (record.projectId !== binding.projectId
480
+ || record.ownerSessionId !== binding.ownerSessionId) {
481
+ if (persistRecovery)
482
+ record = markRecoveryRequired(record);
483
+ return recoveryView(binding, record);
484
+ }
485
+ if (record.phase === 'discarded') {
486
+ if (record.delivery.state === 'delivered')
487
+ return committedFollowupView(binding, record);
488
+ return {
489
+ ...recoveryView(binding, record),
490
+ checkout: {
491
+ id: record.checkoutId,
492
+ kind: 'isolated',
493
+ label: 'Isolated Checkout',
494
+ phase: 'discarded',
495
+ },
496
+ };
497
+ }
498
+ let prefetchedValidation;
499
+ if (record.phase === 'recovery_required'
500
+ && record.journal === null
501
+ && dependencies.files.exists(record.managedRoot)) {
502
+ const recoveryValidation = await validateManagedCheckoutDetailed(binding, record, false);
503
+ if (recoveryValidation.status === 'valid' && persistRecovery) {
504
+ const restored = updateManagedCheckout(record.checkoutId, (current) => ({
505
+ ...current,
506
+ managedRoot: recoveryValidation.checkout.canonicalManagedRoot,
507
+ managedGitRoot: recoveryValidation.checkout.canonicalManagedGitRoot,
508
+ gitDir: recoveryValidation.checkout.snapshot.gitDir,
509
+ phase: 'ready',
510
+ revision: current.revision + 1,
511
+ }));
512
+ if (restored) {
513
+ record = restored;
514
+ prefetchedValidation = recoveryValidation.checkout;
515
+ }
516
+ }
517
+ }
518
+ if ((record.phase !== 'ready' && record.phase !== 'finalized' && record.phase !== 'retained') || !dependencies.files.exists(record.managedRoot)) {
519
+ if (record.delivery.state === 'finalized' || record.delivery.state === 'retained') {
520
+ return committedFollowupView(binding, record);
521
+ }
522
+ if (persistRecovery)
523
+ record = markRecoveryRequired(record);
524
+ let dirty = false;
525
+ if (dependencies.files.exists(record.managedRoot)) {
526
+ try {
527
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty;
528
+ }
529
+ catch {
530
+ dirty = true;
531
+ }
532
+ }
533
+ return recoveryView(binding, record, dirty);
534
+ }
535
+ const validation = prefetchedValidation
536
+ ? { status: 'valid', checkout: prefetchedValidation }
537
+ : await validateManagedCheckoutDetailed(binding, record, false);
538
+ if (validation.status !== 'valid') {
539
+ if (record.delivery.state === 'finalized' || record.delivery.state === 'retained') {
540
+ return committedFollowupView(binding, record);
541
+ }
542
+ // 只有确定的身份/路径不匹配才持久进入 recovery;瞬时 Git/I/O 故障允许下一次 inspect 直接重试。
543
+ if (persistRecovery && validation.status === 'invalid')
544
+ record = markRecoveryRequired(record);
545
+ return recoveryView(binding, record);
546
+ }
547
+ const { snapshot, status } = validation.checkout;
548
+ const delivery = projectDelivery(record);
549
+ return {
550
+ project: { id: record.projectId, name: record.projectName },
551
+ checkout: {
552
+ id: record.checkoutId,
553
+ kind: 'isolated',
554
+ label: 'Isolated Checkout',
555
+ phase: record.phase,
556
+ },
557
+ source: { ref: record.sourceRef, oid: record.baseOid },
558
+ current: { branch: snapshot.branch, oid: snapshot.headOid },
559
+ ownership: binding.inheritedFromSessionId ? 'inherited' : 'owner',
560
+ dirty: status.dirty,
561
+ revision: record.revision,
562
+ delivery,
563
+ };
564
+ }
565
+ function getPersistedBinding(sessionId) {
566
+ return dependencies.registry.read().sessionBindings[sessionId];
567
+ }
568
+ async function resolveBinding(sessionId) {
569
+ const session = requireSession(sessionId);
570
+ const persisted = getPersistedBinding(sessionId);
571
+ if (persisted) {
572
+ if (session.projectId !== persisted.projectId) {
573
+ throw new SessionCheckoutError('project_mismatch', '会话当前项目与已绑定 Session Target 不一致,已停止访问 checkout');
574
+ }
575
+ return persisted;
576
+ }
577
+ throw new SessionCheckoutError('target_unselected', '会话尚未选择 Session Target');
578
+ }
579
+ let bindingQueue = Promise.resolve();
580
+ let activeBindingOperation = null;
581
+ let activeBindingOperationDone = Promise.resolve();
582
+ let pendingMaintenanceOperations = 0;
583
+ let maintenanceReady;
584
+ let signalMaintenanceReady = () => undefined;
585
+ let activeMaintenanceInspects = 0;
586
+ let maintenanceInspectsDrained = Promise.resolve();
587
+ let signalMaintenanceInspectsDrained = () => undefined;
588
+ function prepareMaintenanceReadySignal() {
589
+ maintenanceReady = new Promise((resolveReady) => { signalMaintenanceReady = resolveReady; });
590
+ }
591
+ function beginMaintenanceInspect() {
592
+ if (activeMaintenanceInspects === 0) {
593
+ maintenanceInspectsDrained = new Promise((resolveDrained) => {
594
+ signalMaintenanceInspectsDrained = resolveDrained;
595
+ });
596
+ }
597
+ activeMaintenanceInspects += 1;
598
+ }
599
+ function finishMaintenanceInspect() {
600
+ activeMaintenanceInspects -= 1;
601
+ if (activeMaintenanceInspects === 0)
602
+ signalMaintenanceInspectsDrained();
603
+ }
604
+ async function withBindingLock(operation, options = {}) {
605
+ const maintenance = options.allowConcurrentInspect === true;
606
+ if (maintenance) {
607
+ pendingMaintenanceOperations += 1;
608
+ if (pendingMaintenanceOperations === 1)
609
+ prepareMaintenanceReadySignal();
610
+ }
611
+ const previous = bindingQueue;
612
+ let release = () => undefined;
613
+ bindingQueue = new Promise((resolveLock) => { release = resolveLock; });
614
+ await previous.catch(() => undefined);
615
+ let signalOperationDone = () => undefined;
616
+ activeBindingOperationDone = new Promise((resolveDone) => { signalOperationDone = resolveDone; });
617
+ activeBindingOperation = maintenance ? 'maintenance' : 'exclusive';
618
+ if (maintenance)
619
+ signalMaintenanceReady();
620
+ try {
621
+ return await operation();
622
+ }
623
+ finally {
624
+ if (maintenance) {
625
+ // 先关闭新的只读旁路,再等待已启动 inspect 结束,之后 mutation 才能接管队列。
626
+ activeBindingOperation = 'maintenance_draining';
627
+ if (activeMaintenanceInspects > 0)
628
+ await maintenanceInspectsDrained;
629
+ pendingMaintenanceOperations -= 1;
630
+ if (pendingMaintenanceOperations > 0)
631
+ prepareMaintenanceReadySignal();
632
+ else
633
+ maintenanceReady = undefined;
634
+ }
635
+ activeBindingOperation = null;
636
+ signalOperationDone();
637
+ release();
638
+ }
639
+ }
640
+ async function inspectTarget(sessionId, persistRecovery = true) {
641
+ const binding = await resolveBinding(sessionId);
642
+ if (binding.target.kind === 'local')
643
+ return inspectLocal(binding);
644
+ return inspectIsolated(binding, persistRecovery);
645
+ }
646
+ async function inspectDuringMaintenance(sessionId) {
647
+ if (activeBindingOperation !== 'maintenance')
648
+ return inspectAvailable(sessionId);
649
+ beginMaintenanceInspect();
650
+ try {
651
+ // 后台 reconcile/retention cleanup 可能因 Windows 文件占用持续数十秒。
652
+ // 此时 inspect 只读取权威快照,不写 registry,避免一个历史 Worktree 阻塞所有会话。
653
+ return await inspectTarget(sessionId, false);
654
+ }
655
+ finally {
656
+ finishMaintenanceInspect();
657
+ }
658
+ }
659
+ async function inspectAvailable(sessionId) {
660
+ while (true) {
661
+ if (activeBindingOperation === 'maintenance')
662
+ return inspectDuringMaintenance(sessionId);
663
+ if (activeBindingOperation === 'maintenance_draining') {
664
+ const operationDone = activeBindingOperationDone;
665
+ await operationDone.catch(() => undefined);
666
+ continue;
667
+ }
668
+ if (pendingMaintenanceOperations > 0 && maintenanceReady) {
669
+ // maintenance 可能排在当前交互操作之后;等它真正取得锁后再走只读旁路,
670
+ // 避免 inspect 被预先排到 maintenance 后面,也避免与当前交互 mutation 并发。
671
+ await maintenanceReady;
672
+ continue;
673
+ }
674
+ return withBindingLock(() => inspectTarget(sessionId, true));
675
+ }
676
+ }
677
+ async function reconcile() {
678
+ const registry = dependencies.registry.read();
679
+ const recoveryRequiredCheckoutIds = [];
680
+ const orphanedCheckoutIds = [];
681
+ const dirtyOrphanedCheckoutIds = [];
682
+ const finalizedCheckoutIds = [];
683
+ let changed = false;
684
+ for (const [checkoutId, current] of Object.entries(registry.managedCheckouts)) {
685
+ if (current.phase === 'discarded')
686
+ continue;
687
+ let record = current;
688
+ if (current.delivery.state === 'finalized') {
689
+ // 兼容旧版本曾把 cleanup 残余错误持久化为 recovery_required;Commit 事实优先,继续收口资源即可。
690
+ finalizedCheckoutIds.push(checkoutId);
691
+ continue;
692
+ }
693
+ if (current.phase === 'mutating'
694
+ && (current.journal?.operation === 'apply' || current.journal?.operation === 'finish')
695
+ && current.journal.step === 'planning') {
696
+ // planning 阶段尚未触碰 Local,可安全重试。
697
+ record = {
698
+ ...current,
699
+ phase: 'ready',
700
+ journal: null,
701
+ revision: current.revision + 1,
702
+ };
703
+ registry.managedCheckouts[checkoutId] = record;
704
+ registry.revision += 1;
705
+ changed = true;
706
+ }
707
+ else if (current.phase === 'preparing' || current.phase === 'mutating') {
708
+ record = {
709
+ ...current,
710
+ phase: 'recovery_required',
711
+ revision: current.revision + 1,
712
+ };
713
+ registry.managedCheckouts[checkoutId] = record;
714
+ registry.revision += 1;
715
+ recoveryRequiredCheckoutIds.push(checkoutId);
716
+ changed = true;
717
+ }
718
+ else if (current.phase === 'recovery_required') {
719
+ recoveryRequiredCheckoutIds.push(checkoutId);
720
+ }
721
+ if (dependencies.lookup.getSession(record.ownerSessionId))
722
+ continue;
723
+ orphanedCheckoutIds.push(checkoutId);
724
+ let dirty = true;
725
+ if (dependencies.files.exists(record.managedRoot)) {
726
+ try {
727
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty;
728
+ }
729
+ catch {
730
+ dirty = true;
731
+ }
732
+ }
733
+ if (dirty)
734
+ dirtyOrphanedCheckoutIds.push(checkoutId);
735
+ }
736
+ if (changed)
737
+ dependencies.registry.write(registry);
738
+ for (const checkoutId of finalizedCheckoutIds) {
739
+ const current = dependencies.registry.read().managedCheckouts[checkoutId];
740
+ if (current?.delivery.state === 'finalized') {
741
+ // 启动收敛不受单个卡死记录阻塞:超时后跳过,下次启动再试。
742
+ await withCleanupTimeout(checkoutId, () => cleanupFinalized(current));
743
+ }
744
+ }
745
+ await cleanupExpiredRetained();
746
+ const currentRegistry = dependencies.registry.read();
747
+ return {
748
+ recoveryRequiredCheckoutIds,
749
+ orphanedCheckoutIds,
750
+ dirtyOrphanedCheckoutIds,
751
+ retainedCheckoutCount: Object.values(currentRegistry.managedCheckouts)
752
+ .filter((record) => record.phase !== 'discarded')
753
+ .length,
754
+ };
755
+ }
756
+ function operationError(code, message, target) {
757
+ return { status: 'error', code, message, ...(target ? { target } : {}) };
758
+ }
759
+ function updateManagedCheckout(checkoutId, update) {
760
+ const registry = dependencies.registry.read();
761
+ const current = registry.managedCheckouts[checkoutId];
762
+ if (!current)
763
+ return undefined;
764
+ const next = update(current);
765
+ registry.managedCheckouts[checkoutId] = next;
766
+ registry.revision += 1;
767
+ dependencies.registry.write(registry);
768
+ return next;
769
+ }
770
+ async function markReadyForReviewTarget(sessionId, input) {
771
+ const binding = await resolveBinding(sessionId);
772
+ if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
773
+ throw new SessionCheckoutError('not_owner', '只有 owner Isolated 会话可以准备验收');
774
+ }
775
+ const summary = input.summary.trim();
776
+ const suggestedCommitMessage = input.suggestedCommitMessage.trim();
777
+ if (!summary || !suggestedCommitMessage || input.tests.length > 20) {
778
+ throw new SessionCheckoutError('invalid_input', '验收摘要、提交信息或验证项目无效');
779
+ }
780
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
781
+ if (!record)
782
+ throw new SessionCheckoutError('checkout_missing', 'Isolated Checkout 记录不存在');
783
+ if (record.phase !== 'ready') {
784
+ throw new SessionCheckoutError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能准备验收`);
785
+ }
786
+ const inspected = await inspectIsolated(binding);
787
+ if (inspected.checkout.phase !== 'ready') {
788
+ throw new SessionCheckoutError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复');
789
+ }
790
+ const snapshot = await dependencies.applyEngine.inspectReview({
791
+ baseOid: record.applyBaseOid ?? record.baseOid,
792
+ isolatedPath: record.managedRoot,
793
+ localPath: record.localRoot,
794
+ });
795
+ if (snapshot.status === 'error') {
796
+ throw new SessionCheckoutError(snapshot.error.code, snapshot.error.message);
797
+ }
798
+ const reviewId = dependencies.createCheckoutId();
799
+ updateManagedCheckout(record.checkoutId, (current) => ({
800
+ ...current,
801
+ delivery: {
802
+ state: 'ready_for_review',
803
+ review: {
804
+ reviewId,
805
+ iteration: current.delivery.state === 'working'
806
+ ? current.delivery.iteration
807
+ : current.delivery.state === 'delivered'
808
+ ? current.delivery.iteration + 1
809
+ : current.delivery.review.iteration,
810
+ preparedAt: Date.now(),
811
+ ...(input.detailsMarkdown?.trim() ? { detailsMarkdown: input.detailsMarkdown.trim() } : {}),
812
+ summary,
813
+ validationStatus: input.validationStatus,
814
+ ...(input.validationSummary?.trim() ? { validationSummary: input.validationSummary.trim() } : {}),
815
+ tests: input.tests.map((test) => ({
816
+ command: test.command.trim(),
817
+ status: test.status,
818
+ ...(test.summary?.trim() ? { summary: test.summary.trim() } : {}),
819
+ })),
820
+ changedFiles: [...snapshot.changedFiles],
821
+ suggestedCommitMessage,
822
+ isolatedFingerprint: snapshot.isolatedFingerprint,
823
+ isolatedHeadOid: snapshot.isolatedHeadOid,
824
+ },
825
+ },
826
+ revision: current.revision + 1,
827
+ }));
828
+ return inspectIsolated(binding);
829
+ }
830
+ async function operateApply(input, binding) {
831
+ if (binding.ownerSessionId !== input.sessionId) {
832
+ return operationError('not_owner', '继承 Session Target 的会话不能执行 Apply');
833
+ }
834
+ if (binding.target.kind !== 'isolated') {
835
+ return operationError('operation_not_allowed', 'Local Checkout 不支持 Apply');
836
+ }
837
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
838
+ if (!record)
839
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
840
+ if (record.revision !== input.expectedRevision) {
841
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
842
+ }
843
+ if (record.phase !== 'ready') {
844
+ return operationError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能 Apply`, await inspectIsolated(binding));
845
+ }
846
+ const holder = findProjectAcceptanceHolder(record);
847
+ if (holder) {
848
+ return operationError('project_acceptance_busy', `同一项目已有验收任务正在占用 Local:${holder.projectName}`, await inspectIsolated(binding));
849
+ }
850
+ const inspected = await inspectIsolated(binding);
851
+ if (inspected.checkout.phase !== 'ready') {
852
+ return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
853
+ }
854
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
855
+ if (!record || record.phase !== 'ready') {
856
+ return operationError('recovery_required', 'Isolated Checkout 状态已变化,需要恢复');
857
+ }
858
+ const startedAt = Date.now();
859
+ const operationId = dependencies.createCheckoutId();
860
+ const applyBaseOid = record.applyBaseOid ?? record.baseOid;
861
+ const applying = updateManagedCheckout(record.checkoutId, (current) => ({
862
+ ...current,
863
+ phase: 'mutating',
864
+ journal: { operation: 'apply', operationId, step: 'planning', startedAt, baseOid: applyBaseOid },
865
+ revision: current.revision + 1,
866
+ }));
867
+ if (!applying)
868
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
869
+ const planResult = await dependencies.applyEngine.plan({
870
+ baseOid: applyBaseOid,
871
+ isolatedPath: applying.managedRoot,
872
+ localPath: applying.localRoot,
873
+ });
874
+ if (planResult.status === 'conflict') {
875
+ updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
876
+ return {
877
+ status: 'conflict',
878
+ code: 'apply_conflict',
879
+ reason: 'content_conflict',
880
+ target: await inspectIsolated(binding),
881
+ baseStrategy: planResult.baseStrategy,
882
+ effectiveBaseOid: planResult.effectiveBaseOid,
883
+ localHeadOid: planResult.localHeadOid,
884
+ isolatedHeadOid: planResult.isolatedHeadOid,
885
+ canRetryAfterRefresh: false,
886
+ conflictingFiles: planResult.conflictingFiles,
887
+ };
888
+ }
889
+ if (planResult.status === 'error') {
890
+ updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
891
+ return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
892
+ }
893
+ if (applying.delivery.state === 'ready_for_review'
894
+ && planResult.plan.isolatedFingerprint !== applying.delivery.review.isolatedFingerprint) {
895
+ updateManagedCheckout(applying.checkoutId, (current) => ({
896
+ ...current,
897
+ phase: 'ready',
898
+ delivery: { state: 'working', iteration: applying.delivery.state === 'ready_for_review' ? applying.delivery.review.iteration : 1 },
899
+ journal: null,
900
+ revision: current.revision + 1,
901
+ }));
902
+ return operationError('stale_isolated', 'Worktree 在标记可验收后又发生变化,请重新准备验收', await inspectIsolated(binding));
903
+ }
904
+ if (planResult.plan.changedFiles.length === 0) {
905
+ updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
906
+ return { status: 'applied', target: await inspectIsolated(binding), changedFiles: [] };
907
+ }
908
+ const iteration = applying.delivery.state === 'working'
909
+ ? applying.delivery.iteration
910
+ : applying.delivery.state === 'delivered'
911
+ ? applying.delivery.iteration + 1
912
+ : applying.delivery.review.iteration;
913
+ const review = applying.delivery.state === 'ready_for_review'
914
+ ? applying.delivery.review
915
+ : {
916
+ reviewId: operationId,
917
+ iteration,
918
+ preparedAt: startedAt,
919
+ summary: 'Worktree 修改已通过 ApplyWorktree 同步到 Local 验收',
920
+ validationStatus: 'not_run',
921
+ tests: [],
922
+ changedFiles: [...planResult.plan.changedFiles],
923
+ suggestedCommitMessage: 'chore: 提交 Worktree 修改',
924
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
925
+ isolatedHeadOid: planResult.plan.isolatedHeadOid,
926
+ };
927
+ updateManagedCheckout(applying.checkoutId, (current) => ({
928
+ ...current,
929
+ delivery: { state: 'ready_for_review', review },
930
+ journal: {
931
+ operation: 'apply',
932
+ operationId,
933
+ step: 'writing_local',
934
+ startedAt,
935
+ baseOid: applyBaseOid,
936
+ planRevision: planResult.plan.revision,
937
+ localFingerprint: planResult.plan.localFingerprint,
938
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
939
+ effectiveBaseOid: planResult.plan.effectiveBaseOid,
940
+ baseStrategy: planResult.plan.baseStrategy,
941
+ localHeadOid: planResult.plan.localHeadOid,
942
+ isolatedHeadOid: planResult.plan.isolatedHeadOid,
943
+ changedFiles: [...planResult.plan.changedFiles],
944
+ },
945
+ revision: current.revision + 1,
946
+ }));
947
+ const result = await dependencies.applyEngine.apply(planResult.plan);
948
+ if (result.status === 'error') {
949
+ if (result.error.code === 'invalid_plan'
950
+ || result.error.code === 'stale_local'
951
+ || result.error.code === 'stale_isolated') {
952
+ // 这些错误码可证明未触碰 Local,恢复 ready;git_error 交由 reconcile 兜底 recovery_required。
953
+ updateManagedCheckout(applying.checkoutId, (checkout) => ({ ...checkout, phase: 'ready', journal: null, revision: checkout.revision + 1 }));
954
+ }
955
+ return operationError(result.error.code, result.error.message, await inspectIsolated(binding));
956
+ }
957
+ updateManagedCheckout(applying.checkoutId, (current) => ({
958
+ ...current,
959
+ applyBaseOid: result.nextBaseOid,
960
+ phase: 'ready',
961
+ journal: null,
962
+ revision: current.revision + 1,
963
+ }));
964
+ return { status: 'applied', target: await inspectIsolated(binding), changedFiles: result.changedFiles };
965
+ }
966
+ function holdsProjectAcceptanceSlot(record) {
967
+ return record.journal?.operation === 'apply' || record.journal?.operation === 'finish';
968
+ }
969
+ function findProjectAcceptanceHolder(record) {
970
+ return Object.values(dependencies.registry.read().managedCheckouts).find((candidate) => (candidate.checkoutId !== record.checkoutId
971
+ && candidate.phase !== 'discarded'
972
+ && pathsEqual(candidate.localRoot, record.localRoot)
973
+ && holdsProjectAcceptanceSlot(candidate)));
974
+ }
975
+ function blockedPreflight(record, reason, message) {
976
+ return {
977
+ status: 'blocked',
978
+ localModified: false,
979
+ checkoutId: record?.checkoutId ?? '',
980
+ reviewId: record?.delivery.state === 'ready_for_review' ? record.delivery.review.reviewId : null,
981
+ revision: record?.revision ?? 0,
982
+ reason,
983
+ message,
984
+ };
985
+ }
986
+ async function preflightTarget(sessionId, expectedRevision) {
987
+ const binding = await resolveBinding(sessionId);
988
+ if (binding.ownerSessionId !== sessionId || binding.target.kind !== 'isolated') {
989
+ return blockedPreflight(undefined, 'not_owner', '只有 owner Isolated 会话可以执行同步预检');
990
+ }
991
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
992
+ if (!record)
993
+ return blockedPreflight(undefined, 'checkout_unavailable', 'Isolated Checkout 记录不存在');
994
+ if (record.revision !== expectedRevision) {
995
+ return blockedPreflight(record, 'stale_target', 'Session Target 已变化,请刷新后重新预检');
996
+ }
997
+ if (record.phase !== 'ready' || record.delivery.state !== 'ready_for_review') {
998
+ return blockedPreflight(record, 'not_ready_for_review', '当前 Worktree 尚未处于可验收状态');
999
+ }
1000
+ if (findProjectAcceptanceHolder(record)) {
1001
+ return blockedPreflight(record, 'project_acceptance_busy', '另一个任务正在占用该项目的 Local 验收槽位');
1002
+ }
1003
+ const validated = await validateManagedCheckoutDetailed(binding, record, false);
1004
+ if (validated.status !== 'valid') {
1005
+ return blockedPreflight(record, 'checkout_unavailable', 'Worktree 身份、路径或 Git 状态暂时无法确认');
1006
+ }
1007
+ const review = record.delivery.review;
1008
+ const result = await dependencies.applyEngine.preflight({
1009
+ baseOid: record.applyBaseOid ?? record.baseOid,
1010
+ isolatedPath: validated.checkout.canonicalManagedRoot,
1011
+ localPath: record.localRoot,
1012
+ });
1013
+ const current = dependencies.registry.read().managedCheckouts[record.checkoutId];
1014
+ if (!current
1015
+ || current.revision !== expectedRevision
1016
+ || current.delivery.state !== 'ready_for_review'
1017
+ || current.delivery.review.reviewId !== review.reviewId)
1018
+ return blockedPreflight(current, 'stale_target', 'Session Target 在预检期间发生变化,请刷新后重试');
1019
+ if (result.status === 'error') {
1020
+ return blockedPreflight(current, result.error.code === 'stale_isolated' ? 'stale_isolated' : 'git_error', result.error.message);
1021
+ }
1022
+ const isolatedFingerprint = result.status === 'ready'
1023
+ ? result.plan.isolatedFingerprint
1024
+ : result.isolatedFingerprint;
1025
+ if (isolatedFingerprint !== review.isolatedFingerprint) {
1026
+ return blockedPreflight(current, 'stale_isolated', 'Worktree 在准备验收后发生变化,请重新生成验收结果');
1027
+ }
1028
+ const localBranch = result.status === 'ready' && result.plan.localHeadRef?.startsWith('refs/heads/')
1029
+ ? result.plan.localHeadRef.slice('refs/heads/'.length)
1030
+ : validated.checkout.snapshot.branch;
1031
+ const common = {
1032
+ localModified: false,
1033
+ checkoutId: record.checkoutId,
1034
+ reviewId: review.reviewId,
1035
+ revision: expectedRevision,
1036
+ configuredBaseOid: record.baseOid,
1037
+ effectiveBaseOid: result.status === 'ready' ? result.plan.effectiveBaseOid : result.effectiveBaseOid,
1038
+ baseStrategy: result.status === 'ready' ? result.plan.baseStrategy : result.baseStrategy,
1039
+ localBranch,
1040
+ localHeadOid: result.status === 'ready' ? result.plan.localHeadOid : result.localHeadOid,
1041
+ isolatedHeadOid: result.status === 'ready' ? result.plan.isolatedHeadOid : result.isolatedHeadOid,
1042
+ changedFiles: result.status === 'ready' ? [...result.plan.changedFiles] : [...review.changedFiles],
1043
+ };
1044
+ if (result.status === 'conflict') {
1045
+ return { ...common, status: 'conflict', conflictingFiles: [...result.conflictingFiles] };
1046
+ }
1047
+ const status = result.plan.changedFiles.length === 0
1048
+ ? 'already_in_local'
1049
+ : result.plan.localHeadOid !== record.baseOid
1050
+ ? 'local_advanced'
1051
+ : 'ready';
1052
+ return { ...common, status };
1053
+ }
1054
+ function retainFinalized(record, retention) {
1055
+ if (record.delivery.state !== 'finalized')
1056
+ return undefined;
1057
+ const retainedAt = Date.now();
1058
+ return updateManagedCheckout(record.checkoutId, (current) => {
1059
+ if (current.delivery.state !== 'finalized')
1060
+ return current;
1061
+ return {
1062
+ ...current,
1063
+ phase: 'retained',
1064
+ delivery: {
1065
+ state: 'retained',
1066
+ review: current.delivery.review,
1067
+ commitOid: current.delivery.commitOid,
1068
+ ...(current.delivery.proof ? { proof: current.delivery.proof } : {}),
1069
+ isolatedFingerprint: current.delivery.isolatedFingerprint,
1070
+ retention,
1071
+ retainedAt,
1072
+ expiresAt: retentionExpiresAt(retention, retainedAt),
1073
+ cleanup: 'scheduled',
1074
+ },
1075
+ journal: null,
1076
+ revision: current.revision + 1,
1077
+ };
1078
+ });
1079
+ }
1080
+ async function cleanupFinalized(record, options = {}) {
1081
+ const block = (message, reason = cleanupReasonForMessage(message)) => {
1082
+ updateManagedCheckout(record.checkoutId, (current) => {
1083
+ const journal = current.journal?.operation === 'cleanup' ? current.journal : null;
1084
+ if (current.delivery.state === 'finalized') {
1085
+ return {
1086
+ ...current,
1087
+ phase: 'finalized',
1088
+ delivery: { ...current.delivery, cleanup: 'blocked', cleanupMessage: message },
1089
+ journal,
1090
+ revision: current.revision + 1,
1091
+ };
1092
+ }
1093
+ if (current.delivery.state === 'retained') {
1094
+ return {
1095
+ ...current,
1096
+ phase: 'retained',
1097
+ delivery: { ...current.delivery, cleanup: 'blocked', cleanupMessage: message },
1098
+ journal,
1099
+ revision: current.revision + 1,
1100
+ };
1101
+ }
1102
+ return current;
1103
+ });
1104
+ return { cleaned: false, message, reason };
1105
+ };
1106
+ if (record.delivery.state !== 'finalized' && record.delivery.state !== 'retained') {
1107
+ return block('Worktree 交付状态不完整,未执行清理。');
1108
+ }
1109
+ if ((record.delivery.state === 'finalized' || record.delivery.state === 'retained')
1110
+ && record.delivery.cleanup === 'blocked'
1111
+ && record.delivery.cleanupMessage?.includes('Local index')) {
1112
+ return block(record.delivery.cleanupMessage);
1113
+ }
1114
+ if (hasLegacyCleanupResidueEvidence(record) && !options.allowLegacyResidue && dependencies.files.exists(record.managedGitRoot)) {
1115
+ return { cleaned: false, message: CLEANUP_RESIDUE_MESSAGE, reason: 'detached_residue' };
1116
+ }
1117
+ const registry = dependencies.registry.read();
1118
+ const persistedBinding = registry.sessionBindings[record.ownerSessionId];
1119
+ const binding = persistedBinding?.target.kind === 'isolated'
1120
+ && persistedBinding.target.checkoutId === record.checkoutId
1121
+ ? persistedBinding
1122
+ : bindingForManagedRecord(record);
1123
+ const beginRemoval = (currentRecord, managedDirectoryIdentity) => {
1124
+ if (hasCleanupRemovalReceipt(currentRecord))
1125
+ return currentRecord;
1126
+ return updateManagedCheckout(currentRecord.checkoutId, (current) => ({
1127
+ ...current,
1128
+ journal: {
1129
+ operation: 'cleanup',
1130
+ operationId: dependencies.createCheckoutId(),
1131
+ step: 'removing_worktree',
1132
+ startedAt: Date.now(),
1133
+ commitOid: current.delivery.state === 'finalized' || current.delivery.state === 'retained'
1134
+ ? current.delivery.commitOid ?? undefined
1135
+ : undefined,
1136
+ isolatedFingerprint: current.delivery.state === 'finalized' || current.delivery.state === 'retained'
1137
+ ? current.delivery.isolatedFingerprint
1138
+ : undefined,
1139
+ managedDirectoryIdentity,
1140
+ },
1141
+ revision: current.revision + 1,
1142
+ }));
1143
+ };
1144
+ const quarantineAndRemove = async (currentRecord, residue) => {
1145
+ const journal = currentRecord.journal?.operation === 'cleanup' ? currentRecord.journal : undefined;
1146
+ if (!journal?.managedDirectoryIdentity)
1147
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree cleanup receipt 不完整');
1148
+ const quarantinePath = journal.cleanupQuarantinePath ?? join(dirname(currentRecord.managedGitRoot), `.dsh-wt-cleanup--${currentRecord.checkoutId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)}--${journal.operationId}`);
1149
+ const quarantining = journal.cleanupQuarantinePath
1150
+ ? currentRecord
1151
+ : updateManagedCheckout(currentRecord.checkoutId, (current) => ({
1152
+ ...current,
1153
+ journal: current.journal?.operation === 'cleanup'
1154
+ ? { ...current.journal, cleanupQuarantinePath: quarantinePath }
1155
+ : current.journal,
1156
+ revision: current.revision + 1,
1157
+ }));
1158
+ if (!quarantining)
1159
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录在 quarantine 前丢失');
1160
+ if (!dependencies.files.exists(quarantinePath)) {
1161
+ await retryTransientCleanup(() => dependencies.files.quarantineDirectoryTree(residue.canonicalManagedGitRoot, residue.directoryIdentity, quarantinePath));
1162
+ }
1163
+ const validatedQuarantine = await validateCleanupQuarantine(quarantining);
1164
+ if (!validatedQuarantine)
1165
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree quarantine 身份无法验证');
1166
+ await retryTransientCleanup(() => dependencies.files.removeDirectoryTree(validatedQuarantine));
1167
+ };
1168
+ try {
1169
+ const existingQuarantine = await validateCleanupQuarantine(record);
1170
+ if (existingQuarantine) {
1171
+ await retryTransientCleanup(() => dependencies.files.removeDirectoryTree(existingQuarantine));
1172
+ }
1173
+ else if (record.journal?.operation === 'cleanup' && record.journal.cleanupQuarantinePath && dependencies.files.exists(record.journal.cleanupQuarantinePath)) {
1174
+ return block(CLEANUP_IDENTITY_CHANGED_MESSAGE);
1175
+ }
1176
+ else if (dependencies.files.exists(record.managedGitRoot)) {
1177
+ const validated = dependencies.files.exists(record.managedRoot)
1178
+ ? await validateManagedCheckout(binding, record, false)
1179
+ : undefined;
1180
+ if (validated) {
1181
+ const snapshot = await dependencies.applyEngine.inspectReview({
1182
+ baseOid: record.applyBaseOid ?? record.baseOid,
1183
+ isolatedPath: record.managedRoot,
1184
+ localPath: record.localRoot,
1185
+ });
1186
+ if (snapshot.status !== 'ready' || snapshot.isolatedFingerprint !== record.delivery.isolatedFingerprint) {
1187
+ return block('Worktree 在提交后出现了新修改,未执行清理。');
1188
+ }
1189
+ const directoryIdentity = await dependencies.files.inspectDirectoryIdentity(validated.canonicalManagedGitRoot);
1190
+ if (!directoryIdentity)
1191
+ return block(CLEANUP_IDENTITY_CHANGED_MESSAGE);
1192
+ const removing = beginRemoval(record, directoryIdentity);
1193
+ if (!removing)
1194
+ return block('Worktree 记录在清理前丢失,未执行清理。');
1195
+ await retryTransientCleanup(() => dependencies.git.removeWorktree(removing.localRoot, removing.managedGitRoot));
1196
+ if (dependencies.files.exists(removing.managedGitRoot)) {
1197
+ const residue = await validateDetachedCleanupResidue(removing);
1198
+ if (!residue)
1199
+ return block(CLEANUP_IDENTITY_CHANGED_MESSAGE);
1200
+ await quarantineAndRemove(removing, residue);
1201
+ }
1202
+ }
1203
+ else {
1204
+ const residue = await validateDetachedCleanupResidue(record, options.allowLegacyResidue === true);
1205
+ if (!residue)
1206
+ return block(CLEANUP_IDENTITY_CHANGED_MESSAGE);
1207
+ const removing = beginRemoval(record, residue.directoryIdentity);
1208
+ if (!removing)
1209
+ return block('Worktree 记录在清理前丢失,未执行清理。');
1210
+ const revalidatedResidue = await validateDetachedCleanupResidue(removing);
1211
+ if (!revalidatedResidue)
1212
+ return block(CLEANUP_IDENTITY_CHANGED_MESSAGE);
1213
+ await quarantineAndRemove(removing, revalidatedResidue);
1214
+ }
1215
+ }
1216
+ await releaseApplyBaseBestEffort(record);
1217
+ updateManagedCheckout(record.checkoutId, (current) => {
1218
+ if (current.delivery.state !== 'finalized' && current.delivery.state !== 'retained')
1219
+ return current;
1220
+ return {
1221
+ ...current,
1222
+ phase: 'discarded',
1223
+ delivery: {
1224
+ state: 'delivered',
1225
+ iteration: current.delivery.review.iteration,
1226
+ commitOid: current.delivery.commitOid,
1227
+ ...(current.delivery.proof ? { proof: current.delivery.proof } : {}),
1228
+ deliveredAt: Date.now(),
1229
+ },
1230
+ journal: null,
1231
+ revision: current.revision + 1,
1232
+ };
1233
+ });
1234
+ return { cleaned: true };
1235
+ }
1236
+ catch (error) {
1237
+ console.warn('[session-checkout] finalized Worktree cleanup failed:', error);
1238
+ const failureRecord = dependencies.registry.read().managedCheckouts[record.checkoutId] ?? record;
1239
+ const quarantineBusy = failureRecord.journal?.operation === 'cleanup' && Boolean(failureRecord.journal.cleanupQuarantinePath);
1240
+ const reason = quarantineBusy ? 'quarantine_busy' : 'directory_busy';
1241
+ const message = quarantineBusy
1242
+ ? 'Worktree 已安全移入 quarantine,但目录仍被进程占用;dsh-git-worktree 会在同一清理授权内有限重试。'
1243
+ : 'Worktree 目录仍被进程占用或 Windows 暂时拒绝删除;dsh-git-worktree 已完成有限重试,可稍后重试清理。';
1244
+ updateManagedCheckout(record.checkoutId, (current) => {
1245
+ const journal = current.journal?.operation === 'cleanup' ? current.journal : null;
1246
+ if (current.delivery.state === 'finalized') {
1247
+ return { ...current, phase: 'finalized', delivery: { ...current.delivery, cleanup: 'pending', cleanupMessage: message }, journal, revision: current.revision + 1 };
1248
+ }
1249
+ if (current.delivery.state === 'retained') {
1250
+ return { ...current, phase: 'retained', delivery: { ...current.delivery, cleanup: 'blocked', cleanupMessage: message }, journal, revision: current.revision + 1 };
1251
+ }
1252
+ return current;
1253
+ });
1254
+ return { cleaned: false, message, reason };
1255
+ }
1256
+ }
1257
+ async function operateRetryCleanup(input, binding) {
1258
+ if (binding.ownerSessionId !== input.sessionId || binding.target.kind !== 'isolated') {
1259
+ return operationError('not_owner', '只有 owner Isolated 会话可以重试清理');
1260
+ }
1261
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1262
+ if (!record)
1263
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1264
+ if (record.revision !== input.expectedRevision) {
1265
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1266
+ }
1267
+ if (record.delivery.state !== 'finalized' && record.delivery.state !== 'retained') {
1268
+ return operationError('operation_not_allowed', '当前没有待重试的 Worktree 清理', await inspectIsolated(binding));
1269
+ }
1270
+ const cleanup = await cleanupFinalized(record, { allowLegacyResidue: true });
1271
+ return {
1272
+ status: 'finished',
1273
+ target: await inspectIsolated(binding),
1274
+ changedFiles: [...record.delivery.review.changedFiles],
1275
+ commitOid: record.delivery.commitOid,
1276
+ cleanup: cleanup.cleaned ? 'discarded' : 'pending',
1277
+ ...(cleanup.message ? { cleanupMessage: cleanup.message } : {}),
1278
+ ...(cleanup.reason ? { cleanupReason: cleanup.reason } : {}),
1279
+ };
1280
+ }
1281
+ async function operateFinish(input, binding) {
1282
+ if (binding.ownerSessionId !== input.sessionId) {
1283
+ return operationError('not_owner', '继承 Session Target 的会话不能执行 Finish');
1284
+ }
1285
+ if (binding.target.kind !== 'isolated') {
1286
+ return operationError('operation_not_allowed', 'Local Checkout 不支持 Finish');
1287
+ }
1288
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1289
+ if (!record)
1290
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1291
+ if (record.revision !== input.expectedRevision) {
1292
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1293
+ }
1294
+ if (record.phase !== 'ready') {
1295
+ return operationError('operation_not_allowed', `当前 ${record.phase}/${record.delivery.state} 状态不能直接 Finish`, await inspectIsolated(binding));
1296
+ }
1297
+ const holder = findProjectAcceptanceHolder(record);
1298
+ if (holder) {
1299
+ return operationError('project_acceptance_busy', `同一项目已有验收任务正在占用 Local:${holder.projectName}`, await inspectIsolated(binding));
1300
+ }
1301
+ const inspected = await inspectIsolated(binding);
1302
+ if (inspected.checkout.phase !== 'ready') {
1303
+ return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
1304
+ }
1305
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1306
+ if (!record || record.phase !== 'ready') {
1307
+ return operationError('recovery_required', 'Isolated Checkout 状态已变化,需要恢复');
1308
+ }
1309
+ const startedAt = Date.now();
1310
+ const operationId = dependencies.createCheckoutId();
1311
+ const applyBaseOid = record.applyBaseOid ?? record.baseOid;
1312
+ const applying = updateManagedCheckout(record.checkoutId, (current) => ({
1313
+ ...current,
1314
+ phase: 'mutating',
1315
+ journal: {
1316
+ operation: 'finish',
1317
+ operationId,
1318
+ step: 'planning',
1319
+ startedAt,
1320
+ baseOid: applyBaseOid,
1321
+ },
1322
+ revision: current.revision + 1,
1323
+ }));
1324
+ if (!applying)
1325
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1326
+ const planResult = await dependencies.applyEngine.plan({
1327
+ baseOid: applyBaseOid,
1328
+ isolatedPath: applying.managedRoot,
1329
+ localPath: applying.localRoot,
1330
+ });
1331
+ if (planResult.status === 'conflict') {
1332
+ updateManagedCheckout(applying.checkoutId, (current) => ({
1333
+ ...current,
1334
+ phase: 'ready',
1335
+ journal: null,
1336
+ revision: current.revision + 1,
1337
+ }));
1338
+ return {
1339
+ status: 'conflict',
1340
+ code: 'apply_conflict',
1341
+ reason: 'content_conflict',
1342
+ target: await inspectIsolated(binding),
1343
+ baseStrategy: planResult.baseStrategy,
1344
+ effectiveBaseOid: planResult.effectiveBaseOid,
1345
+ localHeadOid: planResult.localHeadOid,
1346
+ isolatedHeadOid: planResult.isolatedHeadOid,
1347
+ canRetryAfterRefresh: false,
1348
+ conflictingFiles: planResult.conflictingFiles,
1349
+ };
1350
+ }
1351
+ if (planResult.status === 'error') {
1352
+ updateManagedCheckout(applying.checkoutId, (current) => ({
1353
+ ...current,
1354
+ phase: 'ready',
1355
+ journal: null,
1356
+ revision: current.revision + 1,
1357
+ }));
1358
+ return operationError(planResult.error.code, planResult.error.message, await inspectIsolated(binding));
1359
+ }
1360
+ const iteration = applying.delivery.state === 'working'
1361
+ ? applying.delivery.iteration
1362
+ : applying.delivery.state === 'delivered'
1363
+ ? applying.delivery.iteration + 1
1364
+ : applying.delivery.review.iteration;
1365
+ const review = applying.delivery.state === 'ready_for_review'
1366
+ ? applying.delivery.review
1367
+ : {
1368
+ reviewId: operationId,
1369
+ iteration,
1370
+ preparedAt: startedAt,
1371
+ summary: '跳过 Local 验收并直接提交',
1372
+ validationStatus: 'not_run',
1373
+ tests: [],
1374
+ changedFiles: [...planResult.plan.changedFiles],
1375
+ suggestedCommitMessage: input.commitMessage,
1376
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
1377
+ isolatedHeadOid: planResult.plan.isolatedHeadOid,
1378
+ };
1379
+ updateManagedCheckout(applying.checkoutId, (current) => ({
1380
+ ...current,
1381
+ delivery: { state: 'ready_for_review', review },
1382
+ journal: {
1383
+ operation: 'finish',
1384
+ operationId,
1385
+ step: 'writing_local',
1386
+ startedAt,
1387
+ baseOid: applyBaseOid,
1388
+ planRevision: planResult.plan.revision,
1389
+ localFingerprint: planResult.plan.localFingerprint,
1390
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
1391
+ effectiveBaseOid: planResult.plan.effectiveBaseOid,
1392
+ baseStrategy: planResult.plan.baseStrategy,
1393
+ localHeadOid: planResult.plan.localHeadOid,
1394
+ isolatedHeadOid: planResult.plan.isolatedHeadOid,
1395
+ changedFiles: [...planResult.plan.changedFiles],
1396
+ },
1397
+ revision: current.revision + 1,
1398
+ }));
1399
+ const finishResult = await dependencies.applyEngine.finish(planResult.plan, {
1400
+ commitMessage: input.commitMessage,
1401
+ });
1402
+ if (finishResult.status === 'error') {
1403
+ if (finishResult.error.code === 'invalid_plan'
1404
+ || finishResult.error.code === 'stale_local'
1405
+ || finishResult.error.code === 'stale_isolated'
1406
+ || 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。
1410
+ updateManagedCheckout(applying.checkoutId, (current) => ({ ...current, phase: 'ready', journal: null, revision: current.revision + 1 }));
1411
+ }
1412
+ return operationError(finishResult.error.code, finishResult.error.message, await inspectIsolated(binding));
1413
+ }
1414
+ const finalized = updateManagedCheckout(applying.checkoutId, (current) => ({
1415
+ ...current,
1416
+ applyBaseOid: finishResult.nextBaseOid,
1417
+ phase: 'finalized',
1418
+ delivery: {
1419
+ state: 'finalized',
1420
+ review,
1421
+ commitOid: finishResult.commitOid,
1422
+ proof: {
1423
+ localBranch: planResult.plan.localHeadRef?.startsWith('refs/heads/')
1424
+ ? planResult.plan.localHeadRef.slice('refs/heads/'.length)
1425
+ : null,
1426
+ localHeadBefore: planResult.plan.localHeadOid,
1427
+ localHeadAfter: finishResult.commitOid ?? planResult.plan.localHeadOid,
1428
+ changedFiles: [...finishResult.changedFiles],
1429
+ },
1430
+ isolatedFingerprint: planResult.plan.isolatedFingerprint,
1431
+ finalizedAt: Date.now(),
1432
+ cleanup: 'pending',
1433
+ },
1434
+ journal: null,
1435
+ revision: current.revision + 1,
1436
+ }));
1437
+ if (!finalized) {
1438
+ return operationError('checkout_missing', '任务提交已创建,但 Checkout 记录丢失,需要人工检查');
1439
+ }
1440
+ const retention = input.retention ?? 'cleanup';
1441
+ if (retention !== 'cleanup') {
1442
+ const retained = retainFinalized(finalized, retention);
1443
+ if (!retained)
1444
+ return operationError('checkout_missing', '任务提交已创建,但保留 Worktree 状态写入失败');
1445
+ return {
1446
+ status: 'finished',
1447
+ target: await inspectIsolated(binding),
1448
+ changedFiles: finishResult.changedFiles,
1449
+ commitOid: finishResult.commitOid,
1450
+ cleanup: 'retained',
1451
+ };
1452
+ }
1453
+ const cleanup = await cleanupFinalized(finalized);
1454
+ return {
1455
+ status: 'finished',
1456
+ target: await inspectIsolated(binding),
1457
+ changedFiles: finishResult.changedFiles,
1458
+ commitOid: finishResult.commitOid,
1459
+ cleanup: cleanup.cleaned ? 'discarded' : 'pending',
1460
+ ...(cleanup.message ? { cleanupMessage: cleanup.message } : {}),
1461
+ ...(cleanup.reason ? { cleanupReason: cleanup.reason } : {}),
1462
+ };
1463
+ }
1464
+ async function releaseApplyBaseBestEffort(record) {
1465
+ try {
1466
+ await dependencies.git.releaseApplyBase(record.localRoot, record.checkoutId);
1467
+ await dependencies.git.releaseInternalArtifacts(record.localRoot, record.checkoutId);
1468
+ }
1469
+ catch {
1470
+ // checkout 删除优先;内部无 ref artifact 的清理失败不能阻止 owner 明确收口。
1471
+ console.warn('[session-checkout] 清理内部 Session Checkout refs 失败,已保守保留不可见引用');
1472
+ }
1473
+ }
1474
+ async function operateDiscard(input, binding) {
1475
+ if (binding.ownerSessionId !== input.sessionId) {
1476
+ return operationError('not_owner', '继承 Session Target 的会话不能执行 Discard');
1477
+ }
1478
+ if (binding.target.kind !== 'isolated') {
1479
+ return operationError('operation_not_allowed', 'Local Checkout 不支持 Discard');
1480
+ }
1481
+ let record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1482
+ if (!record)
1483
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1484
+ if (record.revision !== input.expectedRevision) {
1485
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1486
+ }
1487
+ if (record.phase === 'recovery_required' && !dependencies.files.exists(record.managedRoot)) {
1488
+ await releaseApplyBaseBestEffort(record);
1489
+ updateManagedCheckout(record.checkoutId, (current) => ({
1490
+ ...current,
1491
+ phase: 'discarded',
1492
+ journal: null,
1493
+ revision: current.revision + 1,
1494
+ }));
1495
+ return { status: 'discarded', target: await inspectIsolated(binding) };
1496
+ }
1497
+ if (record.phase !== 'ready' && record.phase !== 'recovery_required') {
1498
+ return operationError('operation_not_allowed', `当前 ${record.phase} 状态不能 Discard`, await inspectIsolated(binding));
1499
+ }
1500
+ let inspected;
1501
+ let dirty = true;
1502
+ if (record.phase === 'ready') {
1503
+ inspected = await inspectIsolated(binding);
1504
+ if (inspected.checkout.phase !== 'ready') {
1505
+ return operationError('recovery_required', 'Isolated Checkout 身份无法确认,需要恢复', inspected);
1506
+ }
1507
+ record = dependencies.registry.read().managedCheckouts[record.checkoutId];
1508
+ if (!record || record.phase !== 'ready') {
1509
+ return operationError('recovery_required', 'Isolated Checkout 状态已变化,需要恢复');
1510
+ }
1511
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty;
1512
+ }
1513
+ else {
1514
+ try {
1515
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty;
1516
+ }
1517
+ catch {
1518
+ dirty = true;
1519
+ }
1520
+ inspected = recoveryView(binding, record, dirty);
1521
+ }
1522
+ if (dirty && !input.confirmDirty) {
1523
+ return operationError('dirty_confirmation_required', 'Isolated Checkout 含未提交修改或状态无法确认,需要明确确认', inspected);
1524
+ }
1525
+ try {
1526
+ await dependencies.git.removeWorktree(record.localRoot, record.managedGitRoot);
1527
+ }
1528
+ catch {
1529
+ return operationError('git_operation_failed', '删除 managed checkout 失败', await inspectIsolated(binding));
1530
+ }
1531
+ await releaseApplyBaseBestEffort(record);
1532
+ updateManagedCheckout(record.checkoutId, (current) => ({
1533
+ ...current,
1534
+ phase: 'discarded',
1535
+ journal: null,
1536
+ revision: current.revision + 1,
1537
+ }));
1538
+ return { status: 'discarded', target: await inspectIsolated(binding) };
1539
+ }
1540
+ async function operateRecover(input, binding) {
1541
+ if (binding.ownerSessionId !== input.sessionId) {
1542
+ return operationError('not_owner', '继承 Session Target 的会话不能执行 Recover');
1543
+ }
1544
+ if (binding.target.kind !== 'isolated') {
1545
+ return operationError('operation_not_allowed', 'Local Checkout 不支持 Recover');
1546
+ }
1547
+ const record = dependencies.registry.read().managedCheckouts[binding.target.checkoutId];
1548
+ if (!record)
1549
+ return operationError('checkout_missing', 'Isolated Checkout 记录不存在');
1550
+ if (record.revision !== input.expectedRevision) {
1551
+ return operationError('stale_target', 'Session Target 已变化,请刷新后重试', await inspectIsolated(binding));
1552
+ }
1553
+ const recoverCreate = record.journal?.operation === 'create';
1554
+ if ((record.journal !== null && !recoverCreate) || record.phase === 'mutating') {
1555
+ return operationError('recovery_unsafe', 'Apply 是否已修改 Local 无法安全确认;不会自动重试或猜测成功', await inspectIsolated(binding));
1556
+ }
1557
+ if (!dependencies.files.exists(record.managedRoot)) {
1558
+ return operationError('recovery_required', 'Isolated Checkout 缺失,只能由 owner 明确 Discard 收口');
1559
+ }
1560
+ const validated = await validateManagedCheckout(binding, record, recoverCreate);
1561
+ if (!validated) {
1562
+ return operationError('recovery_unsafe', 'Isolated Checkout 的路径、Git 身份、项目、HEAD 或状态无法完整确认');
1563
+ }
1564
+ updateManagedCheckout(record.checkoutId, (current) => ({
1565
+ ...current,
1566
+ managedRoot: validated.canonicalManagedRoot,
1567
+ managedGitRoot: validated.canonicalManagedGitRoot,
1568
+ gitDir: validated.snapshot.gitDir,
1569
+ phase: 'ready',
1570
+ journal: null,
1571
+ revision: current.revision + 1,
1572
+ }));
1573
+ return { status: 'recovered', target: await inspectIsolated(binding) };
1574
+ }
1575
+ async function operateTarget(input) {
1576
+ try {
1577
+ const binding = await resolveBinding(input.sessionId);
1578
+ if (input.action === 'apply')
1579
+ return await operateApply(input, binding);
1580
+ if (input.action === 'finish')
1581
+ return await operateFinish(input, binding);
1582
+ if (input.action === 'retry_cleanup')
1583
+ return await operateRetryCleanup(input, binding);
1584
+ if (input.action === 'discard')
1585
+ return await operateDiscard(input, binding);
1586
+ if (input.action === 'recover')
1587
+ return await operateRecover(input, binding);
1588
+ const exhaustive = input;
1589
+ return exhaustive;
1590
+ }
1591
+ catch (error) {
1592
+ if (error instanceof SessionCheckoutError)
1593
+ return operationError(error.code, error.message);
1594
+ throw error;
1595
+ }
1596
+ }
1597
+ function managedIteration(record) {
1598
+ if (record.delivery.state === 'working' || record.delivery.state === 'delivered')
1599
+ return record.delivery.iteration;
1600
+ return record.delivery.review.iteration;
1601
+ }
1602
+ function managedUpdatedAt(record) {
1603
+ if (record.delivery.state === 'working')
1604
+ return record.journal?.startedAt ?? 0;
1605
+ if (record.delivery.state === 'ready_for_review')
1606
+ return record.delivery.review.preparedAt;
1607
+ if (record.delivery.state === 'finalized')
1608
+ return record.delivery.finalizedAt;
1609
+ if (record.delivery.state === 'retained')
1610
+ return record.delivery.retainedAt;
1611
+ return record.delivery.deliveredAt;
1612
+ }
1613
+ async function summarizeManagedWorktree(record, includeDiagnostics = false) {
1614
+ // 快速列表必须保守且不扫描 Git/磁盘;只有后台单项诊断完成后才开放清理。
1615
+ let dirty = true;
1616
+ let cleanupResidue = false;
1617
+ let approximateBytes = null;
1618
+ if (includeDiagnostics) {
1619
+ if (record.delivery.state === 'finalized' || record.delivery.state === 'retained') {
1620
+ const quarantine = await validateCleanupQuarantine(record);
1621
+ const residue = quarantine ? undefined : await validateDetachedCleanupResidue(record, true);
1622
+ if (quarantine || residue) {
1623
+ cleanupResidue = true;
1624
+ dirty = false;
1625
+ }
1626
+ else if (dependencies.files.exists(record.managedRoot)) {
1627
+ try {
1628
+ const snapshot = await dependencies.applyEngine.inspectReview({
1629
+ baseOid: record.applyBaseOid ?? record.baseOid,
1630
+ isolatedPath: record.managedRoot,
1631
+ localPath: record.localRoot,
1632
+ });
1633
+ dirty = snapshot.status !== 'ready' || snapshot.isolatedFingerprint !== record.delivery.isolatedFingerprint;
1634
+ }
1635
+ catch {
1636
+ dirty = true;
1637
+ }
1638
+ }
1639
+ }
1640
+ else if (dependencies.files.exists(record.managedRoot)) {
1641
+ try {
1642
+ dirty = (await dependencies.git.status(record.managedRoot)).dirty;
1643
+ }
1644
+ catch {
1645
+ dirty = true;
1646
+ }
1647
+ }
1648
+ const physicalRoot = record.journal?.operation === 'cleanup' && record.journal.cleanupQuarantinePath
1649
+ ? record.journal.cleanupQuarantinePath
1650
+ : record.managedGitRoot;
1651
+ if (dependencies.files.exists(physicalRoot)) {
1652
+ try {
1653
+ approximateBytes = await dependencies.files.measureDirectoryBytes(physicalRoot);
1654
+ }
1655
+ catch {
1656
+ approximateBytes = null;
1657
+ }
1658
+ }
1659
+ }
1660
+ const delivery = record.delivery;
1661
+ const state = record.phase === 'recovery_required'
1662
+ ? 'needs_attention'
1663
+ : delivery.state === 'retained'
1664
+ ? delivery.cleanup === 'blocked' ? 'needs_attention' : 'retained'
1665
+ : delivery.state === 'finalized'
1666
+ ? 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';
1672
+ const commitOid = delivery.state === 'finalized' || delivery.state === 'retained' || delivery.state === 'delivered'
1673
+ ? delivery.commitOid
1674
+ : null;
1675
+ const cleanupMessage = cleanupResidue
1676
+ ? CLEANUP_RESIDUE_MESSAGE
1677
+ : delivery.state === 'finalized' || delivery.state === 'retained'
1678
+ ? delivery.cleanupMessage
1679
+ : undefined;
1680
+ const cleanupReason = cleanupMessage ? cleanupReasonForMessage(cleanupMessage) : undefined;
1681
+ return {
1682
+ checkoutId: record.checkoutId,
1683
+ revision: record.revision,
1684
+ ownerSessionId: record.ownerSessionId,
1685
+ ownerSessionTitle: dependencies.lookup.getSession(record.ownerSessionId)?.title?.trim() || '已删除的 Agent 会话',
1686
+ project: { id: record.projectId, name: record.projectName },
1687
+ iteration: managedIteration(record),
1688
+ state,
1689
+ phase: record.phase,
1690
+ dirty,
1691
+ commitOid,
1692
+ ...(delivery.state === 'retained' ? {
1693
+ retention: delivery.retention,
1694
+ retainedAt: delivery.retainedAt,
1695
+ expiresAt: delivery.expiresAt,
1696
+ } : {}),
1697
+ ...(cleanupMessage ? { cleanupMessage } : {}),
1698
+ ...(cleanupReason ? { cleanupReason } : {}),
1699
+ approximateBytes,
1700
+ updatedAt: managedUpdatedAt(record),
1701
+ canCleanup: includeDiagnostics && (delivery.state === 'retained' || delivery.state === 'finalized') && !dirty,
1702
+ };
1703
+ }
1704
+ function cleanupBlocked(reason, message, revision) {
1705
+ return { eligibility: 'blocked', reason, message, inspectedRevision: revision };
1706
+ }
1707
+ async function inspectCleanupForRecord(record) {
1708
+ if (record.delivery.state === 'working')
1709
+ return cleanupBlocked('working', '当前轮次仍在修改,尚未形成可清理的交付环境。', record.revision);
1710
+ if (record.delivery.state === 'ready_for_review')
1711
+ return cleanupBlocked('review_pending', '当前轮次正在等待验收,不能清理。', record.revision);
1712
+ if (record.delivery.state === 'delivered' || record.phase === 'discarded') {
1713
+ return cleanupBlocked('unknown', 'Worktree 已交付并解除管理,无需再次清理。', record.revision);
1714
+ }
1715
+ if (record.delivery.commitOid && record.delivery.proof) {
1716
+ const local = await validateCommittedLocalCheckout(bindingForManagedRecord(record), record);
1717
+ if (!local)
1718
+ return cleanupBlocked('identity_mismatch', 'Local checkout identity 无法验证,不能证明本轮交付仍存在。', record.revision);
1719
+ try {
1720
+ const delivered = await dependencies.git.isAncestor(local.canonicalLocalRoot, record.delivery.commitOid, local.snapshot.headOid);
1721
+ if (!delivered)
1722
+ return cleanupBlocked('identity_mismatch', '本轮交付 commit 已不在 Local 历史中,不能清理环境。', record.revision);
1723
+ }
1724
+ catch {
1725
+ return cleanupBlocked('unknown', '无法验证本轮交付 commit 是否仍在 Local 历史中。', record.revision);
1726
+ }
1727
+ }
1728
+ const quarantine = await validateCleanupQuarantine(record);
1729
+ const residue = quarantine ? undefined : await validateDetachedCleanupResidue(record, true);
1730
+ if (record.journal?.operation === 'cleanup' && record.journal.cleanupQuarantinePath && !quarantine) {
1731
+ return cleanupBlocked('identity_mismatch', '清理目录身份无法重新验证,已保留环境。', record.revision);
1732
+ }
1733
+ if (!quarantine && !residue && dependencies.files.exists(record.managedRoot)) {
1734
+ const binding = bindingForManagedRecord(record);
1735
+ const validated = await validateManagedCheckout(binding, record, false);
1736
+ if (!validated)
1737
+ return cleanupBlocked('identity_mismatch', 'Worktree checkout identity 无法验证,已保留环境。', record.revision);
1738
+ try {
1739
+ const snapshot = await dependencies.applyEngine.inspectReview({
1740
+ baseOid: record.applyBaseOid ?? record.baseOid,
1741
+ isolatedPath: record.managedRoot,
1742
+ localPath: record.localRoot,
1743
+ });
1744
+ if (snapshot.status !== 'ready' || snapshot.isolatedFingerprint !== record.delivery.isolatedFingerprint) {
1745
+ return cleanupBlocked('uncommitted_changes', '提交或保留后检测到新增修改,不能批量清理。', record.revision);
1746
+ }
1747
+ }
1748
+ catch {
1749
+ return cleanupBlocked('unknown', '无法证明 Worktree 当前状态安全,已保留环境。', record.revision);
1750
+ }
1751
+ }
1752
+ if (record.delivery.cleanup === 'blocked' || record.delivery.cleanup === 'pending') {
1753
+ const cleanupMessage = record.delivery.cleanupMessage ?? '上次清理未完成,可重新校验后重试。';
1754
+ if (cleanupReasonForMessage(cleanupMessage) === 'identity_changed')
1755
+ return cleanupBlocked('identity_mismatch', cleanupMessage, record.revision);
1756
+ if (cleanupReasonForMessage(cleanupMessage) === 'modified_after_finalize')
1757
+ return cleanupBlocked('uncommitted_changes', cleanupMessage, record.revision);
1758
+ }
1759
+ if (record.delivery.state === 'retained'
1760
+ && (record.delivery.retention === 'retain_manual' || record.delivery.expiresAt === null || record.delivery.expiresAt > Date.now())) {
1761
+ return { eligibility: 'retained', reason: 'retention_active', message: record.delivery.retention === 'retain_manual' ? '按用户选择手动保留。' : '保留期限尚未到期。', inspectedRevision: record.revision };
1762
+ }
1763
+ return { eligibility: 'safe', reason: 'cleanup_failed', message: record.delivery.cleanupMessage ?? '已通过只读安全巡检,可以清理。', inspectedRevision: record.revision };
1764
+ }
1765
+ async function inspectManagedWorktreeCleanup(input = {}) {
1766
+ const records = Object.values(dependencies.registry.read().managedCheckouts)
1767
+ .filter((record) => record.phase !== 'discarded')
1768
+ .filter((record) => !input.projectId || record.projectId === input.projectId)
1769
+ .filter((record) => !input.checkoutId || record.checkoutId === input.checkoutId);
1770
+ const summaries = await Promise.all(records.map(async (record) => ({
1771
+ ...(await summarizeManagedWorktree(record, true)),
1772
+ cleanup: await inspectCleanupForRecord(record),
1773
+ })));
1774
+ return summaries
1775
+ .filter((summary) => !input.needsAttention || summary.cleanup?.eligibility === 'blocked')
1776
+ .sort((left, right) => right.updatedAt - left.updatedAt);
1777
+ }
1778
+ async function bulkCleanupManagedWorktrees(candidates) {
1779
+ const cleaned = [];
1780
+ const retained = [];
1781
+ const uniqueCandidates = [...new Map(candidates.map((candidate) => [candidate.checkoutId, candidate])).values()]
1782
+ .sort((left, right) => left.checkoutId.localeCompare(right.checkoutId));
1783
+ for (const candidate of uniqueCandidates) {
1784
+ const record = dependencies.registry.read().managedCheckouts[candidate.checkoutId];
1785
+ if (!record || record.phase === 'discarded')
1786
+ continue;
1787
+ if (record.revision !== candidate.expectedRevision) {
1788
+ retained.push({
1789
+ checkoutId: record.checkoutId,
1790
+ iteration: managedIteration(record),
1791
+ cleanup: cleanupBlocked('unknown', 'Worktree revision 已变化,未执行清理。', record.revision),
1792
+ });
1793
+ continue;
1794
+ }
1795
+ const inspection = await inspectCleanupForRecord(record);
1796
+ if (inspection.eligibility !== 'safe') {
1797
+ retained.push({ checkoutId: record.checkoutId, iteration: managedIteration(record), cleanup: inspection });
1798
+ continue;
1799
+ }
1800
+ const latest = dependencies.registry.read().managedCheckouts[candidate.checkoutId];
1801
+ if (!latest || latest.revision !== candidate.expectedRevision) {
1802
+ retained.push({
1803
+ checkoutId: record.checkoutId,
1804
+ iteration: managedIteration(record),
1805
+ cleanup: cleanupBlocked('unknown', 'Worktree 在清理前发生变化,未执行清理。', latest?.revision ?? record.revision),
1806
+ });
1807
+ continue;
1808
+ }
1809
+ const result = await cleanupFinalized(latest, { allowLegacyResidue: true });
1810
+ const updated = dependencies.registry.read().managedCheckouts[candidate.checkoutId];
1811
+ if (result.cleaned) {
1812
+ cleaned.push({
1813
+ checkoutId: record.checkoutId,
1814
+ iteration: managedIteration(record),
1815
+ commitOid: record.delivery.state === 'finalized' || record.delivery.state === 'retained' ? record.delivery.commitOid : null,
1816
+ });
1817
+ }
1818
+ else if (updated) {
1819
+ retained.push({
1820
+ checkoutId: updated.checkoutId,
1821
+ iteration: managedIteration(updated),
1822
+ cleanup: await inspectCleanupForRecord(updated),
1823
+ });
1824
+ }
1825
+ }
1826
+ return { cleaned, retained };
1827
+ }
1828
+ async function listManagedWorktrees(input = {}) {
1829
+ const records = Object.values(dependencies.registry.read().managedCheckouts)
1830
+ .filter((record) => record.phase !== 'discarded')
1831
+ .filter((record) => !input.projectId || record.projectId === input.projectId)
1832
+ .filter((record) => !input.checkoutId || record.checkoutId === input.checkoutId);
1833
+ const summaries = await Promise.all(records.map((record) => summarizeManagedWorktree(record, input.includeDiagnostics === true)));
1834
+ return summaries
1835
+ .filter((summary) => !input.needsAttention || summary.state === 'needs_attention' || summary.state === 'cleanup_pending')
1836
+ .sort((left, right) => right.updatedAt - left.updatedAt);
1837
+ }
1838
+ async function manageManagedWorktree(input) {
1839
+ const record = dependencies.registry.read().managedCheckouts[input.checkoutId];
1840
+ if (!record)
1841
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
1842
+ if (record.revision !== input.expectedRevision)
1843
+ throw new SessionCheckoutError('stale_target', 'Worktree 状态已变化,请刷新后重试');
1844
+ if (input.action === 'discard') {
1845
+ const result = await operateTarget({
1846
+ action: 'discard',
1847
+ sessionId: record.ownerSessionId,
1848
+ expectedRevision: input.expectedRevision,
1849
+ confirmDirty: input.confirmDirty === true,
1850
+ });
1851
+ if (result.status === 'error')
1852
+ throw new SessionCheckoutError(result.code, result.message);
1853
+ const updated = dependencies.registry.read().managedCheckouts[record.checkoutId];
1854
+ if (!updated)
1855
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
1856
+ return summarizeManagedWorktree(updated);
1857
+ }
1858
+ if (input.action === 'set_retention') {
1859
+ if (record.phase !== 'retained' || record.delivery.state !== 'retained' || !input.retention) {
1860
+ throw new SessionCheckoutError('operation_not_allowed', '只有已保留的冻结 Worktree 可以调整保留期限');
1861
+ }
1862
+ const retainedAt = Date.now();
1863
+ const updated = updateManagedCheckout(record.checkoutId, (current) => {
1864
+ if (current.delivery.state !== 'retained')
1865
+ return current;
1866
+ return {
1867
+ ...current,
1868
+ delivery: {
1869
+ ...current.delivery,
1870
+ retention: input.retention,
1871
+ retainedAt,
1872
+ expiresAt: retentionExpiresAt(input.retention, retainedAt),
1873
+ cleanup: 'scheduled',
1874
+ cleanupMessage: undefined,
1875
+ },
1876
+ revision: current.revision + 1,
1877
+ };
1878
+ });
1879
+ if (!updated)
1880
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
1881
+ return summarizeManagedWorktree(updated);
1882
+ }
1883
+ if (record.delivery.state !== 'retained' && record.delivery.state !== 'finalized') {
1884
+ throw new SessionCheckoutError('operation_not_allowed', '当前 Worktree 不处于可清理状态');
1885
+ }
1886
+ await cleanupFinalized(record, { allowLegacyResidue: true });
1887
+ const updated = dependencies.registry.read().managedCheckouts[record.checkoutId];
1888
+ if (!updated)
1889
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 记录不存在');
1890
+ return summarizeManagedWorktree(updated);
1891
+ }
1892
+ async function resolveManagedRoot(checkoutId) {
1893
+ const record = dependencies.registry.read().managedCheckouts[checkoutId];
1894
+ if (!record || record.phase === 'discarded')
1895
+ throw new SessionCheckoutError('checkout_missing', 'Worktree 目录已不存在');
1896
+ const validated = await validateManagedCheckout(bindingForManagedRecord(record), record, false);
1897
+ if (!validated)
1898
+ throw new SessionCheckoutError('checkout_mismatch', 'Worktree 目录身份无法验证');
1899
+ return validated.canonicalManagedRoot;
1900
+ }
1901
+ async function cleanupExpiredRetained(now = Date.now()) {
1902
+ const expired = Object.values(dependencies.registry.read().managedCheckouts).filter((record) => (record.phase === 'retained'
1903
+ && record.delivery.state === 'retained'
1904
+ && record.delivery.cleanup === 'scheduled'
1905
+ && record.delivery.expiresAt !== null
1906
+ && record.delivery.expiresAt <= now));
1907
+ const cleaned = [];
1908
+ for (const record of expired) {
1909
+ try {
1910
+ // 到期保留清理同样受收敛超时保护,避免单个占用记录卡住后续启动。
1911
+ const result = await withCleanupTimeout(record.checkoutId, () => cleanupFinalized(record));
1912
+ if (result?.cleaned)
1913
+ cleaned.push(record.checkoutId);
1914
+ }
1915
+ catch (error) {
1916
+ console.warn(`[session-checkout] expired retained Worktree cleanup skipped: ${error instanceof Error ? error.message : String(error)}`);
1917
+ }
1918
+ }
1919
+ return cleaned;
1920
+ }
1921
+ async function bindTarget(sessionId, choice, createAttempt = 0, requestStartedAt = Date.now()) {
1922
+ const session = requireSession(sessionId);
1923
+ let nextIteration = 1;
1924
+ let replacedDeliveredBinding;
1925
+ const existing = getPersistedBinding(sessionId);
1926
+ if (existing) {
1927
+ if (choice.kind === 'local' && existing.target.kind === 'local') {
1928
+ return inspectLocal(existing);
1929
+ }
1930
+ if (choice.kind === 'isolated' && existing.target.kind === 'isolated') {
1931
+ const existingView = await inspectIsolated(existing);
1932
+ const registry = dependencies.registry.read();
1933
+ const existingRecord = registry.managedCheckouts[existing.target.checkoutId];
1934
+ if (existingView.ownership === 'owner'
1935
+ && existingRecord
1936
+ && ((existingView.checkout.phase === 'discarded' && existingRecord.delivery.state === 'delivered')
1937
+ || (existingView.checkout.phase === 'finalized' && existingRecord.delivery.state === 'finalized')
1938
+ || (existingView.checkout.phase === 'retained' && existingRecord.delivery.state === 'retained'))) {
1939
+ nextIteration = managedIteration(existingRecord) + 1;
1940
+ replacedDeliveredBinding = {
1941
+ ...existing,
1942
+ target: { ...existing.target },
1943
+ };
1944
+ delete registry.sessionBindings[sessionId];
1945
+ registry.revision += 1;
1946
+ dependencies.registry.write(registry);
1947
+ }
1948
+ else {
1949
+ return existingView;
1950
+ }
1951
+ }
1952
+ if (!(choice.kind === 'isolated' && existing.target.kind === 'isolated' && nextIteration > 1)) {
1953
+ throw new SessionCheckoutError('target_already_bound', '会话已经绑定 Session Target,不能切换');
1954
+ }
1955
+ }
1956
+ const { project } = await resolveSessionProject(sessionId);
1957
+ const snapshot = await dependencies.git.inspect(project.root);
1958
+ if (choice.kind === 'local') {
1959
+ const binding = {
1960
+ sessionId,
1961
+ projectId: project.id,
1962
+ projectName: project.name,
1963
+ target: { kind: 'local' },
1964
+ ownerSessionId: sessionId,
1965
+ sourceRef: snapshot?.headRef ?? UNVERSIONED_REF,
1966
+ sourceOid: snapshot?.headOid ?? UNVERSIONED_OID,
1967
+ revision: 1,
1968
+ };
1969
+ const registry = dependencies.registry.read();
1970
+ registry.sessionBindings[sessionId] = binding;
1971
+ registry.revision += 1;
1972
+ dependencies.registry.write(registry);
1973
+ return inspectLocal(binding);
1974
+ }
1975
+ if (!snapshot) {
1976
+ throw new SessionCheckoutError('not_git_repository', '非 Git 项目不能创建 Isolated Checkout');
1977
+ }
1978
+ // managed Worktree 不再使用全局数量硬上限;生命周期通过交付后清理收口。
1979
+ const checkoutId = dependencies.createCheckoutId();
1980
+ const localRoot = await dependencies.files.canonicalize(project.root);
1981
+ 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));
1985
+ const projectRelativePath = relative(localGitRoot, localRoot);
1986
+ if (projectRelativePath.startsWith('..') || isAbsolute(projectRelativePath)) {
1987
+ throw new SessionCheckoutError('checkout_mismatch', '项目根目录不在其 Git checkout 内');
1988
+ }
1989
+ const managedRoot = join(managedGitRoot, projectRelativePath);
1990
+ const record = {
1991
+ checkoutId,
1992
+ projectId: project.id,
1993
+ projectName: project.name,
1994
+ ownerSessionId: sessionId,
1995
+ localRoot,
1996
+ managedRoot: resolve(managedRoot),
1997
+ managedGitRoot: resolve(managedGitRoot),
1998
+ gitCommonDir: snapshot.commonDir,
1999
+ gitDir: '',
2000
+ baseOid: snapshot.headOid,
2001
+ sourceRef: snapshot.headRef,
2002
+ phase: 'preparing',
2003
+ delivery: { state: 'working', iteration: nextIteration },
2004
+ journal: {
2005
+ operation: 'create',
2006
+ operationId: dependencies.createCheckoutId(),
2007
+ step: 'creating_worktree',
2008
+ startedAt: Date.now(),
2009
+ },
2010
+ revision: 1,
2011
+ };
2012
+ const binding = {
2013
+ sessionId,
2014
+ projectId: project.id,
2015
+ projectName: project.name,
2016
+ target: { kind: 'isolated', checkoutId },
2017
+ ownerSessionId: sessionId,
2018
+ sourceRef: snapshot.headRef,
2019
+ sourceOid: snapshot.headOid,
2020
+ revision: 1,
2021
+ };
2022
+ const preparingRegistry = dependencies.registry.read();
2023
+ preparingRegistry.sessionBindings[sessionId] = binding;
2024
+ preparingRegistry.managedCheckouts[checkoutId] = record;
2025
+ preparingRegistry.revision += 1;
2026
+ dependencies.registry.write(preparingRegistry);
2027
+ try {
2028
+ await dependencies.git.createDetachedWorktree(localGitRoot, managedGitRoot, snapshot.headOid);
2029
+ const canonicalManagedGitRoot = await dependencies.files.canonicalize(managedGitRoot);
2030
+ const canonicalManagedRoot = await dependencies.files.canonicalize(managedRoot);
2031
+ const created = await dependencies.git.inspect(canonicalManagedRoot);
2032
+ if (!created
2033
+ || !pathsEqual(created.root, canonicalManagedGitRoot)
2034
+ || !pathsEqual(created.commonDir, snapshot.commonDir)) {
2035
+ throw new SessionCheckoutError('checkout_mismatch', '新建 checkout 的 Git common dir 不匹配');
2036
+ }
2037
+ const readyRegistry = dependencies.registry.read();
2038
+ const readyRecord = {
2039
+ ...record,
2040
+ managedRoot: canonicalManagedRoot,
2041
+ managedGitRoot: canonicalManagedGitRoot,
2042
+ gitDir: created.gitDir,
2043
+ phase: 'ready',
2044
+ journal: null,
2045
+ revision: record.revision + 1,
2046
+ };
2047
+ readyRegistry.managedCheckouts[checkoutId] = readyRecord;
2048
+ readyRegistry.revision += 1;
2049
+ dependencies.registry.write(readyRegistry);
2050
+ return inspectIsolated(binding);
2051
+ }
2052
+ catch (error) {
2053
+ let partialCheckout = null;
2054
+ try {
2055
+ // 仓库内布局下,残余目录会被 git 识别为上层主仓库 checkout;
2056
+ // 有效 worktree 必须带 .git 文件(git worktree add 创建),否则视为残余。
2057
+ if (dependencies.files.exists(join(managedGitRoot, '.git'))) {
2058
+ partialCheckout = await dependencies.git.inspect(managedRoot);
2059
+ }
2060
+ }
2061
+ catch {
2062
+ partialCheckout = null;
2063
+ }
2064
+ if (partialCheckout) {
2065
+ markRecoveryRequired(record);
2066
+ throw error;
2067
+ }
2068
+ let residueRemoved = false;
2069
+ try {
2070
+ residueRemoved = dependencies.files.removeEmptyDirectoryTree(managedGitRoot);
2071
+ }
2072
+ catch {
2073
+ residueRemoved = false;
2074
+ }
2075
+ if (!residueRemoved) {
2076
+ markRecoveryRequired(record);
2077
+ throw new SessionCheckoutError('recovery_required', 'Worktree 创建失败且残余目录包含未知内容,已保留现场,请查看原因或改用新会话');
2078
+ }
2079
+ const failedRegistry = dependencies.registry.read();
2080
+ delete failedRegistry.managedCheckouts[checkoutId];
2081
+ const currentBinding = failedRegistry.sessionBindings[sessionId];
2082
+ if (currentBinding?.target.kind === 'isolated' && currentBinding.target.checkoutId === checkoutId) {
2083
+ if (replacedDeliveredBinding) {
2084
+ failedRegistry.sessionBindings[sessionId] = replacedDeliveredBinding;
2085
+ }
2086
+ else {
2087
+ delete failedRegistry.sessionBindings[sessionId];
2088
+ }
2089
+ }
2090
+ failedRegistry.revision += 1;
2091
+ dependencies.registry.write(failedRegistry);
2092
+ if (createAttempt < 1) {
2093
+ return bindTarget(sessionId, choice, createAttempt + 1, requestStartedAt);
2094
+ }
2095
+ throw new SessionCheckoutError('git_operation_failed', 'Worktree 创建失败,已安全清理残余目录,可直接重试');
2096
+ }
2097
+ }
2098
+ return {
2099
+ inspect: inspectAvailable,
2100
+ preflight: (sessionId, expectedRevision) => withBindingLock(() => preflightTarget(sessionId, expectedRevision)),
2101
+ runExclusiveSessionMutation: (sessionId, operation) => withBindingLock(async () => (operation(await inspectTarget(sessionId, true)))),
2102
+ bind: (sessionId, choice) => {
2103
+ const requestStartedAt = Date.now();
2104
+ return withBindingLock(() => bindTarget(sessionId, choice, 0, requestStartedAt));
2105
+ },
2106
+ beginNextIteration: (sessionId) => {
2107
+ const requestStartedAt = Date.now();
2108
+ return withBindingLock(() => bindTarget(sessionId, { kind: 'isolated' }, 0, requestStartedAt));
2109
+ },
2110
+ markReadyForReview: (sessionId, input) => withBindingLock(() => markReadyForReviewTarget(sessionId, input)),
2111
+ operate: (input) => withBindingLock(() => operateTarget(input)),
2112
+ // 只读管理列表不占用全局 mutation lock;慢速目录诊断与用户操作互不阻塞。
2113
+ listManagedWorktrees,
2114
+ inspectManagedWorktreeCleanup,
2115
+ bulkCleanupManagedWorktrees: (candidates) => withBindingLock(() => bulkCleanupManagedWorktrees(candidates)),
2116
+ manageManagedWorktree: (input) => withBindingLock(() => manageManagedWorktree(input)),
2117
+ resolveManagedRoot: (checkoutId) => withBindingLock(() => resolveManagedRoot(checkoutId)),
2118
+ cleanupExpiredRetained: (now) => withBindingLock(() => cleanupExpiredRetained(now), { allowConcurrentInspect: true }),
2119
+ reconcile: () => withBindingLock(reconcile, { allowConcurrentInspect: true }),
2120
+ };
2121
+ }
2122
+ //# sourceMappingURL=session-checkout-module.js.map