@markjaquith/agency 2.58.1 → 2.59.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.
package/README.md CHANGED
@@ -925,6 +925,15 @@ conflicting worktrees. Repair is deliberately conservative: it repairs safe Git
925
925
  registration issues and materializes missing checkouts, but never switches a
926
926
  branch, resets a commit, or discards uncommitted work.
927
927
 
928
+ For jj, explicit removal is a suspend operation. Agency records each workspace's
929
+ exact `@` commit and stable change ID in `.agency-jj-resume.json`, roots that
930
+ commit with an internal local bookmark, and only then forgets the workspace.
931
+ The next `work prepare` validates all three identities, restores the workspace by
932
+ editing the recorded commit, and consumes the resume metadata and bookmarks.
933
+ Missing, ambiguous, or conflicting resume state stops instead of falling back to
934
+ the declared delivery bookmark, `@-`, or the base. Unknown checkout cleanliness
935
+ also stops removal. Git worktree removal and preparation are unchanged.
936
+
928
937
  Agency launches every agent beside its epic or task document. Single-phase tasks
929
938
  and phases first fetch repositories and create or reuse worktrees under `code/`,
930
939
  then launch the execution agent from the task directory with absolute context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.58.1",
3
+ "version": "2.59.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -413,6 +413,7 @@ const restoreWorktreeSnapshots = async (
413
413
  snapshot.head,
414
414
  snapshot.path,
415
415
  ])
416
+ await runGit(["jj", "-R", snapshot.path, "edit", snapshot.head])
416
417
  continue
417
418
  }
418
419
  await runGit(
@@ -723,6 +724,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
723
724
  removedWorktrees.push(
724
725
  ...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
725
726
  dryRun: true,
727
+ persistResume: false,
726
728
  })),
727
729
  )
728
730
  }
@@ -781,6 +783,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
781
783
  worktrees.remove(unit.taskId, unit.phaseId, root, {
782
784
  snapshots,
783
785
  lockHeld: true,
786
+ persistResume: false,
784
787
  }),
785
788
  )
786
789
  } catch (cause) {
@@ -891,6 +894,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
891
894
  const result = yield* worktrees
892
895
  .remove(unit.taskId, executionPhaseId(unit), root, {
893
896
  dryRun: true,
897
+ persistResume: false,
894
898
  })
895
899
  .pipe(Effect.either)
896
900
  if (Either.isLeft(result)) {
@@ -1064,6 +1068,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1064
1068
  worktrees.remove(unit.taskId, unit.phaseId, root, {
1065
1069
  snapshots,
1066
1070
  lockHeld: true,
1071
+ persistResume: false,
1067
1072
  }),
1068
1073
  )
1069
1074
  }
@@ -1212,6 +1217,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1212
1217
  root,
1213
1218
  {
1214
1219
  dryRun: true,
1220
+ persistResume: false,
1215
1221
  },
1216
1222
  )),
1217
1223
  )
@@ -1267,6 +1273,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1267
1273
  {
1268
1274
  snapshots,
1269
1275
  lockHeld: true,
1276
+ persistResume: false,
1270
1277
  },
1271
1278
  ),
1272
1279
  )
@@ -1359,6 +1366,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1359
1366
  yield* rejectExistingDestination(destination, "Archive")
1360
1367
  const removedWorktrees = yield* worktrees.remove(taskId, id, root, {
1361
1368
  dryRun: true,
1369
+ persistResume: false,
1362
1370
  })
1363
1371
  const at = new Date().toISOString()
1364
1372
  const content = yield* declarationContent(task, {
@@ -1407,6 +1415,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1407
1415
  worktrees.remove(taskId, id, root, {
1408
1416
  snapshots,
1409
1417
  lockHeld: true,
1418
+ persistResume: false,
1410
1419
  }),
1411
1420
  )
1412
1421
  },
@@ -101,6 +101,7 @@ const restoreSnapshots = async (
101
101
  snapshot.head,
102
102
  snapshot.path,
103
103
  ])
104
+ await runGit(["jj", "-R", snapshot.path, "edit", snapshot.head])
104
105
  continue
105
106
  }
106
107
  await runGit(
@@ -392,6 +393,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
392
393
  worktrees.remove(taskId, undefined, root, {
393
394
  snapshots,
394
395
  lockHeld: true,
396
+ persistResume: false,
395
397
  }),
396
398
  ).then(() => undefined),
397
399
  rollback: () => restoreSnapshots(snapshots),
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, realpath, rename, rm, stat } from "node:fs/promises"
3
+ import { chmod, mkdir, realpath, rename, rm, stat } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
6
  captureErrors,
@@ -34,6 +34,21 @@ const jj = async (args: string[], cwd?: string) => {
34
34
  throw new Error(await new Response(process.stderr).text())
35
35
  }
36
36
 
37
+ const jjOutput = async (args: string[], cwd?: string) => {
38
+ const process = Bun.spawn(["jj", ...args], {
39
+ cwd,
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ })
43
+ const [exitCode, stdout, stderr] = await Promise.all([
44
+ process.exited,
45
+ new Response(process.stdout).text(),
46
+ new Response(process.stderr).text(),
47
+ ])
48
+ if (exitCode !== 0) throw new Error(stderr)
49
+ return stdout.trim()
50
+ }
51
+
37
52
  describe("WorktreeService", () => {
38
53
  let root: string
39
54
  let source: string
@@ -304,6 +319,269 @@ describe("WorktreeService", () => {
304
319
  expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
305
320
  })
306
321
 
322
+ test("suspends and resumes the exact jj working-copy target", async () => {
323
+ if (!Bun.which("jj")) return
324
+ const repository = join(root, "repos/agency")
325
+ await rm(repository, { recursive: true, force: true })
326
+ await git(["clone", source, repository])
327
+ await jj(["git", "init", "--colocate", repository])
328
+ await Bun.write(
329
+ join(root, "agency.json"),
330
+ JSON.stringify({ version: 2, vcs: "jj" }),
331
+ )
332
+ await runTestEffect(
333
+ TaskService.pipe(
334
+ Effect.flatMap((service) =>
335
+ service.create(
336
+ {
337
+ id: "jj-resume",
338
+ ticketUrl: null,
339
+ repo: "agency",
340
+ branch: "task/jj-resume",
341
+ base: "main",
342
+ },
343
+ root,
344
+ ),
345
+ ),
346
+ ),
347
+ )
348
+ const workspace = await runTestEffect(
349
+ WorktreeService.pipe(
350
+ Effect.flatMap((service) =>
351
+ service.materialize("jj-resume", undefined, root),
352
+ ),
353
+ ),
354
+ )
355
+ await jj(["new", "-m", "local-only parent"], workspace.writablePath!)
356
+ await Bun.write(join(workspace.writablePath!, "local.txt"), "preserve me\n")
357
+ await jj(
358
+ ["describe", "-m", "local working target"],
359
+ workspace.writablePath!,
360
+ )
361
+ const target = await jjOutput(
362
+ ["log", "--no-graph", "-r", "@", "-T", 'commit_id ++ "\\t" ++ change_id'],
363
+ workspace.writablePath!,
364
+ )
365
+ const [commitId, changeId] = target.split("\t")
366
+ if (!commitId || !changeId)
367
+ throw new Error(`Unexpected jj identity: ${target}`)
368
+ await jj(["new", "-m", "temporary successor"], workspace.writablePath!)
369
+ await jj(["edit", commitId], workspace.writablePath!)
370
+
371
+ await runTestEffect(
372
+ WorktreeService.pipe(
373
+ Effect.flatMap((service) =>
374
+ service.remove("jj-resume", undefined, root),
375
+ ),
376
+ ),
377
+ )
378
+ const resumePath = join(root, "tasks/jj-resume/.agency-jj-resume.json")
379
+ const resume = await Bun.file(resumePath).json()
380
+ expect(resume.checkouts[0]).toMatchObject({ commitId, changeId })
381
+ expect(
382
+ await jjOutput(
383
+ [
384
+ "log",
385
+ "--no-graph",
386
+ "-r",
387
+ resume.checkouts[0].bookmark,
388
+ "-T",
389
+ "commit_id",
390
+ ],
391
+ repository,
392
+ ),
393
+ ).toBe(commitId)
394
+ expect(
395
+ await jjOutput(
396
+ ["log", "--no-graph", "-r", "task/jj-resume", "-T", "commit_id"],
397
+ repository,
398
+ ).catch(() => null),
399
+ ).toBeNull()
400
+
401
+ await runTestEffect(
402
+ WorktreeService.pipe(
403
+ Effect.flatMap((service) =>
404
+ service.materialize("jj-resume", undefined, root),
405
+ ),
406
+ ),
407
+ )
408
+ expect(
409
+ await jjOutput(
410
+ ["log", "--no-graph", "-r", "@", "-T", "commit_id"],
411
+ workspace.writablePath!,
412
+ ),
413
+ ).toBe(commitId)
414
+ expect(
415
+ await Bun.file(join(workspace.writablePath!, "local.txt")).text(),
416
+ ).toBe("preserve me\n")
417
+ expect(await Bun.file(resumePath).exists()).toBe(false)
418
+ expect(
419
+ await jjOutput(
420
+ [
421
+ "log",
422
+ "--no-graph",
423
+ "-r",
424
+ resume.checkouts[0].bookmark,
425
+ "-T",
426
+ "commit_id",
427
+ ],
428
+ repository,
429
+ ).catch(() => null),
430
+ ).toBeNull()
431
+ })
432
+
433
+ test("refuses stale jj resume identity and stale registrations", async () => {
434
+ if (!Bun.which("jj")) return
435
+ const repository = join(root, "repos/agency")
436
+ await rm(repository, { recursive: true, force: true })
437
+ await git(["clone", source, repository])
438
+ await jj(["git", "init", "--colocate", repository])
439
+ await Bun.write(
440
+ join(root, "agency.json"),
441
+ JSON.stringify({ version: 2, vcs: "jj" }),
442
+ )
443
+ for (const id of ["stale-resume", "stale-registration"]) {
444
+ await runTestEffect(
445
+ TaskService.pipe(
446
+ Effect.flatMap((service) =>
447
+ service.create(
448
+ {
449
+ id,
450
+ ticketUrl: null,
451
+ repo: "agency",
452
+ branch: `task/${id}`,
453
+ base: "main",
454
+ },
455
+ root,
456
+ ),
457
+ ),
458
+ ),
459
+ )
460
+ }
461
+ const suspended = await runTestEffect(
462
+ WorktreeService.pipe(
463
+ Effect.flatMap((service) =>
464
+ service.materialize("stale-resume", undefined, root),
465
+ ),
466
+ ),
467
+ )
468
+ await runTestEffect(
469
+ WorktreeService.pipe(
470
+ Effect.flatMap((service) =>
471
+ service.remove("stale-resume", undefined, root),
472
+ ),
473
+ ),
474
+ )
475
+ const resumePath = join(root, "tasks/stale-resume/.agency-jj-resume.json")
476
+ const resume = await Bun.file(resumePath).json()
477
+ await jj([
478
+ "-R",
479
+ repository,
480
+ "bookmark",
481
+ "delete",
482
+ resume.checkouts[0].bookmark,
483
+ ])
484
+ await expect(
485
+ runTestEffect(
486
+ WorktreeService.pipe(
487
+ Effect.flatMap((service) =>
488
+ service.materialize("stale-resume", undefined, root),
489
+ ),
490
+ ),
491
+ ),
492
+ ).rejects.toThrow("recorded commit, change ID, path, or internal bookmark")
493
+ expect(await Bun.file(resumePath).exists()).toBe(true)
494
+ expect(await Bun.file(suspended.writablePath!).exists()).toBe(false)
495
+
496
+ const stale = await runTestEffect(
497
+ WorktreeService.pipe(
498
+ Effect.flatMap((service) =>
499
+ service.materialize("stale-registration", undefined, root),
500
+ ),
501
+ ),
502
+ )
503
+ await rm(join(stale.writablePath!, ".jj"), { recursive: true, force: true })
504
+ await expect(
505
+ runTestEffect(
506
+ WorktreeService.pipe(
507
+ Effect.flatMap((service) =>
508
+ service.remove("stale-registration", undefined, root),
509
+ ),
510
+ ),
511
+ ),
512
+ ).rejects.toThrow("checkout cleanliness could not be verified")
513
+ })
514
+
515
+ test("retains jj resume state when workspace deletion and rollback fail", async () => {
516
+ if (!Bun.which("jj")) return
517
+ const repository = join(root, "repos/agency")
518
+ await rm(repository, { recursive: true, force: true })
519
+ await git(["clone", source, repository])
520
+ await jj(["git", "init", "--colocate", repository])
521
+ await Bun.write(
522
+ join(root, "agency.json"),
523
+ JSON.stringify({ version: 2, vcs: "jj" }),
524
+ )
525
+ await runTestEffect(
526
+ TaskService.pipe(
527
+ Effect.flatMap((service) =>
528
+ service.create(
529
+ {
530
+ id: "jj-rollback",
531
+ ticketUrl: null,
532
+ repo: "agency",
533
+ branch: "task/jj-rollback",
534
+ base: "main",
535
+ },
536
+ root,
537
+ ),
538
+ ),
539
+ ),
540
+ )
541
+ const workspace = await runTestEffect(
542
+ WorktreeService.pipe(
543
+ Effect.flatMap((service) =>
544
+ service.materialize("jj-rollback", undefined, root),
545
+ ),
546
+ ),
547
+ )
548
+ const fakeBin = join(root, "fake-bin")
549
+ await mkdir(fakeBin)
550
+ const fakeRm = join(fakeBin, "rm")
551
+ await Bun.write(
552
+ fakeRm,
553
+ `#!/bin/sh\nif [ "$1" = "-rf" ] && [ "$2" = ${JSON.stringify(workspace.writablePath!)} ]; then exit 1; fi\nexec ${JSON.stringify(Bun.which("rm")!)} "$@"\n`,
554
+ )
555
+ await chmod(fakeRm, 0o755)
556
+ const originalPath = process.env.PATH
557
+ process.env.PATH = `${fakeBin}:${originalPath}`
558
+ try {
559
+ await expect(
560
+ runTestEffect(
561
+ WorktreeService.pipe(
562
+ Effect.flatMap((service) =>
563
+ service.remove("jj-rollback", undefined, root),
564
+ ),
565
+ ),
566
+ ),
567
+ ).rejects.toThrow("requires manual recovery")
568
+ } finally {
569
+ process.env.PATH = originalPath
570
+ }
571
+ const resumePath = join(root, "tasks/jj-rollback/.agency-jj-resume.json")
572
+ expect(await Bun.file(resumePath).exists()).toBe(true)
573
+
574
+ await runTestEffect(
575
+ WorktreeService.pipe(
576
+ Effect.flatMap((service) =>
577
+ service.materialize("jj-rollback", undefined, root),
578
+ ),
579
+ ),
580
+ )
581
+ expect((await stat(workspace.writablePath!)).isDirectory()).toBe(true)
582
+ expect(await Bun.file(resumePath).exists()).toBe(false)
583
+ })
584
+
307
585
  test("recommends a repository fetch when jj cannot resolve a base", async () => {
308
586
  if (!Bun.which("jj")) return
309
587
  const repository = join(root, "repos/agency")
@@ -1882,6 +2160,111 @@ pr: null
1882
2160
  ).toBe(true)
1883
2161
  })
1884
2162
 
2163
+ test("rebuilds clean drifted jj references without discarding changes", async () => {
2164
+ if (!Bun.which("jj")) return
2165
+ const first = new TextDecoder()
2166
+ .decode(
2167
+ Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
2168
+ stdout: "pipe",
2169
+ }).stdout,
2170
+ )
2171
+ .trim()
2172
+ await Bun.write(join(source, "README.md"), "updated\n")
2173
+ await git(["add", "README.md"], source)
2174
+ await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
2175
+ const second = new TextDecoder()
2176
+ .decode(
2177
+ Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
2178
+ stdout: "pipe",
2179
+ }).stdout,
2180
+ )
2181
+ .trim()
2182
+ const repository = join(root, "repos/agency")
2183
+ await rm(repository, { recursive: true, force: true })
2184
+ await jj(["git", "clone", "--no-colocate", source, repository])
2185
+ await Bun.write(
2186
+ join(root, "agency.json"),
2187
+ JSON.stringify({ version: 2, vcs: "jj" }),
2188
+ )
2189
+ const createDriftedReview = async (id: string) => {
2190
+ const task = await runTestEffect(
2191
+ TaskService.pipe(
2192
+ Effect.flatMap((service) =>
2193
+ service.create(
2194
+ {
2195
+ id,
2196
+ ticketUrl: null,
2197
+ review: {
2198
+ repo: "agency",
2199
+ source: {
2200
+ kind: "branch",
2201
+ ref: "refs/heads/main",
2202
+ },
2203
+ commit: first,
2204
+ refreshedAt: "2026-08-10T00:00:00.000Z",
2205
+ },
2206
+ },
2207
+ root,
2208
+ ),
2209
+ ),
2210
+ ),
2211
+ )
2212
+ const workspace = await runTestEffect(
2213
+ WorktreeService.pipe(
2214
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
2215
+ ),
2216
+ )
2217
+ await Bun.write(task.path, task.content.replace(first, second))
2218
+ return workspace
2219
+ }
2220
+
2221
+ const workspace = await createDriftedReview("drifted-review")
2222
+
2223
+ await expect(
2224
+ runTestEffect(
2225
+ WorktreeService.pipe(
2226
+ Effect.flatMap((service) =>
2227
+ service.materialize("drifted-review", undefined, root),
2228
+ ),
2229
+ ),
2230
+ ),
2231
+ ).rejects.toThrow("does not match reference")
2232
+ const inspection = await runTestEffect(
2233
+ WorktreeService.pipe(
2234
+ Effect.flatMap((service) =>
2235
+ service.inspect("drifted-review", undefined, root),
2236
+ ),
2237
+ ),
2238
+ )
2239
+ expect(inspection.conflicts).toEqual([
2240
+ expect.objectContaining({ kind: "reference-drift" }),
2241
+ ])
2242
+ await runTestEffect(
2243
+ WorktreeService.pipe(
2244
+ Effect.flatMap((service) =>
2245
+ service.rebuild("drifted-review", undefined, root),
2246
+ ),
2247
+ ),
2248
+ )
2249
+ expect(
2250
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
2251
+ ).toBe("updated\n")
2252
+
2253
+ const dirtyWorkspace = await createDriftedReview("dirty-drifted-review")
2254
+ const dirtyPath = join(dirtyWorkspace.reviewPath!, "dirty.txt")
2255
+ await Bun.write(dirtyPath, "preserve me\n")
2256
+ await expect(
2257
+ runTestEffect(
2258
+ WorktreeService.pipe(
2259
+ Effect.flatMap((service) =>
2260
+ service.rebuild("dirty-drifted-review", undefined, root),
2261
+ ),
2262
+ ),
2263
+ ),
2264
+ ).rejects.toThrow("checkout has uncommitted changes")
2265
+ expect(await Bun.file(dirtyPath).text()).toBe("preserve me\n")
2266
+ })
2267
+
1885
2268
  test("restores removed worktrees when rebuild creation fails", async () => {
1886
2269
  await runTestEffect(
1887
2270
  TaskService.pipe(
@@ -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
@@ -254,11 +271,14 @@ const originRef = (ref: string) =>
254
271
  interface MaterializeOptions extends BaseCommandOptions {
255
272
  readonly force?: boolean
256
273
  readonly lockHeld?: boolean
274
+ readonly allowReferenceDrift?: boolean
257
275
  }
258
276
 
259
277
  interface RemoveOptions extends BaseCommandOptions {
260
278
  readonly snapshots?: WorktreeRemovalSnapshot[]
261
279
  readonly lockHeld?: boolean
280
+ readonly allowReferenceDrift?: boolean
281
+ readonly persistResume?: boolean
262
282
  }
263
283
 
264
284
  interface LifecycleOptions extends BaseCommandOptions {
@@ -770,6 +790,177 @@ const jjWorkspaceName = (
770
790
  repo: string,
771
791
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
772
792
 
793
+ const jjResumePath = (taskPath: string, phasePath: string | null) =>
794
+ join(dirname(phasePath ?? taskPath), ".agency-jj-resume.json")
795
+
796
+ const jjResumeBookmark = (
797
+ taskId: string,
798
+ phaseId: string | undefined,
799
+ repo: string,
800
+ ) =>
801
+ `agency-resume/${Buffer.from(`${taskId}:${phaseId ?? "task"}:${repo}:${crypto.randomUUID()}`).toString("hex")}`
802
+
803
+ const parseJjResumeState = (content: string, path: string): JjResumeState => {
804
+ let value: unknown
805
+ try {
806
+ value = JSON.parse(content)
807
+ } catch {
808
+ throw new WorktreeError({
809
+ message: `Invalid jj resume metadata at ${path}`,
810
+ })
811
+ }
812
+ if (
813
+ typeof value !== "object" ||
814
+ value === null ||
815
+ !("version" in value) ||
816
+ value.version !== 1 ||
817
+ !("taskId" in value) ||
818
+ typeof value.taskId !== "string" ||
819
+ !("phaseId" in value) ||
820
+ (value.phaseId !== null && typeof value.phaseId !== "string") ||
821
+ !("checkouts" in value) ||
822
+ !Array.isArray(value.checkouts) ||
823
+ value.checkouts.some(
824
+ (checkout) =>
825
+ typeof checkout !== "object" ||
826
+ checkout === null ||
827
+ ![
828
+ "repo",
829
+ "workspacePath",
830
+ "workspaceName",
831
+ "commitId",
832
+ "changeId",
833
+ "bookmark",
834
+ ].every((key) => key in checkout && typeof checkout[key] === "string"),
835
+ )
836
+ ) {
837
+ throw new WorktreeError({
838
+ message: `Invalid jj resume metadata at ${path}`,
839
+ })
840
+ }
841
+ return value as JjResumeState
842
+ }
843
+
844
+ const readJjResumeState = (path: string) =>
845
+ Effect.gen(function* () {
846
+ const fs = yield* FileSystemService
847
+ if (!(yield* fs.exists(path))) return null
848
+ return parseJjResumeState(yield* fs.readFile(path), path)
849
+ })
850
+
851
+ const writeJjResumeState = (path: string, state: JjResumeState) =>
852
+ Effect.gen(function* () {
853
+ const fs = yield* FileSystemService
854
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
855
+ yield* Effect.gen(function* () {
856
+ yield* fs.writeJSON(temporary, state)
857
+ yield* fs.moveDirectory(temporary, path)
858
+ }).pipe(
859
+ Effect.catchAll((cause) =>
860
+ Effect.gen(function* () {
861
+ if (yield* fs.exists(temporary)) yield* fs.deleteFile(temporary)
862
+ return yield* Effect.fail(cause)
863
+ }),
864
+ ),
865
+ )
866
+ })
867
+
868
+ const jjIdentity = (repositoryPath: string, revision: string) =>
869
+ Effect.gen(function* () {
870
+ const fs = yield* FileSystemService
871
+ const result = yield* fs.runCommand(
872
+ [
873
+ "jj",
874
+ "-R",
875
+ repositoryPath,
876
+ "--no-pager",
877
+ "log",
878
+ "--ignore-working-copy",
879
+ "--no-graph",
880
+ "-r",
881
+ revision,
882
+ "-T",
883
+ 'commit_id ++ "\\t" ++ change_id ++ "\\n"',
884
+ ],
885
+ { captureOutput: true },
886
+ )
887
+ const lines = result.stdout.trim().split("\n").filter(Boolean)
888
+ if (result.exitCode !== 0 || lines.length !== 1) return null
889
+ const [commitId, changeId] = lines[0]!.split("\t")
890
+ return commitId && changeId ? { commitId, changeId } : null
891
+ })
892
+
893
+ const jjSetBookmark = (
894
+ repositoryPath: string,
895
+ bookmark: string,
896
+ commitId: string,
897
+ ) =>
898
+ Effect.gen(function* () {
899
+ const fs = yield* FileSystemService
900
+ const result = yield* fs.runCommand(
901
+ [
902
+ "jj",
903
+ "-R",
904
+ repositoryPath,
905
+ "bookmark",
906
+ "create",
907
+ bookmark,
908
+ "-r",
909
+ commitId,
910
+ ],
911
+ { captureOutput: true },
912
+ )
913
+ if (result.exitCode !== 0)
914
+ return yield* new WorktreeError({
915
+ message: `Failed to create internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
916
+ })
917
+ })
918
+
919
+ const jjDeleteBookmark = (repositoryPath: string, bookmark: string) =>
920
+ Effect.gen(function* () {
921
+ const fs = yield* FileSystemService
922
+ const result = yield* fs.runCommand(
923
+ ["jj", "-R", repositoryPath, "bookmark", "delete", bookmark],
924
+ { captureOutput: true },
925
+ )
926
+ if (result.exitCode !== 0)
927
+ return yield* new WorktreeError({
928
+ message: `Failed to delete internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
929
+ })
930
+ })
931
+
932
+ const jjEditWorkspace = (workspacePath: string, commitId: string) =>
933
+ Effect.gen(function* () {
934
+ const fs = yield* FileSystemService
935
+ const result = yield* fs.runCommand(
936
+ ["jj", "-R", workspacePath, "edit", commitId],
937
+ { captureOutput: true },
938
+ )
939
+ if (result.exitCode !== 0)
940
+ return yield* new WorktreeError({
941
+ message: `Failed to restore jj workspace ${workspacePath} at ${commitId}: ${result.stderr.trim()}`,
942
+ })
943
+ })
944
+
945
+ const restoreJjWorkspace = (
946
+ backend: VersionControlBackend,
947
+ checkout: {
948
+ repositoryPath: string
949
+ workspacePath: string
950
+ workspaceName: string
951
+ commitId: string
952
+ },
953
+ ) =>
954
+ Effect.gen(function* () {
955
+ yield* backend.createWorkspace({
956
+ repositoryPath: checkout.repositoryPath,
957
+ workspacePath: checkout.workspacePath,
958
+ workspaceName: checkout.workspaceName,
959
+ revision: checkout.commitId,
960
+ })
961
+ yield* jjEditWorkspace(checkout.workspacePath, checkout.commitId)
962
+ })
963
+
773
964
  // A process can stop after `jj workspace forget` but before checkout deletion.
774
965
  // Only recover that residual when its repository and complete tree still match.
775
966
  const inspectJjResidual = (
@@ -967,6 +1158,8 @@ const materializeJj = (options: {
967
1158
  workspacePath: string
968
1159
  workspaceName: string
969
1160
  }[] = []
1161
+ const resumePath = jjResumePath(options.taskPath, options.phasePath)
1162
+ const resume = yield* readJjResumeState(resumePath)
970
1163
  const base = "base" in options.execution ? options.execution.base : null
971
1164
  const { verboseLog } = createLoggers(options.commandOptions)
972
1165
  const forwardCommandOutput =
@@ -976,6 +1169,40 @@ const materializeJj = (options: {
976
1169
 
977
1170
  if (!options.commandOptions.dryRun)
978
1171
  yield* fs.createDirectory(options.codePath)
1172
+ if (resume) {
1173
+ const requestedRepos = [
1174
+ ...options.requestedCheckouts.map(({ repo }) => repo),
1175
+ ].sort()
1176
+ const resumeRepos = [...resume.checkouts.map(({ repo }) => repo)].sort()
1177
+ if (
1178
+ resume.taskId !== options.taskId ||
1179
+ resume.phaseId !== (options.phaseId ?? null) ||
1180
+ requestedRepos.join("\0") !== resumeRepos.join("\0")
1181
+ ) {
1182
+ return yield* new WorktreeError({
1183
+ message: `Jj resume metadata at ${resumePath} does not match this execution unit; restore or remove it manually before retrying`,
1184
+ })
1185
+ }
1186
+ for (const checkout of resume.checkouts) {
1187
+ const repositoryPath = join(options.root, "repos", checkout.repo)
1188
+ const bookmark = yield* jjIdentity(repositoryPath, checkout.bookmark)
1189
+ const commit = yield* jjIdentity(repositoryPath, checkout.commitId)
1190
+ if (
1191
+ !bookmark ||
1192
+ !commit ||
1193
+ bookmark.commitId !== checkout.commitId ||
1194
+ bookmark.changeId !== checkout.changeId ||
1195
+ commit.changeId !== checkout.changeId ||
1196
+ checkout.workspacePath !== join(options.codePath, checkout.repo) ||
1197
+ checkout.workspaceName !==
1198
+ jjWorkspaceName(options.taskId, options.phaseId, checkout.repo)
1199
+ ) {
1200
+ return yield* new WorktreeError({
1201
+ message: `Cannot resume jj workspace for '${checkout.repo}'; recorded commit, change ID, path, or internal bookmark no longer agrees`,
1202
+ })
1203
+ }
1204
+ }
1205
+ }
979
1206
 
980
1207
  return yield* Effect.gen(function* () {
981
1208
  for (const checkout of options.requestedCheckouts) {
@@ -986,19 +1213,38 @@ const materializeJj = (options: {
986
1213
  options.phaseId,
987
1214
  checkout.repo,
988
1215
  )
1216
+ const resumeCheckout = resume?.checkouts.find(
1217
+ (entry) => entry.repo === checkout.repo,
1218
+ )
989
1219
  if (!(yield* fs.exists(repositoryPath))) {
990
1220
  return yield* new WorktreeError({
991
1221
  message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
992
1222
  })
993
1223
  }
994
1224
 
995
- const exists = yield* fs.isDirectory(workspacePath)
1225
+ let exists = yield* fs.isDirectory(workspacePath)
996
1226
  const canonicalPath = exists
997
1227
  ? yield* fs.realPath(workspacePath)
998
1228
  : resolve(workspacePath)
999
1229
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1000
1230
  (workspace) => workspace.path === canonicalPath,
1001
1231
  )
1232
+ if (exists && !registered && resumeCheckout) {
1233
+ const residual = yield* inspectJjResidual(
1234
+ options.root,
1235
+ repositoryPath,
1236
+ workspacePath,
1237
+ resumeCheckout.commitId,
1238
+ )
1239
+ if (residual !== "clean") {
1240
+ return yield* new WorktreeError({
1241
+ message: `Cannot resume unregistered jj checkout ${workspacePath}; ${residual === "modified" ? "its contents differ from the recorded commit" : "its repository identity cannot be verified"}`,
1242
+ })
1243
+ }
1244
+ if (!options.commandOptions.dryRun)
1245
+ yield* fs.deleteDirectory(workspacePath)
1246
+ exists = false
1247
+ }
1002
1248
  if (exists && !registered) {
1003
1249
  return yield* new WorktreeError({
1004
1250
  message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
@@ -1010,12 +1256,39 @@ const materializeJj = (options: {
1010
1256
  })
1011
1257
  }
1012
1258
  if (exists && registered) {
1259
+ const actualCommit = resumeCheckout
1260
+ ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1261
+ : yield* backend.workspaceHead(workspacePath)
1262
+ if (resumeCheckout && actualCommit !== resumeCheckout.commitId) {
1263
+ return yield* new WorktreeError({
1264
+ message: `Existing jj workspace ${workspacePath} is at ${actualCommit ?? "an unknown target"}, not recorded resume commit ${resumeCheckout.commitId}`,
1265
+ })
1266
+ }
1267
+ if ("ref" in checkout && !resumeCheckout) {
1268
+ const expectedCommit = yield* backend.resolveRevision(
1269
+ repositoryPath,
1270
+ checkout.ref,
1271
+ )
1272
+ if (!expectedCommit) {
1273
+ return yield* new WorktreeError({
1274
+ message: `Reference '${checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit; run 'agency repo fetch ${checkout.repo}' and retry`,
1275
+ })
1276
+ }
1277
+ if (
1278
+ actualCommit !== expectedCommit &&
1279
+ !options.commandOptions.allowReferenceDrift
1280
+ ) {
1281
+ return yield* new WorktreeError({
1282
+ message: `Existing checkout ${workspacePath} does not match reference '${checkout.ref}' (${expectedCommit})`,
1283
+ })
1284
+ }
1285
+ }
1013
1286
  reports.push({
1014
1287
  repo: checkout.repo,
1015
1288
  kind: "branch" in checkout ? "writable" : "reference",
1016
1289
  path: workspacePath,
1017
1290
  requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
1018
- resolvedCommit: yield* backend.workspaceHead(workspacePath),
1291
+ resolvedCommit: actualCommit,
1019
1292
  action: "reused",
1020
1293
  })
1021
1294
  continue
@@ -1023,10 +1296,9 @@ const materializeJj = (options: {
1023
1296
 
1024
1297
  const requestedRevision =
1025
1298
  "branch" in checkout ? checkout.branch : checkout.ref
1026
- let revision = yield* backend.resolveRevision(
1027
- repositoryPath,
1028
- requestedRevision,
1029
- )
1299
+ let revision =
1300
+ resumeCheckout?.commitId ??
1301
+ (yield* backend.resolveRevision(repositoryPath, requestedRevision))
1030
1302
  if (!revision && "branch" in checkout && base) {
1031
1303
  revision = yield* backend.resolveRevision(repositoryPath, base)
1032
1304
  }
@@ -1058,20 +1330,45 @@ const materializeJj = (options: {
1058
1330
  command,
1059
1331
  status: options.commandOptions.dryRun ? "planned" : "completed",
1060
1332
  })
1061
- if (!options.commandOptions.dryRun) {
1062
- yield* backend.createWorkspace({
1063
- repositoryPath,
1064
- workspacePath,
1065
- workspaceName,
1066
- revision,
1067
- ...("branch" in checkout ? { branch: checkout.branch } : {}),
1333
+ if (resumeCheckout) {
1334
+ operations.push({
1335
+ action: "restore-workspace",
1336
+ repo: checkout.repo,
1337
+ command: [
1338
+ "jj",
1339
+ "-R",
1340
+ workspacePath,
1341
+ "edit",
1342
+ resumeCheckout.commitId,
1343
+ ],
1344
+ status: options.commandOptions.dryRun ? "planned" : "completed",
1068
1345
  })
1346
+ }
1347
+ if (!options.commandOptions.dryRun) {
1348
+ if (resumeCheckout) {
1349
+ yield* restoreJjWorkspace(backend, {
1350
+ repositoryPath,
1351
+ workspacePath,
1352
+ workspaceName,
1353
+ commitId: resumeCheckout.commitId,
1354
+ })
1355
+ } else {
1356
+ yield* backend.createWorkspace({
1357
+ repositoryPath,
1358
+ workspacePath,
1359
+ workspaceName,
1360
+ revision,
1361
+ ...("branch" in checkout ? { branch: checkout.branch } : {}),
1362
+ })
1363
+ }
1069
1364
  created.push({ repositoryPath, workspacePath, workspaceName })
1070
1365
  const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1071
1366
  const registeredAfterCreate = (yield* backend.listWorkspaces(
1072
1367
  repositoryPath,
1073
1368
  )).find((workspace) => workspace.path === canonicalWorkspacePath)
1074
- const head = yield* backend.workspaceHead(workspacePath)
1369
+ const head = resumeCheckout
1370
+ ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1371
+ : yield* backend.workspaceHead(workspacePath)
1075
1372
  if (!registeredAfterCreate || head !== revision) {
1076
1373
  return yield* new WorktreeError({
1077
1374
  message: `Created jj workspace for '${checkout.repo}' failed validation`,
@@ -1108,6 +1405,16 @@ const materializeJj = (options: {
1108
1405
  })
1109
1406
  }
1110
1407
 
1408
+ if (resume && !options.commandOptions.dryRun) {
1409
+ yield* fs.deleteFile(resumePath)
1410
+ for (const checkout of resume.checkouts) {
1411
+ yield* jjDeleteBookmark(
1412
+ join(options.root, "repos", checkout.repo),
1413
+ checkout.bookmark,
1414
+ ).pipe(Effect.ignore)
1415
+ }
1416
+ }
1417
+
1111
1418
  return {
1112
1419
  root: options.root,
1113
1420
  taskPath: options.taskPath,
@@ -1187,6 +1494,16 @@ const removeJj = (
1187
1494
  "phases" in task.data && phaseId
1188
1495
  ? (yield* phases.show(taskId, phaseId, root)).data
1189
1496
  : task.data
1497
+ const phasePath =
1498
+ "phases" in task.data && phaseId
1499
+ ? (yield* phases.show(taskId, phaseId, root)).path
1500
+ : null
1501
+ const resumePath = jjResumePath(task.path, phasePath)
1502
+ if (yield* fs.exists(resumePath)) {
1503
+ return yield* new WorktreeError({
1504
+ message: `Jj resume metadata already exists at ${resumePath}; run 'agency work prepare' to complete the previous resume before removing the workspace again`,
1505
+ })
1506
+ }
1190
1507
  const recoverable = new Map<string, string>()
1191
1508
  const modifiedResiduals = new Map<string, string>()
1192
1509
  for (const checkout of inspection.checkouts) {
@@ -1220,7 +1537,9 @@ const removeJj = (
1220
1537
  checkout.conflicts
1221
1538
  .filter(
1222
1539
  (conflict) =>
1223
- conflict.kind !== "stale-registration" &&
1540
+ !(
1541
+ options.allowReferenceDrift && conflict.kind === "reference-drift"
1542
+ ) &&
1224
1543
  !(
1225
1544
  conflict.kind === "unregistered-checkout" &&
1226
1545
  recoverable.has(checkout.path)
@@ -1244,48 +1563,79 @@ const removeJj = (
1244
1563
  }
1245
1564
 
1246
1565
  const plans: {
1566
+ repo: string
1247
1567
  repositoryPath: string
1248
1568
  workspacePath: string
1249
1569
  workspaceName: string
1250
1570
  head: string
1251
1571
  branch: string | null
1252
1572
  registered: boolean
1573
+ commitId: string
1574
+ changeId: string
1575
+ bookmark: string
1253
1576
  }[] = []
1254
1577
  for (const checkout of inspection.checkouts) {
1255
- if (checkout.dirty) {
1578
+ if (checkout.dirty === true && options.persistResume === false) {
1256
1579
  return yield* new WorktreeError({
1257
1580
  message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1258
1581
  })
1259
1582
  }
1583
+ if (
1584
+ checkout.exists &&
1585
+ checkout.dirty === null &&
1586
+ !recoverable.has(checkout.path)
1587
+ ) {
1588
+ return yield* new WorktreeError({
1589
+ message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
1590
+ })
1591
+ }
1260
1592
  const repositoryPath = join(root, "repos", checkout.repo)
1261
1593
  if (!checkout.registeredPath) {
1262
1594
  const recoveryRevision = recoverable.get(checkout.path)
1263
1595
  if (!recoveryRevision) continue
1596
+ const identity = yield* jjIdentity(repositoryPath, recoveryRevision)
1597
+ if (!identity) {
1598
+ return yield* new WorktreeError({
1599
+ message: `Cannot prove the exact jj target for unregistered checkout ${checkout.path}`,
1600
+ })
1601
+ }
1264
1602
  plans.push({
1603
+ repo: checkout.repo,
1265
1604
  repositoryPath,
1266
1605
  workspacePath: checkout.path,
1267
1606
  workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1268
1607
  head: recoveryRevision,
1269
1608
  branch: checkout.actualBranch,
1270
1609
  registered: false,
1610
+ ...identity,
1611
+ bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1271
1612
  })
1272
1613
  continue
1273
1614
  }
1274
1615
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1275
1616
  (workspace) => workspace.path === checkout.registeredPath,
1276
1617
  )
1277
- if (!registered?.name || !checkout.actualCommit) {
1618
+ if (!registered?.name || !checkout.actualCommit || !registered.commit) {
1278
1619
  return yield* new WorktreeError({
1279
1620
  message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
1280
1621
  })
1281
1622
  }
1623
+ const identity = yield* jjIdentity(repositoryPath, registered.commit)
1624
+ if (!identity || identity.commitId !== registered.commit) {
1625
+ return yield* new WorktreeError({
1626
+ message: `Cannot prove the exact jj target for workspace at ${checkout.registeredPath}`,
1627
+ })
1628
+ }
1282
1629
  plans.push({
1630
+ repo: checkout.repo,
1283
1631
  repositoryPath,
1284
1632
  workspacePath: checkout.path,
1285
1633
  workspaceName: registered.name,
1286
- head: checkout.actualCommit,
1634
+ head: identity.commitId,
1287
1635
  branch: checkout.actualBranch,
1288
1636
  registered: true,
1637
+ ...identity,
1638
+ bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1289
1639
  })
1290
1640
  }
1291
1641
 
@@ -1302,7 +1652,33 @@ const removeJj = (
1302
1652
  if (options.dryRun) return plans.map((plan) => plan.workspacePath)
1303
1653
 
1304
1654
  const completed: typeof plans = []
1655
+ const persistent = options.persistResume !== false && plans.length > 0
1656
+ const resume: JjResumeState = {
1657
+ version: 1,
1658
+ taskId,
1659
+ phaseId: phaseId ?? null,
1660
+ checkouts: plans.map((plan) => ({
1661
+ repo: plan.repo,
1662
+ workspacePath: plan.workspacePath,
1663
+ workspaceName: plan.workspaceName,
1664
+ commitId: plan.commitId,
1665
+ changeId: plan.changeId,
1666
+ bookmark: plan.bookmark,
1667
+ })),
1668
+ }
1669
+ const bookmarks: typeof plans = []
1305
1670
  return yield* Effect.gen(function* () {
1671
+ if (persistent) {
1672
+ for (const plan of plans) {
1673
+ yield* jjSetBookmark(
1674
+ plan.repositoryPath,
1675
+ plan.bookmark,
1676
+ plan.commitId,
1677
+ )
1678
+ bookmarks.push(plan)
1679
+ }
1680
+ yield* writeJjResumeState(resumePath, resume)
1681
+ }
1306
1682
  for (const plan of plans) {
1307
1683
  if (plan.registered) {
1308
1684
  yield* backend.removeWorkspace({
@@ -1322,19 +1698,45 @@ const removeJj = (
1322
1698
  Effect.gen(function* () {
1323
1699
  const rolledBack: string[] = []
1324
1700
  const manualRecovery: string[] = []
1325
- for (const plan of [...completed].reverse()) {
1701
+ const rollback = [...completed]
1702
+ for (const plan of plans) {
1703
+ if (completed.includes(plan)) continue
1704
+ const registered = (yield* backend.listWorkspaces(
1705
+ plan.repositoryPath,
1706
+ )).some((workspace) => workspace.name === plan.workspaceName)
1707
+ if (!registered) rollback.push(plan)
1708
+ }
1709
+ for (const plan of rollback.reverse()) {
1710
+ if (yield* fs.exists(plan.workspacePath)) {
1711
+ const removed = yield* fs
1712
+ .deleteDirectory(plan.workspacePath)
1713
+ .pipe(
1714
+ Effect.as(true),
1715
+ Effect.catchAll(() => Effect.succeed(false)),
1716
+ )
1717
+ if (!removed) {
1718
+ manualRecovery.push(`Restore ${plan.workspacePath}`)
1719
+ continue
1720
+ }
1721
+ }
1326
1722
  const restored = yield* Effect.either(
1327
- backend.createWorkspace({
1723
+ restoreJjWorkspace(backend, {
1328
1724
  repositoryPath: plan.repositoryPath,
1329
1725
  workspacePath: plan.workspacePath,
1330
1726
  workspaceName: plan.workspaceName,
1331
- revision: plan.head,
1332
- ...(plan.branch ? { branch: plan.branch } : {}),
1727
+ commitId: plan.commitId,
1333
1728
  }),
1334
1729
  )
1335
1730
  if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
1336
1731
  else manualRecovery.push(`Restore ${plan.workspacePath}`)
1337
1732
  }
1733
+ if (manualRecovery.length === 0 && persistent) {
1734
+ if (yield* fs.exists(resumePath)) yield* fs.deleteFile(resumePath)
1735
+ for (const plan of bookmarks)
1736
+ yield* jjDeleteBookmark(plan.repositoryPath, plan.bookmark).pipe(
1737
+ Effect.ignore,
1738
+ )
1739
+ }
1338
1740
  return yield* new WorktreeError({
1339
1741
  message: manualRecovery.length
1340
1742
  ? "Workspace removal failed and requires manual recovery"
@@ -2392,7 +2794,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2392
2794
  root,
2393
2795
  )
2394
2796
  const blockingConflicts = inspection.conflicts.filter(
2395
- (conflict) => conflict.kind !== "stale-registration",
2797
+ (conflict) =>
2798
+ conflict.kind !== "stale-registration" &&
2799
+ !(
2800
+ options.allowReferenceDrift &&
2801
+ conflict.kind === "reference-drift"
2802
+ ),
2396
2803
  )
2397
2804
  if (blockingConflicts.length > 0) {
2398
2805
  return yield* new WorktreeError({
@@ -2723,21 +3130,29 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2723
3130
  )
2724
3131
  }
2725
3132
  const inspection = yield* inspectExecution(taskId, phaseId, root)
2726
- const dirty = inspection.checkouts.filter(
2727
- (checkout) => checkout.dirty === true,
3133
+ const notProvablyClean = inspection.checkouts.filter(
3134
+ (checkout) => checkout.exists && checkout.dirty !== false,
2728
3135
  )
2729
- if (dirty.length > 0) {
3136
+ if (notProvablyClean.length > 0) {
3137
+ const unknown = notProvablyClean.some(
3138
+ (checkout) => checkout.dirty === null,
3139
+ )
2730
3140
  return yield* new WorktreeError({
2731
- message: `Cannot rebuild ${dirty.map((checkout) => checkout.path).join(", ")}; checkout has uncommitted changes`,
2732
- conflicts: dirty.flatMap((checkout) => checkout.conflicts),
3141
+ message: `Cannot rebuild ${notProvablyClean.map((checkout) => checkout.path).join(", ")}; ${unknown ? "checkout cleanliness could not be verified" : "checkout has uncommitted changes"}`,
3142
+ conflicts: notProvablyClean.flatMap(
3143
+ (checkout) => checkout.conflicts,
3144
+ ),
2733
3145
  })
2734
3146
  }
2735
- if (inspection.conflicts.length > 0) {
3147
+ const blockingConflicts = inspection.conflicts.filter(
3148
+ (conflict) => conflict.kind !== "reference-drift",
3149
+ )
3150
+ if (blockingConflicts.length > 0) {
2736
3151
  return yield* new WorktreeError({
2737
- message: inspection.conflicts
3152
+ message: blockingConflicts
2738
3153
  .map(({ message }) => message)
2739
3154
  .join("\n"),
2740
- conflicts: inspection.conflicts,
3155
+ conflicts: blockingConflicts,
2741
3156
  })
2742
3157
  }
2743
3158
  yield* service.materialize(taskId, phaseId, inspection.root, {
@@ -2745,6 +3160,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2745
3160
  dryRun: true,
2746
3161
  silent: true,
2747
3162
  lockHeld: true,
3163
+ allowReferenceDrift: true,
2748
3164
  })
2749
3165
  if (options.dryRun) {
2750
3166
  return {
@@ -2768,6 +3184,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2768
3184
  ...options,
2769
3185
  snapshots,
2770
3186
  lockHeld: true,
3187
+ allowReferenceDrift: true,
3188
+ persistResume: false,
2771
3189
  },
2772
3190
  )
2773
3191
  const workspace = yield* service
@@ -2819,7 +3237,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2819
3237
  const restored = yield* fs.runCommand(command, {
2820
3238
  captureOutput: true,
2821
3239
  })
2822
- if (restored.exitCode === 0) rolledBack.push(snapshot.path)
3240
+ const edited =
3241
+ restored.exitCode === 0 && snapshot.vcs === "jj"
3242
+ ? yield* fs.runCommand(
3243
+ ["jj", "-R", snapshot.path, "edit", snapshot.head],
3244
+ { captureOutput: true },
3245
+ )
3246
+ : restored
3247
+ if (edited.exitCode === 0) rolledBack.push(snapshot.path)
2823
3248
  else manualRecovery.push(`Restore ${snapshot.path}`)
2824
3249
  }
2825
3250
  return yield* new WorktreeError({