@markjaquith/agency 3.2.9 → 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.9",
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),
@@ -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
  })