@markjaquith/agency 2.58.2 → 2.60.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.
@@ -40,6 +40,7 @@ interface WorkspaceOperation {
40
40
  | "create-branch"
41
41
  | "create-worktree"
42
42
  | "create-workspace"
43
+ | "restore-workspace"
43
44
  | "post-checkout"
44
45
  readonly repo: string
45
46
  readonly command: readonly string[]
@@ -154,6 +155,22 @@ export interface WorktreeRemovalSnapshot {
154
155
  readonly workspaceName?: string
155
156
  }
156
157
 
158
+ interface JjResumeCheckout {
159
+ readonly repo: string
160
+ readonly workspacePath: string
161
+ readonly workspaceName: string
162
+ readonly commitId: string
163
+ readonly changeId: string
164
+ readonly bookmark: string
165
+ }
166
+
167
+ interface JjResumeState {
168
+ readonly version: 1
169
+ readonly taskId: string
170
+ readonly phaseId: string | null
171
+ readonly checkouts: readonly JjResumeCheckout[]
172
+ }
173
+
157
174
  const parseWorktreeList = (output: string): readonly GitWorktree[] => {
158
175
  const worktrees: GitWorktree[] = []
159
176
  let current: { path: string; head?: string; branch?: string } | undefined
@@ -255,12 +272,14 @@ interface MaterializeOptions extends BaseCommandOptions {
255
272
  readonly force?: boolean
256
273
  readonly lockHeld?: boolean
257
274
  readonly allowReferenceDrift?: boolean
275
+ readonly validationAlreadyPerformed?: boolean
258
276
  }
259
277
 
260
278
  interface RemoveOptions extends BaseCommandOptions {
261
279
  readonly snapshots?: WorktreeRemovalSnapshot[]
262
280
  readonly lockHeld?: boolean
263
281
  readonly allowReferenceDrift?: boolean
282
+ readonly persistResume?: boolean
264
283
  }
265
284
 
266
285
  interface LifecycleOptions extends BaseCommandOptions {
@@ -772,6 +791,177 @@ const jjWorkspaceName = (
772
791
  repo: string,
773
792
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
774
793
 
794
+ const jjResumePath = (taskPath: string, phasePath: string | null) =>
795
+ join(dirname(phasePath ?? taskPath), ".agency-jj-resume.json")
796
+
797
+ const jjResumeBookmark = (
798
+ taskId: string,
799
+ phaseId: string | undefined,
800
+ repo: string,
801
+ ) =>
802
+ `agency-resume/${Buffer.from(`${taskId}:${phaseId ?? "task"}:${repo}:${crypto.randomUUID()}`).toString("hex")}`
803
+
804
+ const parseJjResumeState = (content: string, path: string): JjResumeState => {
805
+ let value: unknown
806
+ try {
807
+ value = JSON.parse(content)
808
+ } catch {
809
+ throw new WorktreeError({
810
+ message: `Invalid jj resume metadata at ${path}`,
811
+ })
812
+ }
813
+ if (
814
+ typeof value !== "object" ||
815
+ value === null ||
816
+ !("version" in value) ||
817
+ value.version !== 1 ||
818
+ !("taskId" in value) ||
819
+ typeof value.taskId !== "string" ||
820
+ !("phaseId" in value) ||
821
+ (value.phaseId !== null && typeof value.phaseId !== "string") ||
822
+ !("checkouts" in value) ||
823
+ !Array.isArray(value.checkouts) ||
824
+ value.checkouts.some(
825
+ (checkout) =>
826
+ typeof checkout !== "object" ||
827
+ checkout === null ||
828
+ ![
829
+ "repo",
830
+ "workspacePath",
831
+ "workspaceName",
832
+ "commitId",
833
+ "changeId",
834
+ "bookmark",
835
+ ].every((key) => key in checkout && typeof checkout[key] === "string"),
836
+ )
837
+ ) {
838
+ throw new WorktreeError({
839
+ message: `Invalid jj resume metadata at ${path}`,
840
+ })
841
+ }
842
+ return value as JjResumeState
843
+ }
844
+
845
+ const readJjResumeState = (path: string) =>
846
+ Effect.gen(function* () {
847
+ const fs = yield* FileSystemService
848
+ if (!(yield* fs.exists(path))) return null
849
+ return parseJjResumeState(yield* fs.readFile(path), path)
850
+ })
851
+
852
+ const writeJjResumeState = (path: string, state: JjResumeState) =>
853
+ Effect.gen(function* () {
854
+ const fs = yield* FileSystemService
855
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
856
+ yield* Effect.gen(function* () {
857
+ yield* fs.writeJSON(temporary, state)
858
+ yield* fs.moveDirectory(temporary, path)
859
+ }).pipe(
860
+ Effect.catchAll((cause) =>
861
+ Effect.gen(function* () {
862
+ if (yield* fs.exists(temporary)) yield* fs.deleteFile(temporary)
863
+ return yield* Effect.fail(cause)
864
+ }),
865
+ ),
866
+ )
867
+ })
868
+
869
+ const jjIdentity = (repositoryPath: string, revision: string) =>
870
+ Effect.gen(function* () {
871
+ const fs = yield* FileSystemService
872
+ const result = yield* fs.runCommand(
873
+ [
874
+ "jj",
875
+ "-R",
876
+ repositoryPath,
877
+ "--no-pager",
878
+ "log",
879
+ "--ignore-working-copy",
880
+ "--no-graph",
881
+ "-r",
882
+ revision,
883
+ "-T",
884
+ 'commit_id ++ "\\t" ++ change_id ++ "\\n"',
885
+ ],
886
+ { captureOutput: true },
887
+ )
888
+ const lines = result.stdout.trim().split("\n").filter(Boolean)
889
+ if (result.exitCode !== 0 || lines.length !== 1) return null
890
+ const [commitId, changeId] = lines[0]!.split("\t")
891
+ return commitId && changeId ? { commitId, changeId } : null
892
+ })
893
+
894
+ const jjSetBookmark = (
895
+ repositoryPath: string,
896
+ bookmark: string,
897
+ commitId: string,
898
+ ) =>
899
+ Effect.gen(function* () {
900
+ const fs = yield* FileSystemService
901
+ const result = yield* fs.runCommand(
902
+ [
903
+ "jj",
904
+ "-R",
905
+ repositoryPath,
906
+ "bookmark",
907
+ "create",
908
+ bookmark,
909
+ "-r",
910
+ commitId,
911
+ ],
912
+ { captureOutput: true },
913
+ )
914
+ if (result.exitCode !== 0)
915
+ return yield* new WorktreeError({
916
+ message: `Failed to create internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
917
+ })
918
+ })
919
+
920
+ const jjDeleteBookmark = (repositoryPath: string, bookmark: string) =>
921
+ Effect.gen(function* () {
922
+ const fs = yield* FileSystemService
923
+ const result = yield* fs.runCommand(
924
+ ["jj", "-R", repositoryPath, "bookmark", "delete", bookmark],
925
+ { captureOutput: true },
926
+ )
927
+ if (result.exitCode !== 0)
928
+ return yield* new WorktreeError({
929
+ message: `Failed to delete internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
930
+ })
931
+ })
932
+
933
+ const jjEditWorkspace = (workspacePath: string, commitId: string) =>
934
+ Effect.gen(function* () {
935
+ const fs = yield* FileSystemService
936
+ const result = yield* fs.runCommand(
937
+ ["jj", "-R", workspacePath, "edit", commitId],
938
+ { captureOutput: true },
939
+ )
940
+ if (result.exitCode !== 0)
941
+ return yield* new WorktreeError({
942
+ message: `Failed to restore jj workspace ${workspacePath} at ${commitId}: ${result.stderr.trim()}`,
943
+ })
944
+ })
945
+
946
+ const restoreJjWorkspace = (
947
+ backend: VersionControlBackend,
948
+ checkout: {
949
+ repositoryPath: string
950
+ workspacePath: string
951
+ workspaceName: string
952
+ commitId: string
953
+ },
954
+ ) =>
955
+ Effect.gen(function* () {
956
+ yield* backend.createWorkspace({
957
+ repositoryPath: checkout.repositoryPath,
958
+ workspacePath: checkout.workspacePath,
959
+ workspaceName: checkout.workspaceName,
960
+ revision: checkout.commitId,
961
+ })
962
+ yield* jjEditWorkspace(checkout.workspacePath, checkout.commitId)
963
+ })
964
+
775
965
  // A process can stop after `jj workspace forget` but before checkout deletion.
776
966
  // Only recover that residual when its repository and complete tree still match.
777
967
  const inspectJjResidual = (
@@ -969,6 +1159,8 @@ const materializeJj = (options: {
969
1159
  workspacePath: string
970
1160
  workspaceName: string
971
1161
  }[] = []
1162
+ const resumePath = jjResumePath(options.taskPath, options.phasePath)
1163
+ const resume = yield* readJjResumeState(resumePath)
972
1164
  const base = "base" in options.execution ? options.execution.base : null
973
1165
  const { verboseLog } = createLoggers(options.commandOptions)
974
1166
  const forwardCommandOutput =
@@ -978,6 +1170,40 @@ const materializeJj = (options: {
978
1170
 
979
1171
  if (!options.commandOptions.dryRun)
980
1172
  yield* fs.createDirectory(options.codePath)
1173
+ if (resume) {
1174
+ const requestedRepos = [
1175
+ ...options.requestedCheckouts.map(({ repo }) => repo),
1176
+ ].sort()
1177
+ const resumeRepos = [...resume.checkouts.map(({ repo }) => repo)].sort()
1178
+ if (
1179
+ resume.taskId !== options.taskId ||
1180
+ resume.phaseId !== (options.phaseId ?? null) ||
1181
+ requestedRepos.join("\0") !== resumeRepos.join("\0")
1182
+ ) {
1183
+ return yield* new WorktreeError({
1184
+ message: `Jj resume metadata at ${resumePath} does not match this execution unit; restore or remove it manually before retrying`,
1185
+ })
1186
+ }
1187
+ for (const checkout of resume.checkouts) {
1188
+ const repositoryPath = join(options.root, "repos", checkout.repo)
1189
+ const bookmark = yield* jjIdentity(repositoryPath, checkout.bookmark)
1190
+ const commit = yield* jjIdentity(repositoryPath, checkout.commitId)
1191
+ if (
1192
+ !bookmark ||
1193
+ !commit ||
1194
+ bookmark.commitId !== checkout.commitId ||
1195
+ bookmark.changeId !== checkout.changeId ||
1196
+ commit.changeId !== checkout.changeId ||
1197
+ checkout.workspacePath !== join(options.codePath, checkout.repo) ||
1198
+ checkout.workspaceName !==
1199
+ jjWorkspaceName(options.taskId, options.phaseId, checkout.repo)
1200
+ ) {
1201
+ return yield* new WorktreeError({
1202
+ message: `Cannot resume jj workspace for '${checkout.repo}'; recorded commit, change ID, path, or internal bookmark no longer agrees`,
1203
+ })
1204
+ }
1205
+ }
1206
+ }
981
1207
 
982
1208
  return yield* Effect.gen(function* () {
983
1209
  for (const checkout of options.requestedCheckouts) {
@@ -988,19 +1214,38 @@ const materializeJj = (options: {
988
1214
  options.phaseId,
989
1215
  checkout.repo,
990
1216
  )
1217
+ const resumeCheckout = resume?.checkouts.find(
1218
+ (entry) => entry.repo === checkout.repo,
1219
+ )
991
1220
  if (!(yield* fs.exists(repositoryPath))) {
992
1221
  return yield* new WorktreeError({
993
1222
  message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
994
1223
  })
995
1224
  }
996
1225
 
997
- const exists = yield* fs.isDirectory(workspacePath)
1226
+ let exists = yield* fs.isDirectory(workspacePath)
998
1227
  const canonicalPath = exists
999
1228
  ? yield* fs.realPath(workspacePath)
1000
1229
  : resolve(workspacePath)
1001
1230
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1002
1231
  (workspace) => workspace.path === canonicalPath,
1003
1232
  )
1233
+ if (exists && !registered && resumeCheckout) {
1234
+ const residual = yield* inspectJjResidual(
1235
+ options.root,
1236
+ repositoryPath,
1237
+ workspacePath,
1238
+ resumeCheckout.commitId,
1239
+ )
1240
+ if (residual !== "clean") {
1241
+ return yield* new WorktreeError({
1242
+ message: `Cannot resume unregistered jj checkout ${workspacePath}; ${residual === "modified" ? "its contents differ from the recorded commit" : "its repository identity cannot be verified"}`,
1243
+ })
1244
+ }
1245
+ if (!options.commandOptions.dryRun)
1246
+ yield* fs.deleteDirectory(workspacePath)
1247
+ exists = false
1248
+ }
1004
1249
  if (exists && !registered) {
1005
1250
  return yield* new WorktreeError({
1006
1251
  message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
@@ -1012,8 +1257,15 @@ const materializeJj = (options: {
1012
1257
  })
1013
1258
  }
1014
1259
  if (exists && registered) {
1015
- const actualCommit = yield* backend.workspaceHead(workspacePath)
1016
- if ("ref" in checkout) {
1260
+ const actualCommit = resumeCheckout
1261
+ ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1262
+ : yield* backend.workspaceHead(workspacePath)
1263
+ if (resumeCheckout && actualCommit !== resumeCheckout.commitId) {
1264
+ return yield* new WorktreeError({
1265
+ message: `Existing jj workspace ${workspacePath} is at ${actualCommit ?? "an unknown target"}, not recorded resume commit ${resumeCheckout.commitId}`,
1266
+ })
1267
+ }
1268
+ if ("ref" in checkout && !resumeCheckout) {
1017
1269
  const expectedCommit = yield* backend.resolveRevision(
1018
1270
  repositoryPath,
1019
1271
  checkout.ref,
@@ -1045,10 +1297,9 @@ const materializeJj = (options: {
1045
1297
 
1046
1298
  const requestedRevision =
1047
1299
  "branch" in checkout ? checkout.branch : checkout.ref
1048
- let revision = yield* backend.resolveRevision(
1049
- repositoryPath,
1050
- requestedRevision,
1051
- )
1300
+ let revision =
1301
+ resumeCheckout?.commitId ??
1302
+ (yield* backend.resolveRevision(repositoryPath, requestedRevision))
1052
1303
  if (!revision && "branch" in checkout && base) {
1053
1304
  revision = yield* backend.resolveRevision(repositoryPath, base)
1054
1305
  }
@@ -1080,20 +1331,45 @@ const materializeJj = (options: {
1080
1331
  command,
1081
1332
  status: options.commandOptions.dryRun ? "planned" : "completed",
1082
1333
  })
1083
- if (!options.commandOptions.dryRun) {
1084
- yield* backend.createWorkspace({
1085
- repositoryPath,
1086
- workspacePath,
1087
- workspaceName,
1088
- revision,
1089
- ...("branch" in checkout ? { branch: checkout.branch } : {}),
1334
+ if (resumeCheckout) {
1335
+ operations.push({
1336
+ action: "restore-workspace",
1337
+ repo: checkout.repo,
1338
+ command: [
1339
+ "jj",
1340
+ "-R",
1341
+ workspacePath,
1342
+ "edit",
1343
+ resumeCheckout.commitId,
1344
+ ],
1345
+ status: options.commandOptions.dryRun ? "planned" : "completed",
1090
1346
  })
1347
+ }
1348
+ if (!options.commandOptions.dryRun) {
1349
+ if (resumeCheckout) {
1350
+ yield* restoreJjWorkspace(backend, {
1351
+ repositoryPath,
1352
+ workspacePath,
1353
+ workspaceName,
1354
+ commitId: resumeCheckout.commitId,
1355
+ })
1356
+ } else {
1357
+ yield* backend.createWorkspace({
1358
+ repositoryPath,
1359
+ workspacePath,
1360
+ workspaceName,
1361
+ revision,
1362
+ ...("branch" in checkout ? { branch: checkout.branch } : {}),
1363
+ })
1364
+ }
1091
1365
  created.push({ repositoryPath, workspacePath, workspaceName })
1092
1366
  const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1093
1367
  const registeredAfterCreate = (yield* backend.listWorkspaces(
1094
1368
  repositoryPath,
1095
1369
  )).find((workspace) => workspace.path === canonicalWorkspacePath)
1096
- const head = yield* backend.workspaceHead(workspacePath)
1370
+ const head = resumeCheckout
1371
+ ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1372
+ : yield* backend.workspaceHead(workspacePath)
1097
1373
  if (!registeredAfterCreate || head !== revision) {
1098
1374
  return yield* new WorktreeError({
1099
1375
  message: `Created jj workspace for '${checkout.repo}' failed validation`,
@@ -1130,6 +1406,16 @@ const materializeJj = (options: {
1130
1406
  })
1131
1407
  }
1132
1408
 
1409
+ if (resume && !options.commandOptions.dryRun) {
1410
+ yield* fs.deleteFile(resumePath)
1411
+ for (const checkout of resume.checkouts) {
1412
+ yield* jjDeleteBookmark(
1413
+ join(options.root, "repos", checkout.repo),
1414
+ checkout.bookmark,
1415
+ ).pipe(Effect.ignore)
1416
+ }
1417
+ }
1418
+
1133
1419
  return {
1134
1420
  root: options.root,
1135
1421
  taskPath: options.taskPath,
@@ -1209,6 +1495,16 @@ const removeJj = (
1209
1495
  "phases" in task.data && phaseId
1210
1496
  ? (yield* phases.show(taskId, phaseId, root)).data
1211
1497
  : task.data
1498
+ const phasePath =
1499
+ "phases" in task.data && phaseId
1500
+ ? (yield* phases.show(taskId, phaseId, root)).path
1501
+ : null
1502
+ const resumePath = jjResumePath(task.path, phasePath)
1503
+ if (yield* fs.exists(resumePath)) {
1504
+ return yield* new WorktreeError({
1505
+ message: `Jj resume metadata already exists at ${resumePath}; run 'agency work prepare' to complete the previous resume before removing the workspace again`,
1506
+ })
1507
+ }
1212
1508
  const recoverable = new Map<string, string>()
1213
1509
  const modifiedResiduals = new Map<string, string>()
1214
1510
  for (const checkout of inspection.checkouts) {
@@ -1242,7 +1538,6 @@ const removeJj = (
1242
1538
  checkout.conflicts
1243
1539
  .filter(
1244
1540
  (conflict) =>
1245
- conflict.kind !== "stale-registration" &&
1246
1541
  !(
1247
1542
  options.allowReferenceDrift && conflict.kind === "reference-drift"
1248
1543
  ) &&
@@ -1269,48 +1564,79 @@ const removeJj = (
1269
1564
  }
1270
1565
 
1271
1566
  const plans: {
1567
+ repo: string
1272
1568
  repositoryPath: string
1273
1569
  workspacePath: string
1274
1570
  workspaceName: string
1275
1571
  head: string
1276
1572
  branch: string | null
1277
1573
  registered: boolean
1574
+ commitId: string
1575
+ changeId: string
1576
+ bookmark: string
1278
1577
  }[] = []
1279
1578
  for (const checkout of inspection.checkouts) {
1280
- if (checkout.dirty) {
1579
+ if (checkout.dirty === true && options.persistResume === false) {
1281
1580
  return yield* new WorktreeError({
1282
1581
  message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1283
1582
  })
1284
1583
  }
1584
+ if (
1585
+ checkout.exists &&
1586
+ checkout.dirty === null &&
1587
+ !recoverable.has(checkout.path)
1588
+ ) {
1589
+ return yield* new WorktreeError({
1590
+ message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
1591
+ })
1592
+ }
1285
1593
  const repositoryPath = join(root, "repos", checkout.repo)
1286
1594
  if (!checkout.registeredPath) {
1287
1595
  const recoveryRevision = recoverable.get(checkout.path)
1288
1596
  if (!recoveryRevision) continue
1597
+ const identity = yield* jjIdentity(repositoryPath, recoveryRevision)
1598
+ if (!identity) {
1599
+ return yield* new WorktreeError({
1600
+ message: `Cannot prove the exact jj target for unregistered checkout ${checkout.path}`,
1601
+ })
1602
+ }
1289
1603
  plans.push({
1604
+ repo: checkout.repo,
1290
1605
  repositoryPath,
1291
1606
  workspacePath: checkout.path,
1292
1607
  workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1293
1608
  head: recoveryRevision,
1294
1609
  branch: checkout.actualBranch,
1295
1610
  registered: false,
1611
+ ...identity,
1612
+ bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1296
1613
  })
1297
1614
  continue
1298
1615
  }
1299
1616
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1300
1617
  (workspace) => workspace.path === checkout.registeredPath,
1301
1618
  )
1302
- if (!registered?.name || !checkout.actualCommit) {
1619
+ if (!registered?.name || !checkout.actualCommit || !registered.commit) {
1303
1620
  return yield* new WorktreeError({
1304
1621
  message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
1305
1622
  })
1306
1623
  }
1624
+ const identity = yield* jjIdentity(repositoryPath, registered.commit)
1625
+ if (!identity || identity.commitId !== registered.commit) {
1626
+ return yield* new WorktreeError({
1627
+ message: `Cannot prove the exact jj target for workspace at ${checkout.registeredPath}`,
1628
+ })
1629
+ }
1307
1630
  plans.push({
1631
+ repo: checkout.repo,
1308
1632
  repositoryPath,
1309
1633
  workspacePath: checkout.path,
1310
1634
  workspaceName: registered.name,
1311
- head: checkout.actualCommit,
1635
+ head: identity.commitId,
1312
1636
  branch: checkout.actualBranch,
1313
1637
  registered: true,
1638
+ ...identity,
1639
+ bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1314
1640
  })
1315
1641
  }
1316
1642
 
@@ -1327,7 +1653,33 @@ const removeJj = (
1327
1653
  if (options.dryRun) return plans.map((plan) => plan.workspacePath)
1328
1654
 
1329
1655
  const completed: typeof plans = []
1656
+ const persistent = options.persistResume !== false && plans.length > 0
1657
+ const resume: JjResumeState = {
1658
+ version: 1,
1659
+ taskId,
1660
+ phaseId: phaseId ?? null,
1661
+ checkouts: plans.map((plan) => ({
1662
+ repo: plan.repo,
1663
+ workspacePath: plan.workspacePath,
1664
+ workspaceName: plan.workspaceName,
1665
+ commitId: plan.commitId,
1666
+ changeId: plan.changeId,
1667
+ bookmark: plan.bookmark,
1668
+ })),
1669
+ }
1670
+ const bookmarks: typeof plans = []
1330
1671
  return yield* Effect.gen(function* () {
1672
+ if (persistent) {
1673
+ for (const plan of plans) {
1674
+ yield* jjSetBookmark(
1675
+ plan.repositoryPath,
1676
+ plan.bookmark,
1677
+ plan.commitId,
1678
+ )
1679
+ bookmarks.push(plan)
1680
+ }
1681
+ yield* writeJjResumeState(resumePath, resume)
1682
+ }
1331
1683
  for (const plan of plans) {
1332
1684
  if (plan.registered) {
1333
1685
  yield* backend.removeWorkspace({
@@ -1347,19 +1699,45 @@ const removeJj = (
1347
1699
  Effect.gen(function* () {
1348
1700
  const rolledBack: string[] = []
1349
1701
  const manualRecovery: string[] = []
1350
- for (const plan of [...completed].reverse()) {
1702
+ const rollback = [...completed]
1703
+ for (const plan of plans) {
1704
+ if (completed.includes(plan)) continue
1705
+ const registered = (yield* backend.listWorkspaces(
1706
+ plan.repositoryPath,
1707
+ )).some((workspace) => workspace.name === plan.workspaceName)
1708
+ if (!registered) rollback.push(plan)
1709
+ }
1710
+ for (const plan of rollback.reverse()) {
1711
+ if (yield* fs.exists(plan.workspacePath)) {
1712
+ const removed = yield* fs
1713
+ .deleteDirectory(plan.workspacePath)
1714
+ .pipe(
1715
+ Effect.as(true),
1716
+ Effect.catchAll(() => Effect.succeed(false)),
1717
+ )
1718
+ if (!removed) {
1719
+ manualRecovery.push(`Restore ${plan.workspacePath}`)
1720
+ continue
1721
+ }
1722
+ }
1351
1723
  const restored = yield* Effect.either(
1352
- backend.createWorkspace({
1724
+ restoreJjWorkspace(backend, {
1353
1725
  repositoryPath: plan.repositoryPath,
1354
1726
  workspacePath: plan.workspacePath,
1355
1727
  workspaceName: plan.workspaceName,
1356
- revision: plan.head,
1357
- ...(plan.branch ? { branch: plan.branch } : {}),
1728
+ commitId: plan.commitId,
1358
1729
  }),
1359
1730
  )
1360
1731
  if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
1361
1732
  else manualRecovery.push(`Restore ${plan.workspacePath}`)
1362
1733
  }
1734
+ if (manualRecovery.length === 0 && persistent) {
1735
+ if (yield* fs.exists(resumePath)) yield* fs.deleteFile(resumePath)
1736
+ for (const plan of bookmarks)
1737
+ yield* jjDeleteBookmark(plan.repositoryPath, plan.bookmark).pipe(
1738
+ Effect.ignore,
1739
+ )
1740
+ }
1363
1741
  return yield* new WorktreeError({
1364
1742
  message: manualRecovery.length
1365
1743
  ? "Workspace removal failed and requires manual recovery"
@@ -1473,12 +1851,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1473
1851
  const { root, config } = yield* workbase.loadConfig(startPath)
1474
1852
  const backend = yield* versionControl.forWorkbase(root)
1475
1853
  const materialization = Effect.gen(function* () {
1476
- const report = yield* workbase.validate(root)
1477
- const validationIssue = report.issues[0]
1478
- if (validationIssue && !options.force) {
1479
- return yield* new WorktreeError({
1480
- message: `${validationIssue.path}: ${validationIssue.message}`,
1481
- })
1854
+ if (!options.validationAlreadyPerformed) {
1855
+ const report = yield* workbase.validate(root)
1856
+ const validationIssue = report.issues[0]
1857
+ if (validationIssue && !options.force) {
1858
+ return yield* new WorktreeError({
1859
+ message: `${validationIssue.path}: ${validationIssue.message}`,
1860
+ })
1861
+ }
1482
1862
  }
1483
1863
  const task = yield* tasks.show(taskId, root)
1484
1864
 
@@ -2808,6 +3188,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2808
3188
  snapshots,
2809
3189
  lockHeld: true,
2810
3190
  allowReferenceDrift: true,
3191
+ persistResume: false,
2811
3192
  },
2812
3193
  )
2813
3194
  const workspace = yield* service
@@ -2859,7 +3240,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2859
3240
  const restored = yield* fs.runCommand(command, {
2860
3241
  captureOutput: true,
2861
3242
  })
2862
- if (restored.exitCode === 0) rolledBack.push(snapshot.path)
3243
+ const edited =
3244
+ restored.exitCode === 0 && snapshot.vcs === "jj"
3245
+ ? yield* fs.runCommand(
3246
+ ["jj", "-R", snapshot.path, "edit", snapshot.head],
3247
+ { captureOutput: true },
3248
+ )
3249
+ : restored
3250
+ if (edited.exitCode === 0) rolledBack.push(snapshot.path)
2863
3251
  else manualRecovery.push(`Restore ${snapshot.path}`)
2864
3252
  }
2865
3253
  return yield* new WorktreeError({