@markjaquith/agency 3.2.2 → 3.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +30 -62
  2. package/cli-main.ts +20 -83
  3. package/fixtures/protocol/orchestration-recipes.json +9 -34
  4. package/package.json +1 -4
  5. package/schemas/agency-graph-v1.schema.json +2 -31
  6. package/src/cli-parser.test.ts +34 -132
  7. package/src/cli-parser.ts +45 -102
  8. package/src/cli.test.ts +60 -117
  9. package/src/commands/act.ts +2 -6
  10. package/src/commands/sync.ts +3 -3
  11. package/src/commands/validate.ts +3 -1
  12. package/src/commands/work.test.ts +70 -8
  13. package/src/commands/work.ts +15 -8
  14. package/src/graph-schema.ts +0 -2
  15. package/src/protocol.test.ts +0 -25
  16. package/src/protocol.ts +0 -11
  17. package/src/readiness.test.ts +2 -2
  18. package/src/services/ArchiveBulkService.test.ts +0 -88
  19. package/src/services/ArchiveService.ts +0 -32
  20. package/src/services/GraphMutationService.ts +0 -26
  21. package/src/services/GraphService.test.ts +1 -1
  22. package/src/services/IntegrationService.test.ts +7 -4
  23. package/src/services/LifecycleTransaction.ts +5 -1
  24. package/src/services/PhaseService.ts +1 -8
  25. package/src/services/ReadinessService.test.ts +0 -34
  26. package/src/services/ReadinessService.ts +1 -20
  27. package/src/services/ReviewService.test.ts +0 -64
  28. package/src/services/ReviewService.ts +0 -5
  29. package/src/services/SyncService.test.ts +75 -88
  30. package/src/services/SyncService.ts +52 -123
  31. package/src/services/TaskPhaseService.test.ts +13 -1
  32. package/src/services/TaskService.ts +1 -7
  33. package/src/services/WorkbaseService.ts +0 -3
  34. package/src/services/WorktreeService.test.ts +192 -1
  35. package/src/services/WorktreeService.ts +169 -34
  36. package/src/test-utils.ts +0 -2
  37. package/src/usage-log.test.ts +89 -5
  38. package/src/usage-log.ts +139 -31
  39. package/src/workbase/AGENTS.md +9 -15
  40. package/src/workbase/agent-command.test.ts +1 -3
  41. package/src/workbase/agent-command.ts +1 -6
  42. package/src/workbase/document-revision.ts +0 -4
  43. package/src/workbase/opencode-plugin-file.ts +1 -0
  44. package/src/workbase/schemas.test.ts +12 -25
  45. package/src/workbase/schemas.ts +0 -16
  46. package/src/commands/claim.ts +0 -122
  47. package/src/services/ClaimService.test.ts +0 -415
  48. package/src/services/ClaimService.ts +0 -608
@@ -1,21 +1,28 @@
1
1
  import { Data, Effect, Either } from "effect"
2
2
  import { dirname, join, resolve } from "node:path"
3
3
  import type {
4
- ClaimRecord,
5
4
  PhaseFrontmatter,
6
5
  RepositoryReference,
7
6
  TaskFrontmatter,
8
7
  WorkStatus,
9
8
  PullRequestRecord,
10
9
  } from "../workbase/schemas"
10
+ import { documentRevision } from "../workbase/document-revision"
11
+ import {
12
+ formatMarkdownDocument,
13
+ parseFrontmatterSync,
14
+ } from "../workbase/frontmatter"
11
15
  import {
12
16
  normalizePullRequestRecord,
13
17
  parseOptionalPullRequestRecord,
14
18
  recordFromGitHubJson,
15
19
  resolveDeliveryCommand,
16
20
  } from "../workbase/delivery-command"
17
- import { ClaimService } from "./ClaimService"
18
21
  import { FileSystemService } from "./FileSystemService"
22
+ import {
23
+ documentWriteStep,
24
+ runLifecycleTransaction,
25
+ } from "./LifecycleTransaction"
19
26
  import { WorkbaseService } from "./WorkbaseService"
20
27
  import { WorktreeService } from "./WorktreeService"
21
28
  import {
@@ -37,6 +44,7 @@ interface ExecutionRecord {
37
44
  readonly taskId: string
38
45
  readonly phaseId?: string
39
46
  readonly path: string
47
+ readonly content: string
40
48
  readonly revision: string
41
49
  readonly data: ExecutionData
42
50
  }
@@ -49,11 +57,7 @@ interface RegisteredWorktree {
49
57
  }
50
58
 
51
59
  interface SyncChange {
52
- readonly kind:
53
- | "materialize-workspace"
54
- | "release-stale-claim"
55
- | "record-pr"
56
- | "mark-done"
60
+ readonly kind: "materialize-workspace" | "record-pr" | "mark-done"
57
61
  readonly target: string
58
62
  readonly message: string
59
63
  readonly status: "planned" | "applied"
@@ -84,7 +88,6 @@ interface ExecutionSyncState {
84
88
  readonly status: WorkStatus
85
89
  readonly branch: string | null
86
90
  readonly base: string | null
87
- readonly claim: ClaimRecord | null
88
91
  readonly checkouts: readonly CheckoutState[]
89
92
  readonly pr: Record<string, unknown>
90
93
  readonly review?: {
@@ -140,11 +143,6 @@ const parseJson = <T>(value: string, fallback: T): T => {
140
143
  }
141
144
  }
142
145
 
143
- const isExpired = (claim: ClaimRecord | undefined, now: Date) =>
144
- claim?.state === "active" &&
145
- claim.expiresAt !== undefined &&
146
- Date.parse(claim.expiresAt) <= now.getTime()
147
-
148
146
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
149
147
 
150
148
  const originRef = (ref: string) =>
@@ -199,13 +197,32 @@ const mergedPullRequestFromGitHub = (
199
197
  return current
200
198
  }
201
199
 
200
+ const mutateExecution = (
201
+ root: string,
202
+ record: ExecutionRecord,
203
+ revision: string,
204
+ data: ExecutionData,
205
+ ) => {
206
+ const parsed = parseFrontmatterSync(record.content, record.path)
207
+ const content = formatMarkdownDocument(data, parsed.body)
208
+ return runLifecycleTransaction({
209
+ root,
210
+ preconditions: [{ path: record.path, revision }],
211
+ steps: [documentWriteStep(root, [{ path: record.path, content }])],
212
+ }).pipe(
213
+ Effect.as({ data, revision: documentRevision(content) }),
214
+ Effect.catchTag("LifecycleTransactionError", (error) =>
215
+ Effect.fail(new SyncError({ message: error.message })),
216
+ ),
217
+ )
218
+ }
219
+
202
220
  export class SyncService extends Effect.Service<SyncService>()("SyncService", {
203
221
  sync: () => ({
204
222
  reconcile: (
205
223
  options: {
206
224
  readonly cwd?: string
207
225
  readonly apply?: boolean
208
- readonly now?: Date
209
226
  readonly onProgress?: (progress: SyncProgress) => void
210
227
  readonly taskId?: string
211
228
  readonly phaseId?: string
@@ -215,7 +232,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
215
232
  const fs = yield* FileSystemService
216
233
  const workbase = yield* WorkbaseService
217
234
  const worktrees = yield* WorktreeService
218
- const claims = yield* ClaimService
219
235
  const repositories = yield* RepositoryService
220
236
  const versionControl = yield* VersionControlService
221
237
  const { root, config } = yield* workbase.loadConfig(options.cwd)
@@ -255,6 +271,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
255
271
  taskId: task.id,
256
272
  phaseId: phase.id,
257
273
  path: phase.path,
274
+ content: phase.content,
258
275
  revision: phase.revision,
259
276
  data: phase.data,
260
277
  })
@@ -264,6 +281,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
264
281
  key: `task:${task.id}`,
265
282
  taskId: task.id,
266
283
  path: task.path,
284
+ content: task.content,
267
285
  revision: task.revision,
268
286
  data: task.data,
269
287
  })
@@ -299,7 +317,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
299
317
  })
300
318
 
301
319
  const apply = options.apply === true
302
- const now = options.now ?? new Date()
303
320
  const changes: SyncChange[] = []
304
321
  const warnings: SyncNotice[] = []
305
322
  const unresolved: SyncNotice[] = []
@@ -500,8 +517,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
500
517
  ? null
501
518
  : mergedPullRequestFromGitHub(data, query)
502
519
  const skipCheckoutReconciliation =
503
- Boolean(data.completion) ||
504
- (remoteMergedPr !== null && data.claim?.state !== "active")
520
+ Boolean(data.completion) || remoteMergedPr !== null
505
521
  let materialize = false
506
522
  let workspaceConflict = false
507
523
  const declared: readonly (
@@ -764,57 +780,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
764
780
  })
765
781
  }
766
782
 
767
- if (
768
- isExpired(data.claim, now) &&
769
- (data.status === "working" || data.status === "delegated")
770
- ) {
771
- const sessionId = data.claim!.sessionId
772
- if (apply) {
773
- const expired = yield* claims.expire(
774
- {
775
- taskId: record.taskId,
776
- phaseId: record.phaseId,
777
- revision,
778
- now,
779
- },
780
- root,
781
- )
782
- data = expired.data as ExecutionData
783
- revision = expired.revision
784
- } else {
785
- const claim: ClaimRecord = {
786
- ...data.claim!,
787
- state: "released",
788
- releasedAt: now.toISOString(),
789
- }
790
- data = { ...data, status: "open", claim }
791
- }
792
- changes.push({
793
- kind: "release-stale-claim",
794
- target: record.key,
795
- message: `Release expired claim '${sessionId}'`,
796
- status: apply ? "applied" : "planned",
797
- })
798
- } else if (
799
- data.claim?.state === "active" &&
800
- data.status !== "working" &&
801
- data.status !== "delegated"
802
- ) {
803
- unresolved.push({
804
- kind: "claim-status-conflict",
805
- target: record.key,
806
- message: `Active claim conflicts with '${data.status}' status`,
807
- action: "Release or finish the claim explicitly",
808
- })
809
- }
810
-
811
783
  if (data.completion) {
812
784
  executions.push({
813
785
  target: record.key,
814
786
  status: data.status,
815
787
  branch: data.branch,
816
788
  base: data.base,
817
- claim: data.claim ?? null,
818
789
  checkouts: checkoutStates,
819
790
  pr: { url: null, state: "none" },
820
791
  })
@@ -976,16 +947,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
976
947
 
977
948
  if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
978
949
  if (apply) {
979
- const recorded = yield* claims.reconcile(
980
- {
981
- taskId: record.taskId,
982
- phaseId: record.phaseId,
983
- revision,
984
- pr: current,
985
- },
986
- root,
987
- )
988
- data = recorded.data as ExecutionData
950
+ const recorded = yield* mutateExecution(root, record, revision, {
951
+ ...data,
952
+ pr: current,
953
+ })
954
+ data = recorded.data
989
955
  revision = recorded.revision
990
956
  }
991
957
  changes.push({
@@ -1002,35 +968,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1002
968
  data.status !== "done" &&
1003
969
  data.status !== "dropped"
1004
970
  ) {
1005
- if (data.claim?.state === "active") {
1006
- unresolved.push({
1007
- kind: "merged-with-active-claim",
1008
- target: record.key,
1009
- message:
1010
- "Pull request is merged while the execution unit remains claimed",
1011
- action: "Finish or release the active claim",
1012
- })
1013
- } else {
1014
- if (apply) {
1015
- const completed = yield* claims.reconcile(
1016
- {
1017
- taskId: record.taskId,
1018
- phaseId: record.phaseId,
1019
- revision,
1020
- status: "done",
1021
- },
1022
- root,
1023
- )
1024
- data = completed.data as ExecutionData
1025
- revision = completed.revision
1026
- }
1027
- changes.push({
1028
- kind: "mark-done",
1029
- target: record.key,
1030
- message: "Mark execution unit done from merged pull request",
1031
- status: apply ? "applied" : "planned",
971
+ if (apply) {
972
+ const completed = yield* mutateExecution(root, record, revision, {
973
+ ...data,
974
+ status: "done",
1032
975
  })
976
+ data = completed.data
977
+ revision = completed.revision
1033
978
  }
979
+ changes.push({
980
+ kind: "mark-done",
981
+ target: record.key,
982
+ message: "Mark execution unit done from merged pull request",
983
+ status: apply ? "applied" : "planned",
984
+ })
1034
985
  }
1035
986
 
1036
987
  executions.push({
@@ -1038,7 +989,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1038
989
  status: data.status,
1039
990
  branch: data.branch,
1040
991
  base: data.base,
1041
- claim: data.claim ?? null,
1042
992
  checkouts: checkoutStates,
1043
993
  pr,
1044
994
  })
@@ -1047,27 +997,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1047
997
 
1048
998
  for (const task of reviewRecords) {
1049
999
  if (!("review" in task.data)) continue
1050
- let data = task.data
1051
- let revision = task.revision
1052
- if (
1053
- isExpired(data.claim, now) &&
1054
- (data.status === "working" || data.status === "delegated")
1055
- ) {
1056
- if (apply) {
1057
- const expired = yield* claims.expire(
1058
- { taskId: task.id, revision, now },
1059
- root,
1060
- )
1061
- if ("review" in expired.data) data = expired.data
1062
- revision = expired.revision
1063
- }
1064
- changes.push({
1065
- kind: "release-stale-claim",
1066
- target: `task:${task.id}`,
1067
- message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
1068
- status: apply ? "applied" : "planned",
1069
- })
1070
- }
1000
+ const data = task.data
1071
1001
  const inspection = yield* worktrees.inspect(task.id, undefined, root)
1072
1002
  for (const conflict of inspection.conflicts) {
1073
1003
  unresolved.push({
@@ -1118,7 +1048,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1118
1048
  status: data.status,
1119
1049
  branch: null,
1120
1050
  base: null,
1121
- claim: data.claim ?? null,
1122
1051
  checkouts: checkout
1123
1052
  ? [
1124
1053
  {
@@ -615,7 +615,19 @@ describe("task and phase services", () => {
615
615
  ),
616
616
  ),
617
617
  ),
618
- ).rejects.toThrow("Delegation requires explicit ownership")
618
+ ).rejects.toThrow("Delegated status cannot be set directly")
619
+ await Bun.write(
620
+ createdTask.path,
621
+ workingTask.content.replace("status: working", "status: delegated"),
622
+ )
623
+ const reopenedDelegatedTask = await runTestEffect(
624
+ TaskService.pipe(
625
+ Effect.flatMap((service) =>
626
+ service.setStatus("single-status", "open", root),
627
+ ),
628
+ ),
629
+ )
630
+ expect(reopenedDelegatedTask.data.status).toBe("open")
619
631
  await expect(
620
632
  runTestEffect(
621
633
  TaskService.pipe(
@@ -610,8 +610,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
610
610
  const validStatus = yield* decodeStatus(status)
611
611
  if (validStatus === "delegated") {
612
612
  return yield* new TaskError({
613
- message:
614
- "Delegation requires explicit ownership; use 'agency claim'",
613
+ message: "Delegated status cannot be set directly",
615
614
  })
616
615
  }
617
616
  const record = yield* service.show(id, startPath)
@@ -620,11 +619,6 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
620
619
  message: `Task '${id}' has multiple phases; set status on a phase instead`,
621
620
  })
622
621
  }
623
- if (record.data.claim?.state === "active") {
624
- return yield* new TaskError({
625
- message: `Task '${id}' has an active claim; use agency release or agency finish`,
626
- })
627
- }
628
622
  if (nonPrCompletion && validStatus !== "done") {
629
623
  return yield* new TaskError({
630
624
  message: "Non-PR completion is valid only with a done status",
@@ -870,9 +870,6 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
870
870
  "Non-PR completion cannot have a recorded pull request",
871
871
  )
872
872
  }
873
- if (record.data.claim?.state === "active") {
874
- issue(record.path, "Completed work cannot have an active claim")
875
- }
876
873
  }
877
874
 
878
875
  for (const epic of epics.values()) {
@@ -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 } from "node:fs/promises"
3
+ import { mkdir, realpath, rename, rm, symlink } from "node:fs/promises"
4
4
  import { join, resolve } from "node:path"
5
5
  import {
6
6
  captureErrors,
@@ -1526,6 +1526,107 @@ pr: null
1526
1526
  ).toBe("keep me\n")
1527
1527
  })
1528
1528
 
1529
+ test("refuses to remove worktrees for active work", async () => {
1530
+ await runTestEffect(
1531
+ TaskService.pipe(
1532
+ Effect.flatMap((service) =>
1533
+ service.create(
1534
+ {
1535
+ id: "active-removal",
1536
+ ticketUrl: null,
1537
+ repo: "agency",
1538
+ branch: "task/active-removal",
1539
+ base: "main",
1540
+ },
1541
+ root,
1542
+ ),
1543
+ ),
1544
+ ),
1545
+ )
1546
+ const workspace = await runTestEffect(
1547
+ WorktreeService.pipe(
1548
+ Effect.flatMap((service) =>
1549
+ service.materialize("active-removal", undefined, root),
1550
+ ),
1551
+ ),
1552
+ )
1553
+ await runTestEffect(
1554
+ TaskService.pipe(
1555
+ Effect.flatMap((service) =>
1556
+ service.setStatus("active-removal", "working", root),
1557
+ ),
1558
+ ),
1559
+ )
1560
+
1561
+ await expect(
1562
+ runTestEffect(
1563
+ WorktreeService.pipe(
1564
+ Effect.flatMap((service) =>
1565
+ service.remove("active-removal", undefined, root),
1566
+ ),
1567
+ ),
1568
+ ),
1569
+ ).rejects.toThrow("active 'working' ownership")
1570
+ expect(
1571
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
1572
+ ).toBe(true)
1573
+ })
1574
+
1575
+ test("restores earlier worktrees and preserves the Git failure diagnostic", async () => {
1576
+ await ensureEffectRepository()
1577
+ await runTestEffect(
1578
+ TaskService.pipe(
1579
+ Effect.flatMap((service) =>
1580
+ service.create(
1581
+ {
1582
+ id: "locked-removal",
1583
+ ticketUrl: null,
1584
+ repo: "agency",
1585
+ repos: [{ repo: "effect", ref: "main" }],
1586
+ branch: "task/locked-removal",
1587
+ base: "main",
1588
+ },
1589
+ root,
1590
+ ),
1591
+ ),
1592
+ ),
1593
+ )
1594
+ const workspace = await runTestEffect(
1595
+ WorktreeService.pipe(
1596
+ Effect.flatMap((service) =>
1597
+ service.materialize("locked-removal", undefined, root),
1598
+ ),
1599
+ ),
1600
+ )
1601
+ const referencePath = join(workspace.codePath, "effect")
1602
+ await git([
1603
+ "-C",
1604
+ join(root, "repos/effect"),
1605
+ "worktree",
1606
+ "lock",
1607
+ referencePath,
1608
+ ])
1609
+
1610
+ const failure = await runTestEffect(
1611
+ WorktreeService.pipe(
1612
+ Effect.flatMap((service) =>
1613
+ service.remove("locked-removal", undefined, root),
1614
+ ),
1615
+ Effect.flip,
1616
+ ),
1617
+ )
1618
+
1619
+ expect(failure.message).toContain("locked working tree")
1620
+ expect(failure).toMatchObject({
1621
+ completed: [workspace.writablePath!],
1622
+ rolledBack: [workspace.writablePath!],
1623
+ manualRecovery: [],
1624
+ })
1625
+ for (const checkout of [workspace.writablePath!, referencePath]) {
1626
+ expect(await Bun.file(join(checkout, "README.md")).exists()).toBe(true)
1627
+ }
1628
+ })
1629
+
1529
1630
  test("handles a missing checkout without deleting its branch", async () => {
1530
1631
  await runTestEffect(
1531
1632
  TaskService.pipe(
@@ -1583,6 +1684,52 @@ pr: null
1583
1684
  ).toBe(0)
1584
1685
  })
1585
1686
 
1687
+ test("does not prune unrelated stale worktree registrations", async () => {
1688
+ for (const id of ["stale-target", "stale-unrelated"]) {
1689
+ await runTestEffect(
1690
+ TaskService.pipe(
1691
+ Effect.flatMap((service) =>
1692
+ service.create(
1693
+ {
1694
+ id,
1695
+ ticketUrl: null,
1696
+ repo: "agency",
1697
+ branch: `task/${id}`,
1698
+ base: "main",
1699
+ },
1700
+ root,
1701
+ ),
1702
+ ),
1703
+ ),
1704
+ )
1705
+ const workspace = await runTestEffect(
1706
+ WorktreeService.pipe(
1707
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
1708
+ ),
1709
+ )
1710
+ await rm(workspace.codePath, { recursive: true })
1711
+ }
1712
+
1713
+ await expect(
1714
+ runTestEffect(
1715
+ WorktreeService.pipe(
1716
+ Effect.flatMap((service) =>
1717
+ service.remove("stale-target", undefined, root),
1718
+ ),
1719
+ ),
1720
+ ),
1721
+ ).rejects.toThrow("would also remove unrelated stale registrations")
1722
+ const registered = await gitOutput([
1723
+ "-C",
1724
+ join(root, "repos/agency"),
1725
+ "worktree",
1726
+ "list",
1727
+ "--porcelain",
1728
+ ])
1729
+ expect(registered).toContain("tasks/stale-target/code/agency")
1730
+ expect(registered).toContain("tasks/stale-unrelated/code/agency")
1731
+ })
1732
+
1586
1733
  test("lists and inspects ownership, registration, commits, and dirtiness", async () => {
1587
1734
  const repository = join(root, "repos/agency")
1588
1735
  await git(["-C", repository, "branch", "task/inspected", "main"])
@@ -1712,6 +1859,50 @@ pr: null
1712
1859
  expect(await Bun.file(unexpected).text()).toBe("keep me\n")
1713
1860
  })
1714
1861
 
1862
+ test("does not follow a symlink named for an expected checkout", async () => {
1863
+ await runTestEffect(
1864
+ TaskService.pipe(
1865
+ Effect.flatMap((service) =>
1866
+ service.create(
1867
+ {
1868
+ id: "symlink-removal",
1869
+ ticketUrl: null,
1870
+ repo: "agency",
1871
+ branch: "task/symlink-removal",
1872
+ base: "main",
1873
+ },
1874
+ root,
1875
+ ),
1876
+ ),
1877
+ ),
1878
+ )
1879
+ const repository = join(root, "repos/agency")
1880
+ await git(["-C", repository, "branch", "task/symlink-removal", "main"])
1881
+ const outside = join(root, "outside-symlink-removal")
1882
+ await git([
1883
+ "-C",
1884
+ repository,
1885
+ "worktree",
1886
+ "add",
1887
+ outside,
1888
+ "task/symlink-removal",
1889
+ ])
1890
+ const codePath = join(root, "tasks/symlink-removal/code")
1891
+ await mkdir(codePath, { recursive: true })
1892
+ await symlink(outside, join(codePath, "agency"), "dir")
1893
+
1894
+ await expect(
1895
+ runTestEffect(
1896
+ WorktreeService.pipe(
1897
+ Effect.flatMap((service) =>
1898
+ service.remove("symlink-removal", undefined, root),
1899
+ ),
1900
+ ),
1901
+ ),
1902
+ ).rejects.toThrow("is a symbolic link")
1903
+ expect(await Bun.file(join(outside, "README.md")).exists()).toBe(true)
1904
+ })
1905
+
1715
1906
  test("refuses removal when a branch has duplicate Agency owners", async () => {
1716
1907
  await runTestEffect(
1717
1908
  TaskService.pipe(