@markjaquith/agency 2.58.2 → 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.2",
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")
@@ -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
@@ -261,6 +278,7 @@ interface RemoveOptions extends BaseCommandOptions {
261
278
  readonly snapshots?: WorktreeRemovalSnapshot[]
262
279
  readonly lockHeld?: boolean
263
280
  readonly allowReferenceDrift?: boolean
281
+ readonly persistResume?: boolean
264
282
  }
265
283
 
266
284
  interface LifecycleOptions extends BaseCommandOptions {
@@ -772,6 +790,177 @@ const jjWorkspaceName = (
772
790
  repo: string,
773
791
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
774
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
+
775
964
  // A process can stop after `jj workspace forget` but before checkout deletion.
776
965
  // Only recover that residual when its repository and complete tree still match.
777
966
  const inspectJjResidual = (
@@ -969,6 +1158,8 @@ const materializeJj = (options: {
969
1158
  workspacePath: string
970
1159
  workspaceName: string
971
1160
  }[] = []
1161
+ const resumePath = jjResumePath(options.taskPath, options.phasePath)
1162
+ const resume = yield* readJjResumeState(resumePath)
972
1163
  const base = "base" in options.execution ? options.execution.base : null
973
1164
  const { verboseLog } = createLoggers(options.commandOptions)
974
1165
  const forwardCommandOutput =
@@ -978,6 +1169,40 @@ const materializeJj = (options: {
978
1169
 
979
1170
  if (!options.commandOptions.dryRun)
980
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
+ }
981
1206
 
982
1207
  return yield* Effect.gen(function* () {
983
1208
  for (const checkout of options.requestedCheckouts) {
@@ -988,19 +1213,38 @@ const materializeJj = (options: {
988
1213
  options.phaseId,
989
1214
  checkout.repo,
990
1215
  )
1216
+ const resumeCheckout = resume?.checkouts.find(
1217
+ (entry) => entry.repo === checkout.repo,
1218
+ )
991
1219
  if (!(yield* fs.exists(repositoryPath))) {
992
1220
  return yield* new WorktreeError({
993
1221
  message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
994
1222
  })
995
1223
  }
996
1224
 
997
- const exists = yield* fs.isDirectory(workspacePath)
1225
+ let exists = yield* fs.isDirectory(workspacePath)
998
1226
  const canonicalPath = exists
999
1227
  ? yield* fs.realPath(workspacePath)
1000
1228
  : resolve(workspacePath)
1001
1229
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1002
1230
  (workspace) => workspace.path === canonicalPath,
1003
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
+ }
1004
1248
  if (exists && !registered) {
1005
1249
  return yield* new WorktreeError({
1006
1250
  message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
@@ -1012,8 +1256,15 @@ const materializeJj = (options: {
1012
1256
  })
1013
1257
  }
1014
1258
  if (exists && registered) {
1015
- const actualCommit = yield* backend.workspaceHead(workspacePath)
1016
- if ("ref" in checkout) {
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) {
1017
1268
  const expectedCommit = yield* backend.resolveRevision(
1018
1269
  repositoryPath,
1019
1270
  checkout.ref,
@@ -1045,10 +1296,9 @@ const materializeJj = (options: {
1045
1296
 
1046
1297
  const requestedRevision =
1047
1298
  "branch" in checkout ? checkout.branch : checkout.ref
1048
- let revision = yield* backend.resolveRevision(
1049
- repositoryPath,
1050
- requestedRevision,
1051
- )
1299
+ let revision =
1300
+ resumeCheckout?.commitId ??
1301
+ (yield* backend.resolveRevision(repositoryPath, requestedRevision))
1052
1302
  if (!revision && "branch" in checkout && base) {
1053
1303
  revision = yield* backend.resolveRevision(repositoryPath, base)
1054
1304
  }
@@ -1080,20 +1330,45 @@ const materializeJj = (options: {
1080
1330
  command,
1081
1331
  status: options.commandOptions.dryRun ? "planned" : "completed",
1082
1332
  })
1083
- if (!options.commandOptions.dryRun) {
1084
- yield* backend.createWorkspace({
1085
- repositoryPath,
1086
- workspacePath,
1087
- workspaceName,
1088
- revision,
1089
- ...("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",
1090
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
+ }
1091
1364
  created.push({ repositoryPath, workspacePath, workspaceName })
1092
1365
  const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1093
1366
  const registeredAfterCreate = (yield* backend.listWorkspaces(
1094
1367
  repositoryPath,
1095
1368
  )).find((workspace) => workspace.path === canonicalWorkspacePath)
1096
- const head = yield* backend.workspaceHead(workspacePath)
1369
+ const head = resumeCheckout
1370
+ ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1371
+ : yield* backend.workspaceHead(workspacePath)
1097
1372
  if (!registeredAfterCreate || head !== revision) {
1098
1373
  return yield* new WorktreeError({
1099
1374
  message: `Created jj workspace for '${checkout.repo}' failed validation`,
@@ -1130,6 +1405,16 @@ const materializeJj = (options: {
1130
1405
  })
1131
1406
  }
1132
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
+
1133
1418
  return {
1134
1419
  root: options.root,
1135
1420
  taskPath: options.taskPath,
@@ -1209,6 +1494,16 @@ const removeJj = (
1209
1494
  "phases" in task.data && phaseId
1210
1495
  ? (yield* phases.show(taskId, phaseId, root)).data
1211
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
+ }
1212
1507
  const recoverable = new Map<string, string>()
1213
1508
  const modifiedResiduals = new Map<string, string>()
1214
1509
  for (const checkout of inspection.checkouts) {
@@ -1242,7 +1537,6 @@ const removeJj = (
1242
1537
  checkout.conflicts
1243
1538
  .filter(
1244
1539
  (conflict) =>
1245
- conflict.kind !== "stale-registration" &&
1246
1540
  !(
1247
1541
  options.allowReferenceDrift && conflict.kind === "reference-drift"
1248
1542
  ) &&
@@ -1269,48 +1563,79 @@ const removeJj = (
1269
1563
  }
1270
1564
 
1271
1565
  const plans: {
1566
+ repo: string
1272
1567
  repositoryPath: string
1273
1568
  workspacePath: string
1274
1569
  workspaceName: string
1275
1570
  head: string
1276
1571
  branch: string | null
1277
1572
  registered: boolean
1573
+ commitId: string
1574
+ changeId: string
1575
+ bookmark: string
1278
1576
  }[] = []
1279
1577
  for (const checkout of inspection.checkouts) {
1280
- if (checkout.dirty) {
1578
+ if (checkout.dirty === true && options.persistResume === false) {
1281
1579
  return yield* new WorktreeError({
1282
1580
  message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1283
1581
  })
1284
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
+ }
1285
1592
  const repositoryPath = join(root, "repos", checkout.repo)
1286
1593
  if (!checkout.registeredPath) {
1287
1594
  const recoveryRevision = recoverable.get(checkout.path)
1288
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
+ }
1289
1602
  plans.push({
1603
+ repo: checkout.repo,
1290
1604
  repositoryPath,
1291
1605
  workspacePath: checkout.path,
1292
1606
  workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1293
1607
  head: recoveryRevision,
1294
1608
  branch: checkout.actualBranch,
1295
1609
  registered: false,
1610
+ ...identity,
1611
+ bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1296
1612
  })
1297
1613
  continue
1298
1614
  }
1299
1615
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1300
1616
  (workspace) => workspace.path === checkout.registeredPath,
1301
1617
  )
1302
- if (!registered?.name || !checkout.actualCommit) {
1618
+ if (!registered?.name || !checkout.actualCommit || !registered.commit) {
1303
1619
  return yield* new WorktreeError({
1304
1620
  message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
1305
1621
  })
1306
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
+ }
1307
1629
  plans.push({
1630
+ repo: checkout.repo,
1308
1631
  repositoryPath,
1309
1632
  workspacePath: checkout.path,
1310
1633
  workspaceName: registered.name,
1311
- head: checkout.actualCommit,
1634
+ head: identity.commitId,
1312
1635
  branch: checkout.actualBranch,
1313
1636
  registered: true,
1637
+ ...identity,
1638
+ bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1314
1639
  })
1315
1640
  }
1316
1641
 
@@ -1327,7 +1652,33 @@ const removeJj = (
1327
1652
  if (options.dryRun) return plans.map((plan) => plan.workspacePath)
1328
1653
 
1329
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 = []
1330
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
+ }
1331
1682
  for (const plan of plans) {
1332
1683
  if (plan.registered) {
1333
1684
  yield* backend.removeWorkspace({
@@ -1347,19 +1698,45 @@ const removeJj = (
1347
1698
  Effect.gen(function* () {
1348
1699
  const rolledBack: string[] = []
1349
1700
  const manualRecovery: string[] = []
1350
- 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
+ }
1351
1722
  const restored = yield* Effect.either(
1352
- backend.createWorkspace({
1723
+ restoreJjWorkspace(backend, {
1353
1724
  repositoryPath: plan.repositoryPath,
1354
1725
  workspacePath: plan.workspacePath,
1355
1726
  workspaceName: plan.workspaceName,
1356
- revision: plan.head,
1357
- ...(plan.branch ? { branch: plan.branch } : {}),
1727
+ commitId: plan.commitId,
1358
1728
  }),
1359
1729
  )
1360
1730
  if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
1361
1731
  else manualRecovery.push(`Restore ${plan.workspacePath}`)
1362
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
+ }
1363
1740
  return yield* new WorktreeError({
1364
1741
  message: manualRecovery.length
1365
1742
  ? "Workspace removal failed and requires manual recovery"
@@ -2808,6 +3185,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2808
3185
  snapshots,
2809
3186
  lockHeld: true,
2810
3187
  allowReferenceDrift: true,
3188
+ persistResume: false,
2811
3189
  },
2812
3190
  )
2813
3191
  const workspace = yield* service
@@ -2859,7 +3237,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2859
3237
  const restored = yield* fs.runCommand(command, {
2860
3238
  captureOutput: true,
2861
3239
  })
2862
- 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)
2863
3248
  else manualRecovery.push(`Restore ${snapshot.path}`)
2864
3249
  }
2865
3250
  return yield* new WorktreeError({