@markjaquith/agency 3.2.8 → 3.2.10

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": "3.2.8",
3
+ "version": "3.2.10",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,5 +1,5 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
- import { Data, Effect, Either, Layer } from "effect"
2
+ import { Data, Effect, Either } from "effect"
3
3
  import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
4
4
  import { dirname, join, relative, resolve, sep } from "node:path"
5
5
  import { EpicService, type EpicRecord } from "./EpicService"
@@ -40,6 +40,7 @@ import {
40
40
  directoryMoveStep,
41
41
  documentWriteStep,
42
42
  runLifecycleTransaction,
43
+ transactionEffect,
43
44
  type TransactionStep,
44
45
  } from "./LifecycleTransaction"
45
46
  import { withWorktreeLocks } from "./WorktreeLock"
@@ -358,21 +359,6 @@ const applyMutation = (moves: readonly Move[], writes: readonly Write[]) =>
358
359
  }),
359
360
  })
360
361
 
361
- const WorktreeLayer = Layer.mergeAll(
362
- FileSystemService.Default,
363
- WorkbaseService.Default,
364
- GitVersionControlService.Default,
365
- VersionControlService.Default,
366
- TaskService.Default,
367
- PhaseService.Default,
368
- WorktreeService.Default,
369
- )
370
-
371
- const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
372
- Effect.runPromise(
373
- effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
374
- )
375
-
376
362
  const runGit = async (args: readonly string[]) => {
377
363
  const process = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
378
364
  const [exitCode, stdout, stderr] = await Promise.all([
@@ -791,25 +777,26 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
791
777
  })
792
778
  if (!options.dryRun) {
793
779
  const snapshots: WorktreeRemovalSnapshot[] = []
794
- const steps: TransactionStep[] = [
780
+ const steps: TransactionStep<any>[] = [
795
781
  documentWriteStep(root, writes),
796
782
  {
797
783
  label: `remove worktrees for epic ${id}`,
798
- apply: async () => {
799
- try {
800
- for (const unit of executionUnits)
801
- await runWorktreeEffect(
802
- worktrees.remove(unit.taskId, unit.phaseId, root, {
803
- snapshots,
804
- lockHeld: true,
805
- }),
806
- )
807
- } catch (cause) {
808
- await restoreWorktreeSnapshots(snapshots)
809
- throw cause
810
- }
811
- },
812
- rollback: () => restoreWorktreeSnapshots(snapshots),
784
+ apply: Effect.gen(function* () {
785
+ for (const unit of executionUnits)
786
+ yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
787
+ snapshots,
788
+ lockHeld: true,
789
+ })
790
+ }).pipe(
791
+ Effect.catchAllCause((cause) =>
792
+ transactionEffect(() =>
793
+ restoreWorktreeSnapshots(snapshots),
794
+ ).pipe(Effect.zipRight(Effect.failCause(cause))),
795
+ ),
796
+ ),
797
+ rollback: transactionEffect(() =>
798
+ restoreWorktreeSnapshots(snapshots),
799
+ ),
813
800
  manualRecovery: `Run agency work prepare for each execution unit in epic '${id}'`,
814
801
  },
815
802
  ]
@@ -1074,34 +1061,33 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1074
1061
  )
1075
1062
  if (!options.dryRun && selected.length > 0) {
1076
1063
  const snapshots: WorktreeRemovalSnapshot[] = []
1077
- const steps: TransactionStep[] = [
1064
+ const steps: TransactionStep<any>[] = [
1078
1065
  documentWriteStep(root, writes),
1079
1066
  {
1080
1067
  label: "remove worktrees for task archive cohort",
1081
- apply: async () => {
1082
- try {
1083
- for (const unit of executionUnits) {
1084
- await runWorktreeEffect(
1085
- worktrees.remove(unit.taskId, unit.phaseId, root, {
1086
- snapshots,
1087
- lockHeld: true,
1088
- task: contexts.get(unit.taskId)!.task,
1089
- phase: unit.phaseId
1090
- ? contexts
1091
- .get(unit.taskId)!
1092
- .phases.find(
1093
- (phase) => phase.id === unit.phaseId,
1094
- )
1095
- : undefined,
1096
- }),
1097
- )
1098
- }
1099
- } catch (cause) {
1100
- await restoreWorktreeSnapshots(snapshots)
1101
- throw cause
1068
+ apply: Effect.gen(function* () {
1069
+ for (const unit of executionUnits) {
1070
+ yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
1071
+ snapshots,
1072
+ lockHeld: true,
1073
+ task: contexts.get(unit.taskId)!.task,
1074
+ phase: unit.phaseId
1075
+ ? contexts
1076
+ .get(unit.taskId)!
1077
+ .phases.find((phase) => phase.id === unit.phaseId)
1078
+ : undefined,
1079
+ })
1102
1080
  }
1103
- },
1104
- rollback: () => restoreWorktreeSnapshots(snapshots),
1081
+ }).pipe(
1082
+ Effect.catchAllCause((cause) =>
1083
+ transactionEffect(() =>
1084
+ restoreWorktreeSnapshots(snapshots),
1085
+ ).pipe(Effect.zipRight(Effect.failCause(cause))),
1086
+ ),
1087
+ ),
1088
+ rollback: transactionEffect(() =>
1089
+ restoreWorktreeSnapshots(snapshots),
1090
+ ),
1105
1091
  manualRecovery:
1106
1092
  "Run agency work prepare for each archived execution unit",
1107
1093
  },
@@ -1281,30 +1267,31 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1281
1267
  })
1282
1268
  if (!options.dryRun) {
1283
1269
  const snapshots: WorktreeRemovalSnapshot[] = []
1284
- const steps: TransactionStep[] = [
1270
+ const steps: TransactionStep<any>[] = [
1285
1271
  documentWriteStep(root, writes),
1286
1272
  {
1287
1273
  label: `remove worktrees for task ${id}`,
1288
- apply: async () => {
1289
- try {
1290
- for (const unit of executionUnits)
1291
- await runWorktreeEffect(
1292
- worktrees.remove(
1293
- unit.taskId,
1294
- executionPhaseId(unit),
1295
- root,
1296
- {
1297
- snapshots,
1298
- lockHeld: true,
1299
- },
1300
- ),
1301
- )
1302
- } catch (cause) {
1303
- await restoreWorktreeSnapshots(snapshots)
1304
- throw cause
1305
- }
1306
- },
1307
- rollback: () => restoreWorktreeSnapshots(snapshots),
1274
+ apply: Effect.gen(function* () {
1275
+ for (const unit of executionUnits)
1276
+ yield* worktrees.remove(
1277
+ unit.taskId,
1278
+ executionPhaseId(unit),
1279
+ root,
1280
+ {
1281
+ snapshots,
1282
+ lockHeld: true,
1283
+ },
1284
+ )
1285
+ }).pipe(
1286
+ Effect.catchAllCause((cause) =>
1287
+ transactionEffect(() =>
1288
+ restoreWorktreeSnapshots(snapshots),
1289
+ ).pipe(Effect.zipRight(Effect.failCause(cause))),
1290
+ ),
1291
+ ),
1292
+ rollback: transactionEffect(() =>
1293
+ restoreWorktreeSnapshots(snapshots),
1294
+ ),
1308
1295
  manualRecovery: `Run agency work prepare for task '${id}'`,
1309
1296
  },
1310
1297
  directoryMoveStep(root, dirname(task.path), destination),
@@ -1426,15 +1413,22 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1426
1413
  documentWriteStep(root, writes),
1427
1414
  {
1428
1415
  label: `remove worktrees for phase ${taskId}/${id}`,
1429
- apply: async () => {
1430
- await runWorktreeEffect(
1431
- worktrees.remove(taskId, id, root, {
1432
- snapshots,
1433
- lockHeld: true,
1434
- }),
1435
- )
1436
- },
1437
- rollback: () => restoreWorktreeSnapshots(snapshots),
1416
+ apply: worktrees
1417
+ .remove(taskId, id, root, {
1418
+ snapshots,
1419
+ lockHeld: true,
1420
+ })
1421
+ .pipe(
1422
+ Effect.asVoid,
1423
+ Effect.catchAllCause((cause) =>
1424
+ transactionEffect(() =>
1425
+ restoreWorktreeSnapshots(snapshots),
1426
+ ).pipe(Effect.zipRight(Effect.failCause(cause))),
1427
+ ),
1428
+ ),
1429
+ rollback: transactionEffect(() =>
1430
+ restoreWorktreeSnapshots(snapshots),
1431
+ ),
1438
1432
  manualRecovery: `Run agency work prepare for phase '${taskId}/${id}'`,
1439
1433
  },
1440
1434
  directoryMoveStep(root, dirname(phase.path), destination),
@@ -0,0 +1,81 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir } from "../test-utils"
6
+ import { FileSystemService } from "./FileSystemService"
7
+
8
+ const runFileSystem = <A, E>(effect: Effect.Effect<A, E, FileSystemService>) =>
9
+ Effect.runPromise(effect.pipe(Effect.provide(FileSystemService.Default)))
10
+
11
+ describe("FileSystemService", () => {
12
+ const roots: string[] = []
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(roots.splice(0).map(cleanupTempDir))
16
+ })
17
+
18
+ test("distinguishes missing files from other read failures", async () => {
19
+ const root = await createTempDir()
20
+ roots.push(root)
21
+ const directory = join(root, "document")
22
+ await mkdir(directory)
23
+
24
+ const missing = await runFileSystem(
25
+ FileSystemService.pipe(
26
+ Effect.flatMap((service) => service.readFile(join(root, "missing"))),
27
+ Effect.either,
28
+ ),
29
+ )
30
+ expect(missing).toMatchObject({
31
+ _tag: "Left",
32
+ left: { _tag: "FileNotFoundError" },
33
+ })
34
+
35
+ const unreadable = await runFileSystem(
36
+ FileSystemService.pipe(
37
+ Effect.flatMap((service) => service.readFile(directory)),
38
+ Effect.either,
39
+ ),
40
+ )
41
+ expect(unreadable).toMatchObject({
42
+ _tag: "Left",
43
+ left: {
44
+ _tag: "FileSystemError",
45
+ message: `Failed to read file: ${directory}`,
46
+ },
47
+ })
48
+ })
49
+
50
+ test("only treats a missing symlink path as absent", async () => {
51
+ const root = await createTempDir()
52
+ roots.push(root)
53
+ const regularFile = join(root, "regular")
54
+ await Bun.write(regularFile, "content")
55
+
56
+ await expect(
57
+ runFileSystem(
58
+ FileSystemService.pipe(
59
+ Effect.flatMap((service) =>
60
+ service.readSymlinkTarget(join(root, "missing")),
61
+ ),
62
+ ),
63
+ ),
64
+ ).resolves.toBeNull()
65
+ const unreadable = await runFileSystem(
66
+ FileSystemService.pipe(
67
+ Effect.flatMap((service) =>
68
+ service.readSymlinkTarget(join(regularFile, "child")),
69
+ ),
70
+ Effect.either,
71
+ ),
72
+ )
73
+ expect(unreadable).toMatchObject({
74
+ _tag: "Left",
75
+ left: {
76
+ _tag: "FileSystemError",
77
+ message: `Failed to read symlink target: ${join(regularFile, "child")}`,
78
+ },
79
+ })
80
+ })
81
+ })
@@ -23,6 +23,12 @@ class FileNotFoundError extends Data.TaggedError("FileNotFoundError")<{
23
23
  path: string
24
24
  }> {}
25
25
 
26
+ const isFileNotFound = (error: unknown) =>
27
+ typeof error === "object" &&
28
+ error !== null &&
29
+ "code" in error &&
30
+ error.code === "ENOENT"
31
+
26
32
  // FileSystem Service using Effect.Service pattern
27
33
  export class FileSystemService extends Effect.Service<FileSystemService>()(
28
34
  "FileSystemService",
@@ -88,7 +94,13 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
88
94
  readFile: (path: string) =>
89
95
  Effect.tryPromise({
90
96
  try: () => Bun.file(path).text(),
91
- catch: () => new FileNotFoundError({ path }),
97
+ catch: (error) =>
98
+ isFileNotFound(error)
99
+ ? new FileNotFoundError({ path })
100
+ : new FileSystemError({
101
+ message: `Failed to read file: ${path}`,
102
+ cause: error,
103
+ }),
92
104
  }),
93
105
 
94
106
  inspectFile: (path: string) =>
@@ -184,10 +196,12 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
184
196
  }))
185
197
  },
186
198
  catch: (error) =>
187
- new FileSystemError({
188
- message: `Failed to read directory: ${path}`,
189
- cause: error,
190
- }),
199
+ isFileNotFound(error)
200
+ ? new FileNotFoundError({ path })
201
+ : new FileSystemError({
202
+ message: `Failed to read directory: ${path}`,
203
+ cause: error,
204
+ }),
191
205
  }),
192
206
 
193
207
  deleteDirectory: (path: string) =>
@@ -301,8 +315,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
301
315
  }
302
316
  return await readlink(path)
303
317
  },
304
- catch: () => null,
305
- }).pipe(Effect.catchAll(() => Effect.succeed(null))),
318
+ catch: (error) =>
319
+ isFileNotFound(error)
320
+ ? new FileNotFoundError({ path })
321
+ : new FileSystemError({
322
+ message: `Failed to read symlink target: ${path}`,
323
+ cause: error,
324
+ }),
325
+ }).pipe(
326
+ Effect.catchTag("FileNotFoundError", () => Effect.succeed(null)),
327
+ ),
306
328
  }),
307
329
  },
308
330
  ) {}
@@ -230,6 +230,17 @@ describe("GraphService", () => {
230
230
  expect(calls).toBe(0)
231
231
  })
232
232
 
233
+ test("propagates directory discovery failures", async () => {
234
+ const root = await createTempDir()
235
+ roots.push(root)
236
+ await write(root, "agency.json", '{"version":2}\n')
237
+ await write(root, "tasks", "not a directory")
238
+
239
+ await expect(getGraph(root)).rejects.toThrow(
240
+ `Failed to read directory: ${join(root, "tasks")}`,
241
+ )
242
+ })
243
+
233
244
  test("never reports active or terminal execution units as ready", async () => {
234
245
  const root = await createWorkbase()
235
246
  roots.push(root)
@@ -158,7 +158,7 @@ export class GraphService extends Effect.Service<GraphService>()(
158
158
  .map((entry) => entry.name)
159
159
  .sort(),
160
160
  ),
161
- Effect.catchAll(() => Effect.succeed([])),
161
+ Effect.catchTag("FileNotFoundError", () => Effect.succeed([])),
162
162
  )
163
163
 
164
164
  const readDocument = <S extends Schema.Schema.AnyNoContext>(
@@ -1,10 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
- import { Effect } from "effect"
2
+ import { Deferred, Effect, Fiber } from "effect"
3
3
  import { join } from "node:path"
4
4
  import { cleanupTempDir, createTempDir } from "../test-utils"
5
5
  import {
6
6
  documentWriteStep,
7
7
  runLifecycleTransaction,
8
+ transactionEffect,
8
9
  } from "./LifecycleTransaction"
9
10
 
10
11
  describe("lifecycle transactions", () => {
@@ -25,14 +26,16 @@ describe("lifecycle transactions", () => {
25
26
  steps: [
26
27
  {
27
28
  label: "write marker",
28
- apply: () => Bun.write(marker, "applied").then(() => undefined),
29
+ apply: transactionEffect(async () => {
30
+ await Bun.write(marker, "applied")
31
+ }),
29
32
  },
30
33
  {
31
34
  label: "reject plan",
32
- preflight: async () => {
35
+ preflight: Effect.sync(() => {
33
36
  throw new Error("preflight rejected")
34
- },
35
- apply: async () => undefined,
37
+ }),
38
+ apply: Effect.void,
36
39
  },
37
40
  ],
38
41
  }),
@@ -57,9 +60,9 @@ describe("lifecycle transactions", () => {
57
60
  ]),
58
61
  {
59
62
  label: "fail after documents",
60
- apply: async () => {
63
+ apply: Effect.sync(() => {
61
64
  throw new Error("injected failure")
62
- },
65
+ }),
63
66
  },
64
67
  ],
65
68
  }).pipe(Effect.either),
@@ -83,17 +86,17 @@ describe("lifecycle transactions", () => {
83
86
  steps: [
84
87
  {
85
88
  label: "external mutation",
86
- apply: async () => undefined,
87
- rollback: async () => {
89
+ apply: Effect.void,
90
+ rollback: Effect.sync(() => {
88
91
  throw new Error("rollback failed")
89
- },
92
+ }),
90
93
  manualRecovery: "undo external mutation",
91
94
  },
92
95
  {
93
96
  label: "injected failure",
94
- apply: async () => {
97
+ apply: Effect.sync(() => {
95
98
  throw new Error("apply failed")
96
- },
99
+ }),
97
100
  },
98
101
  ],
99
102
  }).pipe(Effect.either),
@@ -104,4 +107,39 @@ describe("lifecycle transactions", () => {
104
107
  expect(failure.rolledBack).toEqual([])
105
108
  expect(failure.manualRecovery).toEqual(["undo external mutation"])
106
109
  })
110
+
111
+ test("waits for rollback and lock release when interrupted", async () => {
112
+ const events: string[] = []
113
+ const blocked = await Effect.runPromise(Deferred.make<void>())
114
+ const fiber = Effect.runFork(
115
+ runLifecycleTransaction({
116
+ root,
117
+ steps: [
118
+ {
119
+ label: "external mutation",
120
+ apply: Effect.sync(() => events.push("applied")),
121
+ rollback: transactionEffect(async () => {
122
+ events.push("rollback started")
123
+ await Bun.sleep(20)
124
+ events.push("rollback finished")
125
+ }),
126
+ },
127
+ {
128
+ label: "block",
129
+ apply: Deferred.succeed(blocked, undefined).pipe(
130
+ Effect.zipRight(Effect.never),
131
+ ),
132
+ },
133
+ ],
134
+ }),
135
+ )
136
+ await Effect.runPromise(Deferred.await(blocked))
137
+
138
+ await Effect.runPromise(Fiber.interrupt(fiber))
139
+
140
+ expect(events).toEqual(["applied", "rollback started", "rollback finished"])
141
+ expect(
142
+ await Bun.file(join(root, ".agency-graph-mutation.lock")).exists(),
143
+ ).toBe(false)
144
+ })
107
145
  })