@markjaquith/agency 2.57.0 → 2.58.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,485 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { chmod, mkdir, rm } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { ArchiveService } from "./ArchiveService"
7
+ import { EpicService } from "./EpicService"
8
+ import { GraphMutationService } from "./GraphMutationService"
9
+ import { PhaseService } from "./PhaseService"
10
+ import { TaskService } from "./TaskService"
11
+ import { WorktreeService } from "./WorktreeService"
12
+
13
+ const git = (args: readonly string[], cwd?: string) => {
14
+ const result = Bun.spawnSync(["git", ...args], { cwd })
15
+ if (result.exitCode !== 0) {
16
+ throw new Error(new TextDecoder().decode(result.stderr))
17
+ }
18
+ }
19
+
20
+ describe("ArchiveService bulk task archive", () => {
21
+ let root: string
22
+ let source: string
23
+
24
+ beforeEach(async () => {
25
+ root = await createTempDir()
26
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
27
+ source = join(root, "source")
28
+ await mkdir(source, { recursive: true })
29
+ git(["init", "--initial-branch=main"], source)
30
+ git(["config", "user.email", "test@example.com"], source)
31
+ git(["config", "user.name", "Test"], source)
32
+ await Bun.write(join(source, "README.md"), "example\n")
33
+ git(["add", "README.md"], source)
34
+ git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
35
+ await mkdir(join(root, "repos"), { recursive: true })
36
+ git(["clone", "--bare", source, join(root, "repos/agency")])
37
+ })
38
+
39
+ afterEach(async () => cleanupTempDir(root))
40
+
41
+ const createTask = (id: string, epic?: string) =>
42
+ runTestEffect(
43
+ TaskService.pipe(
44
+ Effect.flatMap((service) =>
45
+ service.create(
46
+ {
47
+ id,
48
+ ticketUrl: null,
49
+ ...(epic ? { epic } : {}),
50
+ repo: "agency",
51
+ branch: `task/${id}`,
52
+ base: "main",
53
+ },
54
+ root,
55
+ ),
56
+ ),
57
+ ),
58
+ )
59
+
60
+ const dropTask = (id: string) =>
61
+ runTestEffect(
62
+ TaskService.pipe(
63
+ Effect.flatMap((service) => service.setStatus(id, "dropped", root)),
64
+ ),
65
+ )
66
+
67
+ const archiveTasks = (dryRun = false) =>
68
+ runTestEffect(
69
+ ArchiveService.pipe(
70
+ Effect.flatMap((service) => service.archiveTasks(root, { dryRun })),
71
+ ),
72
+ )
73
+
74
+ test("returns sorted dispositions and treats an empty multi-phase task as non-terminal", async () => {
75
+ await createTask("z-dropped")
76
+ await dropTask("z-dropped")
77
+ await createTask("a-open")
78
+ await runTestEffect(
79
+ TaskService.pipe(
80
+ Effect.flatMap((service) =>
81
+ service.create(
82
+ { id: "m-empty", ticketUrl: null, multiPhase: true },
83
+ root,
84
+ ),
85
+ ),
86
+ ),
87
+ )
88
+
89
+ const result = await archiveTasks(true)
90
+
91
+ expect(result.tasks.map((task) => task.id)).toEqual([
92
+ "a-open",
93
+ "m-empty",
94
+ "z-dropped",
95
+ ])
96
+ expect(result.tasks).toMatchObject([
97
+ {
98
+ id: "a-open",
99
+ disposition: "skipped",
100
+ reason: { code: "non-terminal", details: ["status=open"] },
101
+ },
102
+ {
103
+ id: "m-empty",
104
+ disposition: "skipped",
105
+ reason: {
106
+ code: "non-terminal",
107
+ details: ["multi-phase task has no phases"],
108
+ },
109
+ },
110
+ { id: "z-dropped", disposition: "planned" },
111
+ ])
112
+ expect(await Bun.file(join(root, "tasks/z-dropped/TASK.md")).exists()).toBe(
113
+ true,
114
+ )
115
+ })
116
+
117
+ test("uses aggregate phase status and archives only when every phase is terminal", async () => {
118
+ await runTestEffect(
119
+ TaskService.pipe(
120
+ Effect.flatMap((service) =>
121
+ service.create(
122
+ { id: "multi", ticketUrl: null, multiPhase: true },
123
+ root,
124
+ ),
125
+ ),
126
+ ),
127
+ )
128
+ for (const id of ["done", "open"]) {
129
+ await runTestEffect(
130
+ PhaseService.pipe(
131
+ Effect.flatMap((service) =>
132
+ service.create(
133
+ {
134
+ taskId: "multi",
135
+ id,
136
+ repo: "agency",
137
+ branch: `task/multi-${id}`,
138
+ base: "main",
139
+ },
140
+ root,
141
+ ),
142
+ ),
143
+ ),
144
+ )
145
+ }
146
+ await runTestEffect(
147
+ PhaseService.pipe(
148
+ Effect.flatMap((service) =>
149
+ service.setStatus("multi", "done", "dropped", root),
150
+ ),
151
+ ),
152
+ )
153
+
154
+ const mixed = await archiveTasks(true)
155
+ expect(mixed.tasks[0]).toMatchObject({
156
+ disposition: "skipped",
157
+ reason: { code: "non-terminal" },
158
+ })
159
+
160
+ await runTestEffect(
161
+ PhaseService.pipe(
162
+ Effect.flatMap((service) =>
163
+ service.setStatus("multi", "open", "dropped", root),
164
+ ),
165
+ ),
166
+ )
167
+ const terminal = await archiveTasks()
168
+ expect(terminal.tasks[0]).toMatchObject({
169
+ id: "multi",
170
+ disposition: "archived",
171
+ })
172
+ expect(
173
+ await Bun.file(join(root, "archive/tasks/multi/TASK.md")).exists(),
174
+ ).toBe(true)
175
+ })
176
+
177
+ test("propagates retained-dependent skips to a fixed point", async () => {
178
+ await runTestEffect(
179
+ EpicService.pipe(
180
+ Effect.flatMap((service) =>
181
+ service.create(
182
+ "parent",
183
+ "https://example.com/epic",
184
+ [{ repo: "agency", ref: "main" }],
185
+ root,
186
+ ),
187
+ ),
188
+ ),
189
+ )
190
+ for (const id of ["base", "middle", "retained"])
191
+ await createTask(id, "parent")
192
+ await runTestEffect(
193
+ GraphMutationService.pipe(
194
+ Effect.flatMap((service) =>
195
+ service.mutateTaskDependency("add", "middle", "base", root),
196
+ ),
197
+ ),
198
+ )
199
+ await runTestEffect(
200
+ GraphMutationService.pipe(
201
+ Effect.flatMap((service) =>
202
+ service.mutateTaskDependency("add", "retained", "middle", root),
203
+ ),
204
+ ),
205
+ )
206
+ await dropTask("base")
207
+ await dropTask("middle")
208
+
209
+ const result = await archiveTasks()
210
+
211
+ expect(result.tasks).toMatchObject([
212
+ {
213
+ id: "base",
214
+ disposition: "skipped",
215
+ reason: { code: "retained-dependent", details: ["middle"] },
216
+ },
217
+ {
218
+ id: "middle",
219
+ disposition: "skipped",
220
+ reason: { code: "retained-dependent", details: ["retained"] },
221
+ },
222
+ {
223
+ id: "retained",
224
+ disposition: "skipped",
225
+ reason: { code: "non-terminal" },
226
+ },
227
+ ])
228
+ })
229
+
230
+ test("archives an internal dependency cohort and updates its parent once without archiving it", async () => {
231
+ await runTestEffect(
232
+ EpicService.pipe(
233
+ Effect.flatMap((service) =>
234
+ service.create(
235
+ "parent",
236
+ "https://example.com/epic",
237
+ [{ repo: "agency", ref: "main" }],
238
+ root,
239
+ ),
240
+ ),
241
+ ),
242
+ )
243
+ await createTask("base", "parent")
244
+ await createTask("dependent", "parent")
245
+ await runTestEffect(
246
+ GraphMutationService.pipe(
247
+ Effect.flatMap((service) =>
248
+ service.mutateTaskDependency("add", "dependent", "base", root),
249
+ ),
250
+ ),
251
+ )
252
+ await dropTask("base")
253
+ await dropTask("dependent")
254
+
255
+ const result = await archiveTasks()
256
+
257
+ expect(result.tasks.map((task) => task.disposition)).toEqual([
258
+ "archived",
259
+ "archived",
260
+ ])
261
+ const parent = await runTestEffect(
262
+ EpicService.pipe(
263
+ Effect.flatMap((service) => service.show("parent", root)),
264
+ ),
265
+ )
266
+ expect(parent.data.tasks).toEqual([])
267
+ expect(await Bun.file(join(root, "epics/parent/EPIC.md")).exists()).toBe(
268
+ true,
269
+ )
270
+ expect(await Bun.file(join(root, "archive/epics/parent")).exists()).toBe(
271
+ false,
272
+ )
273
+ })
274
+
275
+ test("skips destination collisions and dirty managed worktrees", async () => {
276
+ await createTask("collision")
277
+ await dropTask("collision")
278
+ await mkdir(join(root, "archive/tasks/collision"), { recursive: true })
279
+ await createTask("dirty")
280
+ await dropTask("dirty")
281
+ const workspace = await runTestEffect(
282
+ WorktreeService.pipe(
283
+ Effect.flatMap((service) =>
284
+ service.materialize("dirty", undefined, root),
285
+ ),
286
+ ),
287
+ )
288
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep\n")
289
+
290
+ const result = await archiveTasks()
291
+
292
+ expect(result.tasks).toMatchObject([
293
+ {
294
+ id: "collision",
295
+ disposition: "skipped",
296
+ reason: { code: "destination-exists" },
297
+ },
298
+ {
299
+ id: "dirty",
300
+ disposition: "skipped",
301
+ reason: { code: "dirty-worktree" },
302
+ },
303
+ ])
304
+ expect(
305
+ await Bun.file(join(workspace.writablePath!, "dirty.txt")).exists(),
306
+ ).toBe(true)
307
+ })
308
+
309
+ test("skips task- and phase-level active claims", async () => {
310
+ await createTask("claimed")
311
+ await dropTask("claimed")
312
+ const taskPath = join(root, "tasks/claimed/TASK.md")
313
+ await Bun.write(
314
+ taskPath,
315
+ (await Bun.file(taskPath).text()).replace(
316
+ "status: dropped\n",
317
+ `status: dropped
318
+ claim:
319
+ claimant: orchestrator
320
+ runner: opencode
321
+ sessionId: task-session
322
+ startedAt: 2026-08-07T00:00:00.000Z
323
+ targetRevision: ${"a".repeat(64)}
324
+ state: active
325
+ `,
326
+ ),
327
+ )
328
+ await runTestEffect(
329
+ TaskService.pipe(
330
+ Effect.flatMap((service) =>
331
+ service.create(
332
+ { id: "multi-claimed", ticketUrl: null, multiPhase: true },
333
+ root,
334
+ ),
335
+ ),
336
+ ),
337
+ )
338
+ await runTestEffect(
339
+ PhaseService.pipe(
340
+ Effect.flatMap((service) =>
341
+ service.create(
342
+ {
343
+ taskId: "multi-claimed",
344
+ id: "phase",
345
+ repo: "agency",
346
+ branch: "task/multi-claimed",
347
+ base: "main",
348
+ },
349
+ root,
350
+ ),
351
+ ),
352
+ ),
353
+ )
354
+ await runTestEffect(
355
+ PhaseService.pipe(
356
+ Effect.flatMap((service) =>
357
+ service.setStatus("multi-claimed", "phase", "dropped", root),
358
+ ),
359
+ ),
360
+ )
361
+ const phasePath = join(root, "tasks/multi-claimed/phases/phase/PHASE.md")
362
+ await Bun.write(
363
+ phasePath,
364
+ (await Bun.file(phasePath).text()).replace(
365
+ "status: dropped\n",
366
+ `status: dropped
367
+ claim:
368
+ claimant: orchestrator
369
+ runner: opencode
370
+ sessionId: phase-session
371
+ startedAt: 2026-08-07T00:00:00.000Z
372
+ targetRevision: ${"b".repeat(64)}
373
+ state: active
374
+ `,
375
+ ),
376
+ )
377
+
378
+ const result = await archiveTasks(true)
379
+
380
+ expect(result.tasks).toMatchObject([
381
+ {
382
+ id: "claimed",
383
+ disposition: "skipped",
384
+ reason: { code: "active-claim", details: ["task:claimed"] },
385
+ },
386
+ {
387
+ id: "multi-claimed",
388
+ disposition: "skipped",
389
+ reason: {
390
+ code: "active-claim",
391
+ details: ["phase:multi-claimed/phase"],
392
+ },
393
+ },
394
+ ])
395
+ })
396
+
397
+ test("skips a dirty jj workspace", async () => {
398
+ if (!Bun.which("jj")) return
399
+ const repository = join(root, "repos/agency")
400
+ await rm(repository, { recursive: true, force: true })
401
+ git(["clone", source, repository])
402
+ const initialized = Bun.spawnSync([
403
+ "jj",
404
+ "git",
405
+ "init",
406
+ "--colocate",
407
+ repository,
408
+ ])
409
+ if (initialized.exitCode !== 0) {
410
+ throw new Error(new TextDecoder().decode(initialized.stderr))
411
+ }
412
+ await Bun.write(
413
+ join(root, "agency.json"),
414
+ JSON.stringify({ version: 2, vcs: "jj" }),
415
+ )
416
+ await createTask("jj-dirty")
417
+ await dropTask("jj-dirty")
418
+ const workspace = await runTestEffect(
419
+ WorktreeService.pipe(
420
+ Effect.flatMap((service) =>
421
+ service.materialize("jj-dirty", undefined, root),
422
+ ),
423
+ ),
424
+ )
425
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep\n")
426
+
427
+ const result = await archiveTasks()
428
+
429
+ expect(result.tasks[0]).toMatchObject({
430
+ id: "jj-dirty",
431
+ disposition: "skipped",
432
+ reason: { code: "dirty-worktree" },
433
+ })
434
+ expect(
435
+ await Bun.file(join(workspace.writablePath!, "dirty.txt")).exists(),
436
+ ).toBe(true)
437
+ })
438
+
439
+ test("rolls back the entire cohort when application fails", async () => {
440
+ await runTestEffect(
441
+ EpicService.pipe(
442
+ Effect.flatMap((service) =>
443
+ service.create(
444
+ "rollback-parent",
445
+ "https://example.com/epic",
446
+ [{ repo: "agency", ref: "main" }],
447
+ root,
448
+ ),
449
+ ),
450
+ ),
451
+ )
452
+ for (const id of ["first", "second"]) {
453
+ await createTask(id, "rollback-parent")
454
+ await dropTask(id)
455
+ }
456
+ const archiveDirectory = join(root, "archive")
457
+ await mkdir(archiveDirectory, { mode: 0o500 })
458
+
459
+ try {
460
+ await expect(archiveTasks()).rejects.toThrow("rolled back")
461
+ } finally {
462
+ await chmod(archiveDirectory, 0o700)
463
+ }
464
+
465
+ for (const id of ["first", "second"]) {
466
+ expect(await Bun.file(join(root, `tasks/${id}/TASK.md`)).exists()).toBe(
467
+ true,
468
+ )
469
+ expect(
470
+ await Bun.file(
471
+ join(root, `tasks/${id}/.agency-lifecycle.json`),
472
+ ).exists(),
473
+ ).toBe(false)
474
+ }
475
+ const parent = await runTestEffect(
476
+ EpicService.pipe(
477
+ Effect.flatMap((service) => service.show("rollback-parent", root)),
478
+ ),
479
+ )
480
+ expect(parent.data.tasks.map((task) => task.id)).toEqual([
481
+ "first",
482
+ "second",
483
+ ])
484
+ })
485
+ })
@@ -24,6 +24,20 @@ const git = async (args: string[], cwd?: string) => {
24
24
 
25
25
  describe("ArchiveService", () => {
26
26
  let root: string
27
+ const dropTask = (id: string) =>
28
+ runTestEffect(
29
+ TaskService.pipe(
30
+ Effect.flatMap((service) => service.setStatus(id, "dropped", root)),
31
+ ),
32
+ )
33
+ const dropPhase = (taskId: string, id: string) =>
34
+ runTestEffect(
35
+ PhaseService.pipe(
36
+ Effect.flatMap((service) =>
37
+ service.setStatus(taskId, id, "dropped", root),
38
+ ),
39
+ ),
40
+ )
27
41
 
28
42
  beforeEach(async () => {
29
43
  root = await createTempDir()
@@ -42,6 +56,142 @@ describe("ArchiveService", () => {
42
56
 
43
57
  afterEach(async () => cleanupTempDir(root))
44
58
 
59
+ test("requires terminal effective status for singular task archival", async () => {
60
+ await runTestEffect(
61
+ TaskService.pipe(
62
+ Effect.flatMap((service) =>
63
+ service.create(
64
+ {
65
+ id: "status-check",
66
+ ticketUrl: null,
67
+ repo: "agency",
68
+ branch: "task/status-check",
69
+ base: "main",
70
+ },
71
+ root,
72
+ ),
73
+ ),
74
+ ),
75
+ )
76
+ await expect(
77
+ runTestEffect(
78
+ ArchiveService.pipe(
79
+ Effect.flatMap((service) =>
80
+ service.archiveTask("status-check", root),
81
+ ),
82
+ ),
83
+ ),
84
+ ).rejects.toThrow("status=open")
85
+ await runTestEffect(
86
+ TaskService.pipe(
87
+ Effect.flatMap((service) =>
88
+ service.setStatus("status-check", "working", root),
89
+ ),
90
+ ),
91
+ )
92
+ await expect(
93
+ runTestEffect(
94
+ ArchiveService.pipe(
95
+ Effect.flatMap((service) =>
96
+ service.archiveTask("status-check", root),
97
+ ),
98
+ ),
99
+ ),
100
+ ).rejects.toThrow("status=working")
101
+ await runTestEffect(
102
+ TaskService.pipe(
103
+ Effect.flatMap((service) =>
104
+ service.setStatus("status-check", "dropped", root),
105
+ ),
106
+ ),
107
+ )
108
+ const result = await runTestEffect(
109
+ ArchiveService.pipe(
110
+ Effect.flatMap((service) =>
111
+ service.archiveTask("status-check", root, { dryRun: true }),
112
+ ),
113
+ ),
114
+ )
115
+ expect(result.dryRun).toBe(true)
116
+
117
+ await runTestEffect(
118
+ TaskService.pipe(
119
+ Effect.flatMap((service) =>
120
+ service.create(
121
+ {
122
+ id: "done-check",
123
+ ticketUrl: null,
124
+ repo: "agency",
125
+ branch: "task/done-check",
126
+ base: "main",
127
+ },
128
+ root,
129
+ ),
130
+ ),
131
+ ),
132
+ )
133
+ const donePath = join(root, "tasks/done-check/TASK.md")
134
+ await Bun.write(
135
+ donePath,
136
+ (await Bun.file(donePath).text()).replace("status: open", "status: done"),
137
+ )
138
+ const doneResult = await runTestEffect(
139
+ ArchiveService.pipe(
140
+ Effect.flatMap((service) =>
141
+ service.archiveTask("done-check", root, { dryRun: true }),
142
+ ),
143
+ ),
144
+ )
145
+ expect(doneResult.dryRun).toBe(true)
146
+ })
147
+
148
+ test("rejects empty and partially terminal multi-phase tasks in singular archival", async () => {
149
+ await runTestEffect(
150
+ TaskService.pipe(
151
+ Effect.flatMap((service) =>
152
+ service.create(
153
+ { id: "empty", ticketUrl: null, multiPhase: true },
154
+ root,
155
+ ),
156
+ ),
157
+ ),
158
+ )
159
+ await expect(
160
+ runTestEffect(
161
+ ArchiveService.pipe(
162
+ Effect.flatMap((service) => service.archiveTask("empty", root)),
163
+ ),
164
+ ),
165
+ ).rejects.toThrow("has no phases")
166
+
167
+ for (const id of ["terminal", "open"]) {
168
+ await runTestEffect(
169
+ PhaseService.pipe(
170
+ Effect.flatMap((service) =>
171
+ service.create(
172
+ {
173
+ taskId: "empty",
174
+ id,
175
+ repo: "agency",
176
+ branch: `task/empty-${id}`,
177
+ base: "main",
178
+ },
179
+ root,
180
+ ),
181
+ ),
182
+ ),
183
+ )
184
+ }
185
+ await dropPhase("empty", "terminal")
186
+ await expect(
187
+ runTestEffect(
188
+ ArchiveService.pipe(
189
+ Effect.flatMap((service) => service.archiveTask("empty", root)),
190
+ ),
191
+ ),
192
+ ).rejects.toThrow("phase:open:status=open")
193
+ })
194
+
45
195
  test("archives a task after removing its worktree and preserves its branch", async () => {
46
196
  await runTestEffect(
47
197
  EpicService.pipe(
@@ -79,6 +229,7 @@ describe("ArchiveService", () => {
79
229
  ),
80
230
  ),
81
231
  )
232
+ await dropTask("child")
82
233
 
83
234
  const result = await runTestEffect(
84
235
  ArchiveService.pipe(
@@ -316,6 +467,7 @@ describe("ArchiveService", () => {
316
467
  ),
317
468
  )
318
469
  await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
470
+ await dropTask("dirty")
319
471
 
320
472
  await expect(
321
473
  runTestEffect(
@@ -349,6 +501,7 @@ describe("ArchiveService", () => {
349
501
  ),
350
502
  ),
351
503
  )
504
+ await dropTask("preview")
352
505
 
353
506
  const archivePreview = await runTestEffect(
354
507
  ArchiveService.pipe(
@@ -416,6 +569,7 @@ describe("ArchiveService", () => {
416
569
  ),
417
570
  ),
418
571
  )
572
+ await dropTask("child")
419
573
  await runTestEffect(
420
574
  ArchiveService.pipe(
421
575
  Effect.flatMap((service) => service.archiveTask("child", root)),
@@ -426,7 +580,11 @@ describe("ArchiveService", () => {
426
580
  ArchiveService.pipe(
427
581
  Effect.flatMap((service) =>
428
582
  service.list(
429
- { kinds: ["task"], repositories: ["agency"], statuses: ["open"] },
583
+ {
584
+ kinds: ["task"],
585
+ repositories: ["agency"],
586
+ statuses: ["dropped"],
587
+ },
430
588
  root,
431
589
  ),
432
590
  ),
@@ -688,6 +846,7 @@ describe("ArchiveService", () => {
688
846
  ),
689
847
  ),
690
848
  )
849
+ await dropTask("reserved")
691
850
  await runTestEffect(
692
851
  ArchiveService.pipe(
693
852
  Effect.flatMap((service) => service.archiveTask("reserved", root)),
@@ -737,6 +896,7 @@ describe("ArchiveService", () => {
737
896
  ),
738
897
  ),
739
898
  )
899
+ await dropTask("locked")
740
900
  await Bun.write(join(root, ".agency-archive.lock"), "held\n")
741
901
 
742
902
  await expect(
@@ -784,6 +944,7 @@ describe("ArchiveService", () => {
784
944
  ),
785
945
  ),
786
946
  )
947
+ await dropTask("child")
787
948
  await runTestEffect(
788
949
  ArchiveService.pipe(
789
950
  Effect.flatMap((service) => service.archiveTask("child", root)),
@@ -895,6 +1056,8 @@ describe("ArchiveService", () => {
895
1056
  join(root, "tasks/multi-dirty/phases/dirty/code/agency/dirty.txt"),
896
1057
  "keep me\n",
897
1058
  )
1059
+ await dropPhase("multi-dirty", "clean")
1060
+ await dropPhase("multi-dirty", "dirty")
898
1061
 
899
1062
  await expect(
900
1063
  runTestEffect(