@markjaquith/agency 2.24.0 → 2.25.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.
@@ -0,0 +1,302 @@
1
+ import { Data, Effect } from "effect"
2
+ import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
3
+ import { dirname, join, relative } from "node:path"
4
+ import {
5
+ documentRevision,
6
+ RevisionConflictError,
7
+ } from "../workbase/document-revision"
8
+
9
+ class LifecycleTransactionError extends Data.TaggedError(
10
+ "LifecycleTransactionError",
11
+ )<{
12
+ readonly message: string
13
+ readonly completed: readonly string[]
14
+ readonly rolledBack: readonly string[]
15
+ readonly manualRecovery: readonly string[]
16
+ readonly cause?: unknown
17
+ }> {}
18
+
19
+ export interface TransactionStep {
20
+ readonly label: string
21
+ readonly preflight?: () => Promise<void>
22
+ readonly apply: () => Promise<void>
23
+ readonly rollback?: () => Promise<void>
24
+ readonly finalize?: () => Promise<void>
25
+ readonly manualRecovery?: string
26
+ }
27
+
28
+ interface DocumentWrite {
29
+ readonly path: string
30
+ readonly content: string
31
+ readonly create?: boolean
32
+ }
33
+
34
+ interface TransactionPlan {
35
+ readonly root: string
36
+ readonly preconditions?: readonly {
37
+ readonly path: string
38
+ readonly revision: string
39
+ }[]
40
+ readonly steps: readonly TransactionStep[]
41
+ }
42
+
43
+ const exists = async (path: string) => {
44
+ try {
45
+ await lstat(path)
46
+ return true
47
+ } catch (error) {
48
+ if (
49
+ typeof error === "object" &&
50
+ error !== null &&
51
+ "code" in error &&
52
+ error.code === "ENOENT"
53
+ )
54
+ return false
55
+ throw error
56
+ }
57
+ }
58
+
59
+ export const documentWriteStep = (
60
+ root: string,
61
+ writes: readonly DocumentWrite[],
62
+ ): TransactionStep => {
63
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
64
+ const stagingDirectory = join(root, `.agency-transaction-${token}`)
65
+ const staged = writes.map((write, index) => ({
66
+ ...write,
67
+ stage: join(stagingDirectory, `${index}.stage`),
68
+ backup: join(stagingDirectory, `${index}.backup`),
69
+ }))
70
+ const installed: typeof staged = []
71
+ const backedUp: typeof staged = []
72
+ const createdDirectories: string[] = []
73
+ const label = `install documents: ${writes
74
+ .map((write) => relative(root, write.path))
75
+ .join(", ")}`
76
+
77
+ const rollback = async () => {
78
+ for (const write of [...installed].reverse()) {
79
+ await rm(write.path, { force: true })
80
+ }
81
+ for (const write of [...backedUp].reverse()) {
82
+ if (await exists(write.backup)) await rename(write.backup, write.path)
83
+ }
84
+ for (const directory of [...createdDirectories].reverse()) {
85
+ await rm(directory, { recursive: true, force: true })
86
+ }
87
+ installed.length = 0
88
+ backedUp.length = 0
89
+ createdDirectories.length = 0
90
+ }
91
+
92
+ return {
93
+ label,
94
+ preflight: async () => {
95
+ for (const write of staged) {
96
+ const targetExists = await exists(write.path)
97
+ if (write.create === true && targetExists)
98
+ throw new Error(
99
+ `Document already exists: ${relative(root, write.path)}`,
100
+ )
101
+ if (write.create !== true && !targetExists)
102
+ throw new Error(
103
+ `Document does not exist: ${relative(root, write.path)}`,
104
+ )
105
+ }
106
+ },
107
+ apply: async () => {
108
+ await mkdir(stagingDirectory)
109
+ for (const write of staged) await Bun.write(write.stage, write.content)
110
+ try {
111
+ for (const write of staged) {
112
+ const parent = dirname(write.path)
113
+ if (!(await exists(parent))) {
114
+ await mkdir(parent, { recursive: true })
115
+ createdDirectories.push(parent)
116
+ }
117
+ if (!write.create) {
118
+ await rename(write.path, write.backup)
119
+ backedUp.push(write)
120
+ }
121
+ try {
122
+ await rename(write.stage, write.path)
123
+ } catch (cause) {
124
+ if (!write.create && (await exists(write.backup)))
125
+ await rename(write.backup, write.path)
126
+ throw cause
127
+ }
128
+ installed.push(write)
129
+ }
130
+ } catch (cause) {
131
+ try {
132
+ await rollback()
133
+ await rm(stagingDirectory, { recursive: true, force: true })
134
+ } catch (rollbackCause) {
135
+ throw new LifecycleTransactionError({
136
+ message: `Document installation failed and requires manual recovery: ${cause instanceof Error ? cause.message : String(cause)}`,
137
+ completed: [label],
138
+ rolledBack: [],
139
+ manualRecovery: [
140
+ `Inspect ${relative(root, stagingDirectory)} for staged documents and backups`,
141
+ ],
142
+ cause: new AggregateError([cause, rollbackCause]),
143
+ })
144
+ }
145
+ throw cause
146
+ }
147
+ },
148
+ rollback,
149
+ finalize: async () => {
150
+ await rm(stagingDirectory, { recursive: true, force: true })
151
+ },
152
+ manualRecovery: `Inspect ${relative(root, stagingDirectory)} for staged documents and backups`,
153
+ }
154
+ }
155
+
156
+ export const directoryMoveStep = (
157
+ root: string,
158
+ from: string,
159
+ to: string,
160
+ ): TransactionStep => {
161
+ let createdParent = false
162
+ return {
163
+ label: `move ${relative(root, from)} to ${relative(root, to)}`,
164
+ preflight: async () => {
165
+ if (!(await exists(from)))
166
+ throw new Error(`Move source does not exist: ${relative(root, from)}`)
167
+ if (await exists(to))
168
+ throw new Error(
169
+ `Move destination already exists: ${relative(root, to)}`,
170
+ )
171
+ },
172
+ apply: async () => {
173
+ const parent = dirname(to)
174
+ if (!(await exists(parent))) {
175
+ await mkdir(parent, { recursive: true })
176
+ createdParent = true
177
+ }
178
+ await rename(from, to)
179
+ },
180
+ rollback: async () => {
181
+ await rename(to, from)
182
+ if (createdParent) await rm(dirname(to), { recursive: true, force: true })
183
+ },
184
+ manualRecovery: `Move ${relative(root, to)} back to ${relative(root, from)}`,
185
+ }
186
+ }
187
+
188
+ export const runLifecycleTransaction = ({
189
+ root,
190
+ preconditions = [],
191
+ steps,
192
+ }: TransactionPlan) =>
193
+ Effect.tryPromise({
194
+ try: async () => {
195
+ const lockPath = join(root, ".agency-graph-mutation.lock")
196
+ let lock: Awaited<ReturnType<typeof open>>
197
+ try {
198
+ lock = await open(lockPath, "wx")
199
+ } catch (cause) {
200
+ throw new LifecycleTransactionError({
201
+ message:
202
+ "Another graph mutation is in progress; wait for it to finish and retry",
203
+ completed: [],
204
+ rolledBack: [],
205
+ manualRecovery: [],
206
+ cause,
207
+ })
208
+ }
209
+
210
+ const completed: TransactionStep[] = []
211
+ const rolledBack: string[] = []
212
+ try {
213
+ for (const precondition of preconditions) {
214
+ const content = await Bun.file(precondition.path).text()
215
+ const currentRevision = documentRevision(content)
216
+ if (currentRevision !== precondition.revision) {
217
+ throw new RevisionConflictError({
218
+ path: relative(root, precondition.path),
219
+ expectedRevision: precondition.revision,
220
+ currentRevision,
221
+ message: `Revision conflict for ${relative(root, precondition.path)}`,
222
+ })
223
+ }
224
+ }
225
+ for (const step of steps) await step.preflight?.()
226
+ for (const step of steps) {
227
+ await step.apply()
228
+ completed.push(step)
229
+ }
230
+ const cleanup = await Promise.allSettled(
231
+ completed.map((step) => step.finalize?.() ?? Promise.resolve()),
232
+ )
233
+ const cleanupFailures = cleanup.filter(
234
+ (result) => result.status === "rejected",
235
+ )
236
+ if (cleanupFailures.length > 0) {
237
+ throw new LifecycleTransactionError({
238
+ message:
239
+ "Lifecycle mutation completed, but transaction artifacts require manual cleanup",
240
+ completed: completed.map((step) => step.label),
241
+ rolledBack: [],
242
+ manualRecovery: completed.flatMap((step) =>
243
+ step.finalize && step.manualRecovery ? [step.manualRecovery] : [],
244
+ ),
245
+ cause: new AggregateError(
246
+ cleanupFailures.map((result) =>
247
+ result.status === "rejected" ? result.reason : undefined,
248
+ ),
249
+ ),
250
+ })
251
+ }
252
+ } catch (cause) {
253
+ if (cause instanceof LifecycleTransactionError) throw cause
254
+ const rollbackErrors: unknown[] = []
255
+ for (const step of [...completed].reverse()) {
256
+ if (!step.rollback) continue
257
+ try {
258
+ await step.rollback()
259
+ rolledBack.push(step.label)
260
+ await step.finalize?.()
261
+ } catch (error) {
262
+ rollbackErrors.push(error)
263
+ }
264
+ }
265
+ const manualRecovery = completed
266
+ .filter(
267
+ (step) =>
268
+ !rolledBack.includes(step.label) &&
269
+ step.manualRecovery !== undefined,
270
+ )
271
+ .map((step) => step.manualRecovery!)
272
+ throw new LifecycleTransactionError({
273
+ message:
274
+ completed.length === 0
275
+ ? `Lifecycle mutation failed before changes were applied: ${cause instanceof Error ? cause.message : String(cause)}`
276
+ : rollbackErrors.length
277
+ ? `Lifecycle mutation failed and rollback requires manual recovery: ${cause instanceof Error ? cause.message : String(cause)}`
278
+ : `Lifecycle mutation failed; completed changes were rolled back: ${cause instanceof Error ? cause.message : String(cause)}`,
279
+ completed: completed.map((step) => step.label),
280
+ rolledBack,
281
+ manualRecovery,
282
+ cause: rollbackErrors.length
283
+ ? new AggregateError([cause, ...rollbackErrors])
284
+ : cause,
285
+ })
286
+ } finally {
287
+ await lock.close().catch(() => undefined)
288
+ await rm(lockPath, { force: true }).catch(() => undefined)
289
+ }
290
+ },
291
+ catch: (cause) =>
292
+ cause instanceof LifecycleTransactionError ||
293
+ cause instanceof RevisionConflictError
294
+ ? cause
295
+ : new LifecycleTransactionError({
296
+ message: "Lifecycle mutation failed before changes were applied",
297
+ completed: [],
298
+ rolledBack: [],
299
+ manualRecovery: [],
300
+ cause,
301
+ }),
302
+ })
@@ -1,5 +1,6 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
+ import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises"
3
4
  import { join } from "node:path"
4
5
  import { FileSystemService } from "./FileSystemService"
5
6
  import { WorkbaseService } from "./WorkbaseService"
@@ -18,6 +19,12 @@ import {
18
19
  import { canTransitionStatus } from "../readiness"
19
20
  import { documentRevision } from "../workbase/document-revision"
20
21
  import { archivedPhaseDirectory } from "../workbase/archive"
22
+ import {
23
+ documentWriteStep,
24
+ runLifecycleTransaction,
25
+ type TransactionStep,
26
+ } from "./LifecycleTransaction"
27
+ import { withWorktreeLocks } from "./WorktreeLock"
21
28
 
22
29
  class PhaseError extends Data.TaggedError("PhaseError")<{
23
30
  readonly message: string
@@ -156,6 +163,16 @@ export class PhaseService extends Effect.Service<PhaseService>()(
156
163
  ]
157
164
  : []),
158
165
  ])
166
+ const newAliases = [
167
+ data.repo,
168
+ ...(data.repos ?? []).map((reference) => reference.repo),
169
+ ]
170
+ if (new Set(newAliases).size !== newAliases.length) {
171
+ return yield* new PhaseError({
172
+ message:
173
+ "Repository references must be unique and cannot include the writable repository",
174
+ })
175
+ }
159
176
  for (const alias of aliases) {
160
177
  if (!(yield* fs.exists(join(root, "repos", alias)))) {
161
178
  return yield* new PhaseError({
@@ -209,46 +226,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
209
226
  .map((part) => part[0]?.toUpperCase() + part.slice(1))
210
227
  .join(" ")
211
228
 
212
- yield* fs.createDirectory(firstDirectory)
213
- yield* fs.createDirectory(directory)
214
- yield* fs.writeFile(
215
- join(firstDirectory, "PHASE.md"),
216
- formatMarkdownDocument(
217
- firstData,
218
- `# ${firstTitle}\n\nDescribe the phase outcome.`,
219
- ),
220
- )
221
- yield* fs.writeFile(path, content)
222
-
223
229
  const oldCodePath = join(root, "tasks", taskId, "code")
224
- if (yield* fs.isDirectory(oldCodePath)) {
225
- const firstCodePath = join(firstDirectory, "code")
226
- yield* fs.moveDirectory(oldCodePath, firstCodePath)
227
- for (const alias of [
228
- firstData.repo,
229
- ...(firstData.repos ?? []).map((reference) => reference.repo),
230
- ]) {
231
- const checkoutPath = join(firstCodePath, alias)
232
- if (!(yield* fs.isDirectory(checkoutPath))) continue
233
- const repair = yield* fs.runCommand(
234
- [
235
- "git",
236
- "-C",
237
- join(root, "repos", alias),
238
- "worktree",
239
- "repair",
240
- checkoutPath,
241
- ],
242
- { captureOutput: true },
243
- )
244
- if (repair.exitCode !== 0) {
245
- return yield* new PhaseError({
246
- message: `Failed to repair moved worktree for '${alias}': ${repair.stderr}`,
247
- })
248
- }
249
- }
250
- }
251
-
252
230
  const convertedTaskData = {
253
231
  ticketUrl: task.data.ticketUrl,
254
232
  ...(task.data.description
@@ -265,9 +243,129 @@ export class PhaseService extends Effect.Service<PhaseService>()(
265
243
  },
266
244
  ],
267
245
  }
268
- yield* fs.writeFile(
269
- task.path,
270
- formatMarkdownDocument(convertedTaskData, parsedTask.body),
246
+ const firstPhasePath = join(firstDirectory, "PHASE.md")
247
+ const firstContent = formatMarkdownDocument(
248
+ firstData,
249
+ `# ${firstTitle}\n\nDescribe the phase outcome.`,
250
+ )
251
+ const steps: TransactionStep[] = []
252
+ if (yield* fs.isDirectory(oldCodePath)) {
253
+ const firstCodePath = join(firstDirectory, "code")
254
+ const checkoutAliases = [
255
+ firstData.repo,
256
+ ...(firstData.repos ?? []).map((reference) => reference.repo),
257
+ ]
258
+ const repair = async (basePath: string) => {
259
+ for (const alias of checkoutAliases) {
260
+ const checkoutPath = join(basePath, alias)
261
+ try {
262
+ await lstat(checkoutPath)
263
+ } catch {
264
+ continue
265
+ }
266
+ const result = Bun.spawnSync([
267
+ "git",
268
+ "-C",
269
+ join(root, "repos", alias),
270
+ "worktree",
271
+ "repair",
272
+ checkoutPath,
273
+ ])
274
+ if (result.exitCode !== 0) {
275
+ throw new Error(
276
+ `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
277
+ )
278
+ }
279
+ }
280
+ }
281
+ steps.push({
282
+ label: `move and repair code for ${taskId}/${firstPhaseId}`,
283
+ preflight: async () => {
284
+ for (const entry of await readdir(oldCodePath)) {
285
+ if (!checkoutAliases.includes(entry))
286
+ throw new Error(
287
+ `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
288
+ )
289
+ }
290
+ for (const alias of checkoutAliases) {
291
+ const checkoutPath = join(oldCodePath, alias)
292
+ try {
293
+ await lstat(checkoutPath)
294
+ } catch {
295
+ continue
296
+ }
297
+ const listed = Bun.spawnSync([
298
+ "git",
299
+ "-C",
300
+ join(root, "repos", alias),
301
+ "worktree",
302
+ "list",
303
+ "--porcelain",
304
+ ])
305
+ if (listed.exitCode !== 0)
306
+ throw new Error(
307
+ `Failed to inspect worktrees for '${alias}'`,
308
+ )
309
+ const expected = await realpath(checkoutPath)
310
+ let registered = false
311
+ for (const line of new TextDecoder()
312
+ .decode(listed.stdout)
313
+ .split("\n")) {
314
+ if (!line.startsWith("worktree ")) continue
315
+ try {
316
+ if ((await realpath(line.slice(9))) === expected) {
317
+ registered = true
318
+ break
319
+ }
320
+ } catch {}
321
+ }
322
+ if (!registered)
323
+ throw new Error(
324
+ `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
325
+ )
326
+ }
327
+ },
328
+ apply: async () => {
329
+ await mkdir(firstDirectory, { recursive: true })
330
+ await rename(oldCodePath, firstCodePath)
331
+ try {
332
+ await repair(firstCodePath)
333
+ } catch (cause) {
334
+ await rename(firstCodePath, oldCodePath)
335
+ await repair(oldCodePath)
336
+ await rm(firstDirectory, { recursive: true, force: true })
337
+ throw cause
338
+ }
339
+ },
340
+ rollback: async () => {
341
+ await rename(firstCodePath, oldCodePath)
342
+ await repair(oldCodePath)
343
+ await rm(firstDirectory, { recursive: true, force: true })
344
+ },
345
+ manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
346
+ })
347
+ }
348
+ steps.push(
349
+ documentWriteStep(root, [
350
+ { path: firstPhasePath, content: firstContent, create: true },
351
+ { path, content, create: true },
352
+ {
353
+ path: task.path,
354
+ content: formatMarkdownDocument(
355
+ convertedTaskData,
356
+ parsedTask.body,
357
+ ),
358
+ },
359
+ ]),
360
+ )
361
+ yield* withWorktreeLocks(
362
+ root,
363
+ [{ taskId }],
364
+ runLifecycleTransaction({
365
+ root,
366
+ preconditions: [{ path: task.path, revision: task.revision }],
367
+ steps,
368
+ }),
271
369
  )
272
370
  return {
273
371
  taskId,
@@ -279,8 +377,6 @@ export class PhaseService extends Effect.Service<PhaseService>()(
279
377
  } satisfies PhaseRecord
280
378
  }
281
379
 
282
- yield* fs.createDirectory(directory)
283
- yield* fs.writeFile(path, content)
284
380
  const updatedTaskData = {
285
381
  ...task.data,
286
382
  phases: [
@@ -293,10 +389,22 @@ export class PhaseService extends Effect.Service<PhaseService>()(
293
389
  },
294
390
  ],
295
391
  }
296
- yield* fs.writeFile(
297
- task.path,
298
- formatMarkdownDocument(updatedTaskData, parsedTask.body),
299
- )
392
+ yield* runLifecycleTransaction({
393
+ root,
394
+ preconditions: [{ path: task.path, revision: task.revision }],
395
+ steps: [
396
+ documentWriteStep(root, [
397
+ { path, content, create: true },
398
+ {
399
+ path: task.path,
400
+ content: formatMarkdownDocument(
401
+ updatedTaskData,
402
+ parsedTask.body,
403
+ ),
404
+ },
405
+ ]),
406
+ ],
407
+ })
300
408
 
301
409
  return {
302
410
  taskId,
@@ -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 { EpicService } from "./EpicService"
@@ -60,6 +60,52 @@ describe("task and phase services", () => {
60
60
  expect(epic.data.tasks).toEqual([{ id: "task-one" }])
61
61
  })
62
62
 
63
+ test("does not create a task when its parent update cannot start", async () => {
64
+ await runTestEffect(
65
+ EpicService.pipe(
66
+ Effect.flatMap((service) =>
67
+ service.create(
68
+ "locked",
69
+ "https://example.com/epic",
70
+ [{ repo: "agency", ref: "main" }],
71
+ root,
72
+ ),
73
+ ),
74
+ ),
75
+ )
76
+ const lock = join(root, ".agency-graph-mutation.lock")
77
+ await Bun.write(lock, "held")
78
+ await expect(
79
+ runTestEffect(
80
+ TaskService.pipe(
81
+ Effect.flatMap((service) =>
82
+ service.create(
83
+ {
84
+ id: "not-created",
85
+ ticketUrl: null,
86
+ epic: "locked",
87
+ repo: "agency",
88
+ branch: "task/not-created",
89
+ base: "main",
90
+ },
91
+ root,
92
+ ),
93
+ ),
94
+ ),
95
+ ),
96
+ ).rejects.toThrow("Another graph mutation is in progress")
97
+ await rm(lock)
98
+ expect(
99
+ await Bun.file(join(root, "tasks/not-created/TASK.md")).exists(),
100
+ ).toBe(false)
101
+ const epic = await runTestEffect(
102
+ EpicService.pipe(
103
+ Effect.flatMap((service) => service.show("locked", root)),
104
+ ),
105
+ )
106
+ expect(epic.data.tasks).toEqual([])
107
+ })
108
+
63
109
  test("creates and sequences phases on a multi-phase task", async () => {
64
110
  await runTestEffect(
65
111
  TaskService.pipe(
@@ -18,6 +18,10 @@ import {
18
18
  import { canTransitionStatus } from "../readiness"
19
19
  import { documentRevision } from "../workbase/document-revision"
20
20
  import { archivedTaskDirectory } from "../workbase/archive"
21
+ import {
22
+ documentWriteStep,
23
+ runLifecycleTransaction,
24
+ } from "./LifecycleTransaction"
21
25
 
22
26
  class TaskError extends Data.TaggedError("TaskError")<{
23
27
  readonly message: string
@@ -129,6 +133,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
129
133
  ...(data.repos ?? []).map((reference) => reference.repo),
130
134
  ]
131
135
  : []
136
+ if (new Set(referencedRepos).size !== referencedRepos.length) {
137
+ return yield* new TaskError({
138
+ message:
139
+ "Repository references must be unique and cannot include the writable repository",
140
+ })
141
+ }
132
142
  for (const alias of referencedRepos) {
133
143
  if (!(yield* fs.exists(join(root, "repos", alias)))) {
134
144
  return yield* new TaskError({
@@ -140,6 +150,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
140
150
  let parentEpic: EpicRecord | undefined
141
151
  if (input.epic) {
142
152
  parentEpic = yield* epics.show(input.epic, root)
153
+ if (parentEpic.data.tasks.some((task) => task.id === id)) {
154
+ return yield* new TaskError({
155
+ message: `Epic '${input.epic}' already lists task '${id}'`,
156
+ })
157
+ }
143
158
  }
144
159
 
145
160
  const title = id
@@ -150,9 +165,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
150
165
  data,
151
166
  `# ${title}\n\nDescribe the task outcome.`,
152
167
  )
153
- yield* fs.createDirectory(directory)
154
- yield* fs.writeFile(path, content)
155
-
168
+ const writes: {
169
+ path: string
170
+ content: string
171
+ create?: boolean
172
+ }[] = [{ path, content, create: true }]
156
173
  if (input.epic && parentEpic) {
157
174
  const parsed = yield* parseFrontmatter(
158
175
  parentEpic.content,
@@ -163,8 +180,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
163
180
  tasks: [...parentEpic.data.tasks, { id }],
164
181
  }
165
182
  const updated = formatMarkdownDocument(epicData, parsed.body)
166
- yield* fs.writeFile(parentEpic.path, updated)
183
+ writes.push({ path: parentEpic.path, content: updated })
167
184
  }
185
+ yield* runLifecycleTransaction({
186
+ root,
187
+ preconditions: parentEpic
188
+ ? [{ path: parentEpic.path, revision: parentEpic.revision }]
189
+ : [],
190
+ steps: [documentWriteStep(root, writes)],
191
+ })
168
192
 
169
193
  return {
170
194
  id,