@markjaquith/agency 2.58.0 → 2.58.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.58.0",
3
+ "version": "2.58.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { ArchiveService } from "./ArchiveService"
@@ -22,6 +22,17 @@ const git = async (args: string[], cwd?: string) => {
22
22
  }
23
23
  }
24
24
 
25
+ const jj = async (args: string[]) => {
26
+ const process = Bun.spawn(["jj", ...args], {
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ })
30
+ await process.exited
31
+ if (process.exitCode !== 0) {
32
+ throw new Error(await new Response(process.stderr).text())
33
+ }
34
+ }
35
+
25
36
  describe("ArchiveService", () => {
26
37
  let root: string
27
38
  const dropTask = (id: string) =>
@@ -265,6 +276,166 @@ describe("ArchiveService", () => {
265
276
  ).toBe(0)
266
277
  })
267
278
 
279
+ test("retries archive after a jj workspace was forgotten but not deleted", async () => {
280
+ if (!Bun.which("jj")) return
281
+ const repository = join(root, "repos/agency")
282
+ await rm(repository, { recursive: true, force: true })
283
+ await git(["clone", join(root, "source"), repository])
284
+ await jj(["git", "init", "--colocate", repository])
285
+ await Bun.write(
286
+ join(root, "agency.json"),
287
+ JSON.stringify({ version: 2, vcs: "jj" }),
288
+ )
289
+ await runTestEffect(
290
+ TaskService.pipe(
291
+ Effect.flatMap((service) =>
292
+ service.create(
293
+ {
294
+ id: "interrupted",
295
+ ticketUrl: null,
296
+ repo: "agency",
297
+ branch: "task/interrupted",
298
+ base: "main",
299
+ },
300
+ root,
301
+ ),
302
+ ),
303
+ ),
304
+ )
305
+ await runTestEffect(
306
+ TaskService.pipe(
307
+ Effect.flatMap((service) =>
308
+ service.setStatus("interrupted", "dropped", root),
309
+ ),
310
+ ),
311
+ )
312
+ const workspace = await runTestEffect(
313
+ WorktreeService.pipe(
314
+ Effect.flatMap((service) =>
315
+ service.materialize("interrupted", undefined, root),
316
+ ),
317
+ ),
318
+ )
319
+ await jj([
320
+ "-R",
321
+ repository,
322
+ "workspace",
323
+ "forget",
324
+ "agency-interrupted-task-agency",
325
+ ])
326
+
327
+ const preview = await runTestEffect(
328
+ ArchiveService.pipe(
329
+ Effect.flatMap((service) =>
330
+ service.archiveTask("interrupted", root, { dryRun: true }),
331
+ ),
332
+ ),
333
+ )
334
+ expect(preview.removedWorktrees).toEqual([workspace.writablePath!])
335
+
336
+ await runTestEffect(
337
+ ArchiveService.pipe(
338
+ Effect.flatMap((service) => service.archiveTask("interrupted", root)),
339
+ ),
340
+ )
341
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
342
+ expect(
343
+ await Bun.file(join(root, "archive/tasks/interrupted/TASK.md")).exists(),
344
+ ).toBe(true)
345
+ })
346
+
347
+ test("refuses a modified residual jj checkout after workspace removal", async () => {
348
+ if (!Bun.which("jj")) return
349
+ const repository = join(root, "repos/agency")
350
+ await rm(repository, { recursive: true, force: true })
351
+ await git(["clone", join(root, "source"), repository])
352
+ await jj(["git", "init", "--colocate", repository])
353
+ await Bun.write(
354
+ join(root, "agency.json"),
355
+ JSON.stringify({ version: 2, vcs: "jj" }),
356
+ )
357
+ await runTestEffect(
358
+ TaskService.pipe(
359
+ Effect.flatMap((service) =>
360
+ service.create(
361
+ {
362
+ id: "modified-residual",
363
+ ticketUrl: null,
364
+ repo: "agency",
365
+ branch: "task/modified-residual",
366
+ base: "main",
367
+ },
368
+ root,
369
+ ),
370
+ ),
371
+ ),
372
+ )
373
+ await runTestEffect(
374
+ TaskService.pipe(
375
+ Effect.flatMap((service) =>
376
+ service.setStatus("modified-residual", "dropped", root),
377
+ ),
378
+ ),
379
+ )
380
+ const workspace = await runTestEffect(
381
+ WorktreeService.pipe(
382
+ Effect.flatMap((service) =>
383
+ service.materialize("modified-residual", undefined, root),
384
+ ),
385
+ ),
386
+ )
387
+ await jj([
388
+ "-R",
389
+ repository,
390
+ "workspace",
391
+ "forget",
392
+ "agency-modified-residual-task-agency",
393
+ ])
394
+ await Bun.write(join(workspace.writablePath!, "keep.txt"), "keep me\n")
395
+
396
+ await expect(
397
+ runTestEffect(
398
+ ArchiveService.pipe(
399
+ Effect.flatMap((service) =>
400
+ service.archiveTask("modified-residual", root),
401
+ ),
402
+ ),
403
+ ),
404
+ ).rejects.toThrow("contents do not match expected revision")
405
+ expect(
406
+ await Bun.file(join(workspace.writablePath!, "keep.txt")).text(),
407
+ ).toBe("keep me\n")
408
+ expect(
409
+ await Bun.file(join(root, "tasks/modified-residual/TASK.md")).exists(),
410
+ ).toBe(true)
411
+ expect(
412
+ await Bun.file(
413
+ join(
414
+ root,
415
+ `.agency-worktree-${Buffer.from("modified-residual:task").toString("hex")}.lock`,
416
+ ),
417
+ ).exists(),
418
+ ).toBe(false)
419
+
420
+ await rm(join(workspace.writablePath!, "keep.txt"))
421
+ await rm(join(workspace.writablePath!, ".jj"), {
422
+ recursive: true,
423
+ force: true,
424
+ })
425
+ await expect(
426
+ runTestEffect(
427
+ ArchiveService.pipe(
428
+ Effect.flatMap((service) =>
429
+ service.archiveTask("modified-residual", root),
430
+ ),
431
+ ),
432
+ ),
433
+ ).rejects.toThrow("is not registered as a jj workspace")
434
+ expect(
435
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
436
+ ).toBe(true)
437
+ })
438
+
268
439
  test("archives a phase in the mirrored task hierarchy", async () => {
269
440
  await runTestEffect(
270
441
  TaskService.pipe(
@@ -1882,6 +1882,111 @@ pr: null
1882
1882
  ).toBe(true)
1883
1883
  })
1884
1884
 
1885
+ test("rebuilds clean drifted jj references without discarding changes", async () => {
1886
+ if (!Bun.which("jj")) return
1887
+ const first = new TextDecoder()
1888
+ .decode(
1889
+ Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
1890
+ stdout: "pipe",
1891
+ }).stdout,
1892
+ )
1893
+ .trim()
1894
+ await Bun.write(join(source, "README.md"), "updated\n")
1895
+ await git(["add", "README.md"], source)
1896
+ await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
1897
+ const second = new TextDecoder()
1898
+ .decode(
1899
+ Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
1900
+ stdout: "pipe",
1901
+ }).stdout,
1902
+ )
1903
+ .trim()
1904
+ const repository = join(root, "repos/agency")
1905
+ await rm(repository, { recursive: true, force: true })
1906
+ await jj(["git", "clone", "--no-colocate", source, repository])
1907
+ await Bun.write(
1908
+ join(root, "agency.json"),
1909
+ JSON.stringify({ version: 2, vcs: "jj" }),
1910
+ )
1911
+ const createDriftedReview = async (id: string) => {
1912
+ const task = await runTestEffect(
1913
+ TaskService.pipe(
1914
+ Effect.flatMap((service) =>
1915
+ service.create(
1916
+ {
1917
+ id,
1918
+ ticketUrl: null,
1919
+ review: {
1920
+ repo: "agency",
1921
+ source: {
1922
+ kind: "branch",
1923
+ ref: "refs/heads/main",
1924
+ },
1925
+ commit: first,
1926
+ refreshedAt: "2026-08-10T00:00:00.000Z",
1927
+ },
1928
+ },
1929
+ root,
1930
+ ),
1931
+ ),
1932
+ ),
1933
+ )
1934
+ const workspace = await runTestEffect(
1935
+ WorktreeService.pipe(
1936
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
1937
+ ),
1938
+ )
1939
+ await Bun.write(task.path, task.content.replace(first, second))
1940
+ return workspace
1941
+ }
1942
+
1943
+ const workspace = await createDriftedReview("drifted-review")
1944
+
1945
+ await expect(
1946
+ runTestEffect(
1947
+ WorktreeService.pipe(
1948
+ Effect.flatMap((service) =>
1949
+ service.materialize("drifted-review", undefined, root),
1950
+ ),
1951
+ ),
1952
+ ),
1953
+ ).rejects.toThrow("does not match reference")
1954
+ const inspection = await runTestEffect(
1955
+ WorktreeService.pipe(
1956
+ Effect.flatMap((service) =>
1957
+ service.inspect("drifted-review", undefined, root),
1958
+ ),
1959
+ ),
1960
+ )
1961
+ expect(inspection.conflicts).toEqual([
1962
+ expect.objectContaining({ kind: "reference-drift" }),
1963
+ ])
1964
+ await runTestEffect(
1965
+ WorktreeService.pipe(
1966
+ Effect.flatMap((service) =>
1967
+ service.rebuild("drifted-review", undefined, root),
1968
+ ),
1969
+ ),
1970
+ )
1971
+ expect(
1972
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
1973
+ ).toBe("updated\n")
1974
+
1975
+ const dirtyWorkspace = await createDriftedReview("dirty-drifted-review")
1976
+ const dirtyPath = join(dirtyWorkspace.reviewPath!, "dirty.txt")
1977
+ await Bun.write(dirtyPath, "preserve me\n")
1978
+ await expect(
1979
+ runTestEffect(
1980
+ WorktreeService.pipe(
1981
+ Effect.flatMap((service) =>
1982
+ service.rebuild("dirty-drifted-review", undefined, root),
1983
+ ),
1984
+ ),
1985
+ ),
1986
+ ).rejects.toThrow("checkout has uncommitted changes")
1987
+ expect(await Bun.file(dirtyPath).text()).toBe("preserve me\n")
1988
+ })
1989
+
1885
1990
  test("restores removed worktrees when rebuild creation fails", async () => {
1886
1991
  await runTestEffect(
1887
1992
  TaskService.pipe(
@@ -254,11 +254,13 @@ const originRef = (ref: string) =>
254
254
  interface MaterializeOptions extends BaseCommandOptions {
255
255
  readonly force?: boolean
256
256
  readonly lockHeld?: boolean
257
+ readonly allowReferenceDrift?: boolean
257
258
  }
258
259
 
259
260
  interface RemoveOptions extends BaseCommandOptions {
260
261
  readonly snapshots?: WorktreeRemovalSnapshot[]
261
262
  readonly lockHeld?: boolean
263
+ readonly allowReferenceDrift?: boolean
262
264
  }
263
265
 
264
266
  interface LifecycleOptions extends BaseCommandOptions {
@@ -770,6 +772,81 @@ const jjWorkspaceName = (
770
772
  repo: string,
771
773
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
772
774
 
775
+ // A process can stop after `jj workspace forget` but before checkout deletion.
776
+ // Only recover that residual when its repository and complete tree still match.
777
+ const inspectJjResidual = (
778
+ root: string,
779
+ repositoryPath: string,
780
+ workspacePath: string,
781
+ revision: string,
782
+ ) =>
783
+ Effect.gen(function* () {
784
+ const fs = yield* FileSystemService
785
+ const repoPointer = join(workspacePath, ".jj", "repo")
786
+ if (!(yield* fs.exists(repoPointer))) return "unowned" as const
787
+
788
+ const pointer = (yield* fs.readFile(repoPointer)).trim()
789
+ if (!pointer) return "unowned" as const
790
+ const linkedRepository = resolve(dirname(repoPointer), pointer)
791
+ const expectedRepository = join(repositoryPath, ".jj", "repo")
792
+ if (
793
+ !(yield* fs.exists(linkedRepository)) ||
794
+ (yield* fs.realPath(linkedRepository)) !==
795
+ (yield* fs.realPath(expectedRepository))
796
+ )
797
+ return "unowned" as const
798
+
799
+ const comparisonPath = join(
800
+ root,
801
+ `.agency-jj-residual-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
802
+ )
803
+ const comparisonName = basename(comparisonPath)
804
+ return yield* Effect.gen(function* () {
805
+ const created = yield* fs.runCommand(
806
+ [
807
+ "jj",
808
+ "-R",
809
+ repositoryPath,
810
+ "workspace",
811
+ "add",
812
+ "--name",
813
+ comparisonName,
814
+ "-r",
815
+ revision,
816
+ comparisonPath,
817
+ ],
818
+ { captureOutput: true },
819
+ )
820
+ if (created.exitCode !== 0) {
821
+ return yield* new WorktreeError({
822
+ message: `Cannot verify unregistered jj checkout ${workspacePath}: ${created.stderr}`,
823
+ })
824
+ }
825
+ const comparison = yield* fs.runCommand(
826
+ ["diff", "-qr", "-x", ".jj", workspacePath, comparisonPath],
827
+ { captureOutput: true },
828
+ )
829
+ if (comparison.exitCode > 1) {
830
+ return yield* new WorktreeError({
831
+ message: `Cannot verify unregistered jj checkout ${workspacePath}: ${comparison.stderr}`,
832
+ })
833
+ }
834
+ return comparison.exitCode === 0
835
+ ? ("clean" as const)
836
+ : ("modified" as const)
837
+ }).pipe(
838
+ Effect.ensuring(
839
+ Effect.gen(function* () {
840
+ yield* fs.runCommand(
841
+ ["jj", "-R", repositoryPath, "workspace", "forget", comparisonName],
842
+ { captureOutput: true },
843
+ )
844
+ yield* fs.deleteDirectory(comparisonPath)
845
+ }).pipe(Effect.ignore),
846
+ ),
847
+ )
848
+ })
849
+
773
850
  const buildInspectionContext = (
774
851
  startPath: string,
775
852
  skipWritableRevisionResolution: boolean,
@@ -935,12 +1012,32 @@ const materializeJj = (options: {
935
1012
  })
936
1013
  }
937
1014
  if (exists && registered) {
1015
+ const actualCommit = yield* backend.workspaceHead(workspacePath)
1016
+ if ("ref" in checkout) {
1017
+ const expectedCommit = yield* backend.resolveRevision(
1018
+ repositoryPath,
1019
+ checkout.ref,
1020
+ )
1021
+ if (!expectedCommit) {
1022
+ return yield* new WorktreeError({
1023
+ message: `Reference '${checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit; run 'agency repo fetch ${checkout.repo}' and retry`,
1024
+ })
1025
+ }
1026
+ if (
1027
+ actualCommit !== expectedCommit &&
1028
+ !options.commandOptions.allowReferenceDrift
1029
+ ) {
1030
+ return yield* new WorktreeError({
1031
+ message: `Existing checkout ${workspacePath} does not match reference '${checkout.ref}' (${expectedCommit})`,
1032
+ })
1033
+ }
1034
+ }
938
1035
  reports.push({
939
1036
  repo: checkout.repo,
940
1037
  kind: "branch" in checkout ? "writable" : "reference",
941
1038
  path: workspacePath,
942
1039
  requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
943
- resolvedCommit: yield* backend.workspaceHead(workspacePath),
1040
+ resolvedCommit: actualCommit,
944
1041
  action: "reused",
945
1042
  })
946
1043
  continue
@@ -1103,10 +1200,66 @@ const removeJj = (
1103
1200
  Effect.gen(function* () {
1104
1201
  const fs = yield* FileSystemService
1105
1202
  const versionControl = yield* VersionControlService
1203
+ const tasks = yield* TaskService
1204
+ const phases = yield* PhaseService
1106
1205
  const backend = yield* versionControl.forWorkbase(root)
1107
1206
  const inspection = yield* inspectExecution(taskId, phaseId, root)
1108
- const blocking = inspection.conflicts.filter(
1109
- (conflict) => conflict.kind !== "stale-registration",
1207
+ const task = yield* tasks.show(taskId, root)
1208
+ const execution =
1209
+ "phases" in task.data && phaseId
1210
+ ? (yield* phases.show(taskId, phaseId, root)).data
1211
+ : task.data
1212
+ const recoverable = new Map<string, string>()
1213
+ const modifiedResiduals = new Map<string, string>()
1214
+ for (const checkout of inspection.checkouts) {
1215
+ const recoveryRevision =
1216
+ checkout.expectedCommit ??
1217
+ (checkout.kind === "writable" && "base" in execution
1218
+ ? yield* backend.resolveRevision(
1219
+ join(root, "repos", checkout.repo),
1220
+ execution.base,
1221
+ )
1222
+ : null)
1223
+ if (
1224
+ !checkout.conflicts.some(
1225
+ (conflict) => conflict.kind === "unregistered-checkout",
1226
+ ) ||
1227
+ !recoveryRevision
1228
+ )
1229
+ continue
1230
+ const repositoryPath = join(root, "repos", checkout.repo)
1231
+ const residual = yield* inspectJjResidual(
1232
+ root,
1233
+ repositoryPath,
1234
+ checkout.path,
1235
+ recoveryRevision,
1236
+ )
1237
+ if (residual === "clean") recoverable.set(checkout.path, recoveryRevision)
1238
+ else if (residual === "modified")
1239
+ modifiedResiduals.set(checkout.path, recoveryRevision)
1240
+ }
1241
+ const blocking = inspection.checkouts.flatMap((checkout) =>
1242
+ checkout.conflicts
1243
+ .filter(
1244
+ (conflict) =>
1245
+ conflict.kind !== "stale-registration" &&
1246
+ !(
1247
+ options.allowReferenceDrift && conflict.kind === "reference-drift"
1248
+ ) &&
1249
+ !(
1250
+ conflict.kind === "unregistered-checkout" &&
1251
+ recoverable.has(checkout.path)
1252
+ ),
1253
+ )
1254
+ .map((conflict) =>
1255
+ conflict.kind === "unregistered-checkout" &&
1256
+ modifiedResiduals.has(checkout.path)
1257
+ ? {
1258
+ ...conflict,
1259
+ message: `Cannot remove unregistered jj checkout ${checkout.path}; its contents do not match expected revision '${modifiedResiduals.get(checkout.path)}'`,
1260
+ }
1261
+ : conflict,
1262
+ ),
1110
1263
  )
1111
1264
  if (blocking.length > 0) {
1112
1265
  return yield* new WorktreeError({
@@ -1121,6 +1274,7 @@ const removeJj = (
1121
1274
  workspaceName: string
1122
1275
  head: string
1123
1276
  branch: string | null
1277
+ registered: boolean
1124
1278
  }[] = []
1125
1279
  for (const checkout of inspection.checkouts) {
1126
1280
  if (checkout.dirty) {
@@ -1128,8 +1282,20 @@ const removeJj = (
1128
1282
  message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1129
1283
  })
1130
1284
  }
1131
- if (!checkout.registeredPath) continue
1132
1285
  const repositoryPath = join(root, "repos", checkout.repo)
1286
+ if (!checkout.registeredPath) {
1287
+ const recoveryRevision = recoverable.get(checkout.path)
1288
+ if (!recoveryRevision) continue
1289
+ plans.push({
1290
+ repositoryPath,
1291
+ workspacePath: checkout.path,
1292
+ workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1293
+ head: recoveryRevision,
1294
+ branch: checkout.actualBranch,
1295
+ registered: false,
1296
+ })
1297
+ continue
1298
+ }
1133
1299
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1134
1300
  (workspace) => workspace.path === checkout.registeredPath,
1135
1301
  )
@@ -1144,6 +1310,7 @@ const removeJj = (
1144
1310
  workspaceName: registered.name,
1145
1311
  head: checkout.actualCommit,
1146
1312
  branch: checkout.actualBranch,
1313
+ registered: true,
1147
1314
  })
1148
1315
  }
1149
1316
 
@@ -1162,11 +1329,15 @@ const removeJj = (
1162
1329
  const completed: typeof plans = []
1163
1330
  return yield* Effect.gen(function* () {
1164
1331
  for (const plan of plans) {
1165
- yield* backend.removeWorkspace({
1166
- repositoryPath: plan.repositoryPath,
1167
- workspacePath: plan.workspacePath,
1168
- workspaceName: plan.workspaceName,
1169
- })
1332
+ if (plan.registered) {
1333
+ yield* backend.removeWorkspace({
1334
+ repositoryPath: plan.repositoryPath,
1335
+ workspacePath: plan.workspacePath,
1336
+ workspaceName: plan.workspaceName,
1337
+ })
1338
+ } else {
1339
+ yield* fs.deleteDirectory(plan.workspacePath)
1340
+ }
1170
1341
  completed.push(plan)
1171
1342
  }
1172
1343
  yield* fs.deleteDirectoryIfEmpty(inspection.codePath)
@@ -2246,7 +2417,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2246
2417
  root,
2247
2418
  )
2248
2419
  const blockingConflicts = inspection.conflicts.filter(
2249
- (conflict) => conflict.kind !== "stale-registration",
2420
+ (conflict) =>
2421
+ conflict.kind !== "stale-registration" &&
2422
+ !(
2423
+ options.allowReferenceDrift &&
2424
+ conflict.kind === "reference-drift"
2425
+ ),
2250
2426
  )
2251
2427
  if (blockingConflicts.length > 0) {
2252
2428
  return yield* new WorktreeError({
@@ -2577,21 +2753,29 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2577
2753
  )
2578
2754
  }
2579
2755
  const inspection = yield* inspectExecution(taskId, phaseId, root)
2580
- const dirty = inspection.checkouts.filter(
2581
- (checkout) => checkout.dirty === true,
2756
+ const notProvablyClean = inspection.checkouts.filter(
2757
+ (checkout) => checkout.exists && checkout.dirty !== false,
2582
2758
  )
2583
- if (dirty.length > 0) {
2759
+ if (notProvablyClean.length > 0) {
2760
+ const unknown = notProvablyClean.some(
2761
+ (checkout) => checkout.dirty === null,
2762
+ )
2584
2763
  return yield* new WorktreeError({
2585
- message: `Cannot rebuild ${dirty.map((checkout) => checkout.path).join(", ")}; checkout has uncommitted changes`,
2586
- conflicts: dirty.flatMap((checkout) => checkout.conflicts),
2764
+ message: `Cannot rebuild ${notProvablyClean.map((checkout) => checkout.path).join(", ")}; ${unknown ? "checkout cleanliness could not be verified" : "checkout has uncommitted changes"}`,
2765
+ conflicts: notProvablyClean.flatMap(
2766
+ (checkout) => checkout.conflicts,
2767
+ ),
2587
2768
  })
2588
2769
  }
2589
- if (inspection.conflicts.length > 0) {
2770
+ const blockingConflicts = inspection.conflicts.filter(
2771
+ (conflict) => conflict.kind !== "reference-drift",
2772
+ )
2773
+ if (blockingConflicts.length > 0) {
2590
2774
  return yield* new WorktreeError({
2591
- message: inspection.conflicts
2775
+ message: blockingConflicts
2592
2776
  .map(({ message }) => message)
2593
2777
  .join("\n"),
2594
- conflicts: inspection.conflicts,
2778
+ conflicts: blockingConflicts,
2595
2779
  })
2596
2780
  }
2597
2781
  yield* service.materialize(taskId, phaseId, inspection.root, {
@@ -2599,6 +2783,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2599
2783
  dryRun: true,
2600
2784
  silent: true,
2601
2785
  lockHeld: true,
2786
+ allowReferenceDrift: true,
2602
2787
  })
2603
2788
  if (options.dryRun) {
2604
2789
  return {
@@ -2622,6 +2807,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2622
2807
  ...options,
2623
2808
  snapshots,
2624
2809
  lockHeld: true,
2810
+ allowReferenceDrift: true,
2625
2811
  },
2626
2812
  )
2627
2813
  const workspace = yield* service