@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
@@ -224,40 +224,6 @@ describe("ReadinessService", () => {
224
224
  readiness.guardWorkTarget("execution-unit:phase/ship/implement", root),
225
225
  )
226
226
 
227
- await write(
228
- root,
229
- "tasks/ship/phases/implement/PHASE.md",
230
- `---
231
- repo: agency
232
- branch: feat/implement
233
- base: main
234
- pr: null
235
- status: working
236
- claim:
237
- claimant: orchestrator
238
- agent: opencode
239
- sessionId: session-1
240
- startedAt: 2026-07-20T00:00:00.000Z
241
- targetRevision: ${"a".repeat(64)}
242
- state: active
243
- ---
244
-
245
- # Execution
246
- `,
247
- )
248
- expect(
249
- await service((readiness) => readiness.getWorkTargetIds(root)),
250
- ).not.toContain("execution-unit:phase/ship/implement")
251
- await expect(
252
- service((readiness) =>
253
- readiness.guardWorkTarget(
254
- "execution-unit:phase/ship/implement",
255
- root,
256
- true,
257
- ),
258
- ),
259
- ).rejects.toThrow("active claim")
260
-
261
227
  const blocked = await service((readiness) =>
262
228
  Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
263
229
  )
@@ -46,22 +46,13 @@ const executionNodeId = (taskId: string, phaseId?: string) =>
46
46
  ? `execution-unit:phase/${taskId}/${phaseId}`
47
47
  : `execution-unit:task/${taskId}`
48
48
 
49
- const hasActiveClaim = (node: GraphNode) =>
50
- node.kind !== "epic" &&
51
- node.kind !== "repository" &&
52
- "claim" in node.data &&
53
- node.data.claim?.state === "active"
54
-
55
49
  const isResumableWork = (node: GraphNode) =>
56
50
  node.kind !== "repository" &&
57
51
  node.status === "working" &&
58
- !hasActiveClaim(node) &&
59
52
  node.readiness.blockers.every((blocker) => blocker.kind !== "validation")
60
53
 
61
54
  const isWorkTarget = (node: GraphNode) =>
62
- node.kind !== "repository" &&
63
- !hasActiveClaim(node) &&
64
- (node.readiness.ready || isResumableWork(node))
55
+ node.kind !== "repository" && (node.readiness.ready || isResumableWork(node))
65
56
 
66
57
  const itemFor = (
67
58
  node: ExecutionNode,
@@ -200,16 +191,6 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
200
191
  blockers: [],
201
192
  })
202
193
  }
203
- if (hasActiveClaim(node)) {
204
- return yield* new ExecutionGuardError({
205
- message: `Cannot work on '${node.key}': it has an active claim. Use agency release or agency finish first.`,
206
- action: "work",
207
- target,
208
- status: node.status!,
209
- blockedBy: node.readiness!.blockedBy,
210
- blockers: node.readiness!.blockers,
211
- })
212
- }
213
194
  if (override) return
214
195
  if (!node.readiness.ready && !isResumableWork(node)) {
215
196
  const item = {
@@ -11,7 +11,6 @@ import { GraphService } from "./GraphService"
11
11
  import { PullRequestService } from "./PullRequestService"
12
12
  import { PhaseService } from "./PhaseService"
13
13
  import { ArchiveService } from "./ArchiveService"
14
- import { ClaimService } from "./ClaimService"
15
14
  import { SyncService } from "./SyncService"
16
15
  import { task as taskCommand } from "../commands/task"
17
16
 
@@ -183,69 +182,6 @@ describe("ReviewService", () => {
183
182
  ).rejects.toThrow("cannot be converted to phases")
184
183
  })
185
184
 
186
- test("participates in claim lifecycle", async () => {
187
- const task = await createReview()
188
- const claimed = await runTestEffect(
189
- ClaimService.pipe(
190
- Effect.flatMap((service) =>
191
- service.claim(
192
- {
193
- taskId: "review",
194
- claimant: "reviewer",
195
- agent: "opencode",
196
- sessionId: "session",
197
- revision: task.revision,
198
- },
199
- root,
200
- ),
201
- ),
202
- ),
203
- )
204
- expect(claimed.data.status).toBe("working")
205
- const released = await runTestEffect(
206
- ClaimService.pipe(
207
- Effect.flatMap((service) =>
208
- service.release(
209
- {
210
- taskId: "review",
211
- sessionId: "session",
212
- revision: claimed.revision,
213
- },
214
- root,
215
- ),
216
- ),
217
- ),
218
- )
219
- expect(released.data.status).toBe("open")
220
- })
221
-
222
- test("rejects refresh while actively claimed", async () => {
223
- const task = await createReview()
224
- await runTestEffect(
225
- ClaimService.pipe(
226
- Effect.flatMap((service) =>
227
- service.claim(
228
- {
229
- taskId: "review",
230
- claimant: "reviewer",
231
- agent: "opencode",
232
- sessionId: "active",
233
- revision: task.revision,
234
- },
235
- root,
236
- ),
237
- ),
238
- ),
239
- )
240
- await expect(
241
- runTestEffect(
242
- ReviewService.pipe(
243
- Effect.flatMap((service) => service.refresh("review", root)),
244
- ),
245
- ),
246
- ).rejects.toThrow("active claim")
247
- })
248
-
249
185
  test("rolls back document and checkout when pin advancement fails", async () => {
250
186
  const created = await createReview()
251
187
  const workspace = await runTestEffect(
@@ -302,11 +302,6 @@ export class ReviewService extends Effect.Service<ReviewService>()(
302
302
  })
303
303
  }
304
304
  const previousReview = task.data.review
305
- if (task.data.claim?.state === "active") {
306
- return yield* new ReviewError({
307
- message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
308
- })
309
- }
310
305
  if (ifRevision && task.revision !== ifRevision) {
311
306
  return yield* new RevisionConflictError({
312
307
  path: task.path,
@@ -3,7 +3,6 @@ import { Effect } from "effect"
3
3
  import { chmod, mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
- import { ClaimService } from "./ClaimService"
7
6
  import { PullRequestService } from "./PullRequestService"
8
7
  import { SyncService } from "./SyncService"
9
8
  import { TaskService } from "./TaskService"
@@ -143,30 +142,6 @@ pr: null
143
142
  ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
144
143
  join(root, "repos/agency"),
145
144
  )
146
- const inspected = await runTestEffect(
147
- ClaimService.pipe(
148
- Effect.flatMap((service) =>
149
- service.inspect("example", undefined, root),
150
- ),
151
- ),
152
- )
153
- await runTestEffect(
154
- ClaimService.pipe(
155
- Effect.flatMap((service) =>
156
- service.claim(
157
- {
158
- taskId: "example",
159
- claimant: "orchestrator",
160
- agent: "agent",
161
- sessionId: "session-1",
162
- revision: inspected.revision,
163
- expiresAt: "2099-01-01T00:00:00.000Z",
164
- },
165
- root,
166
- ),
167
- ),
168
- ),
169
- )
170
145
  await Bun.write(
171
146
  join(workspace.codePath, "reference", "LOCAL.md"),
172
147
  "dirty\n",
@@ -176,21 +151,13 @@ pr: null
176
151
  const before = await Bun.file(taskPath).text()
177
152
  const observed = await runTestEffect(
178
153
  SyncService.pipe(
179
- Effect.flatMap((service) =>
180
- service.reconcile({ cwd: root, now: new Date("2100-01-02") }),
181
- ),
154
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
182
155
  ),
183
156
  )
184
157
 
185
158
  expect(observed.mode).toBe("dry-run")
186
- expect(observed.warnings).toContainEqual(
187
- expect.objectContaining({
188
- kind: "dirty-reference",
189
- target: "task:example",
190
- }),
191
- )
159
+ expect(observed.warnings).toEqual([])
192
160
  expect(observed.changes.map((change) => change.kind)).toEqual([
193
- "release-stale-claim",
194
161
  "record-pr",
195
162
  "mark-done",
196
163
  ])
@@ -199,16 +166,11 @@ pr: null
199
166
  const applied = await runTestEffect(
200
167
  SyncService.pipe(
201
168
  Effect.flatMap((service) =>
202
- service.reconcile({
203
- cwd: root,
204
- apply: true,
205
- now: new Date("2100-01-02"),
206
- }),
169
+ service.reconcile({ cwd: root, apply: true }),
207
170
  ),
208
171
  ),
209
172
  )
210
173
  expect(applied.changes.map((change) => change.kind)).toEqual([
211
- "release-stale-claim",
212
174
  "record-pr",
213
175
  "mark-done",
214
176
  ])
@@ -236,7 +198,6 @@ pr: null
236
198
  baseBranch: "main",
237
199
  mergeable: true,
238
200
  },
239
- claim: { state: "released", sessionId: "session-1" },
240
201
  })
241
202
  })
242
203
 
@@ -314,6 +275,70 @@ JSON
314
275
  })
315
276
  })
316
277
 
278
+ test("rejects a concurrent target change before recording a pull request", async () => {
279
+ await runTestEffect(
280
+ TaskService.pipe(
281
+ Effect.flatMap((service) =>
282
+ service.create(
283
+ {
284
+ id: "concurrent",
285
+ ticketUrl: null,
286
+ repo: "agency",
287
+ branch: "feat/example",
288
+ base: "main",
289
+ },
290
+ root,
291
+ ),
292
+ ),
293
+ ),
294
+ )
295
+ await git(
296
+ ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
297
+ join(root, "repos/agency"),
298
+ )
299
+ const taskPath = join(root, "tasks/concurrent/TASK.md")
300
+ await Bun.write(
301
+ join(root, "bin", "gh"),
302
+ `#!/bin/sh
303
+ printf '\nConcurrent edit.\n' >> ${JSON.stringify(taskPath)}
304
+ cat <<'JSON'
305
+ [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
306
+ JSON
307
+ `,
308
+ )
309
+ await chmod(join(root, "bin", "gh"), 0o755)
310
+
311
+ const failure = await runTestEffect(
312
+ SyncService.pipe(
313
+ Effect.flatMap((service) =>
314
+ service.reconcile({ cwd: root, apply: true }),
315
+ ),
316
+ Effect.flip,
317
+ ),
318
+ )
319
+ expect(failure).toMatchObject({
320
+ _tag: "RevisionConflictError",
321
+ message: expect.stringContaining("Revision conflict"),
322
+ })
323
+ const content = await Bun.file(taskPath).text()
324
+ expect(content).toContain("Concurrent edit.")
325
+ expect(content).not.toContain("provider: github")
326
+
327
+ await Bun.write(join(root, ".agency-graph-mutation.lock"), "busy")
328
+ const transactionFailure = await runTestEffect(
329
+ SyncService.pipe(
330
+ Effect.flatMap((service) =>
331
+ service.reconcile({ cwd: root, apply: true }),
332
+ ),
333
+ Effect.flip,
334
+ ),
335
+ )
336
+ expect(transactionFailure).toMatchObject({
337
+ _tag: "SyncError",
338
+ message: expect.stringContaining("graph mutation is in progress"),
339
+ })
340
+ })
341
+
317
342
  test("queries and records a configured delivery provider", async () => {
318
343
  await runTestEffect(
319
344
  TaskService.pipe(
@@ -400,13 +425,13 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
400
425
  expect("pr" in task.data && task.data.pr).toEqual(record)
401
426
  })
402
427
 
403
- test("marks a successfully finished claim done only after merge", async () => {
428
+ test("marks working execution done after merge", async () => {
404
429
  await runTestEffect(
405
430
  TaskService.pipe(
406
431
  Effect.flatMap((service) =>
407
432
  service.create(
408
433
  {
409
- id: "finished-claim",
434
+ id: "working",
410
435
  ticketUrl: null,
411
436
  repo: "agency",
412
437
  branch: "feat/example",
@@ -420,7 +445,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
420
445
  await runTestEffect(
421
446
  WorktreeService.pipe(
422
447
  Effect.flatMap((service) =>
423
- service.materialize("finished-claim", undefined, root),
448
+ service.materialize("working", undefined, root),
424
449
  ),
425
450
  ),
426
451
  )
@@ -428,48 +453,13 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
428
453
  ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
429
454
  join(root, "repos/agency"),
430
455
  )
431
- const initial = await runTestEffect(
432
- ClaimService.pipe(
433
- Effect.flatMap((service) =>
434
- service.inspect("finished-claim", undefined, root),
435
- ),
436
- ),
437
- )
438
- const claimed = await runTestEffect(
439
- ClaimService.pipe(
440
- Effect.flatMap((service) =>
441
- service.claim(
442
- {
443
- taskId: "finished-claim",
444
- claimant: "orchestrator",
445
- agent: "agent",
446
- sessionId: "session-1",
447
- revision: initial.revision,
448
- },
449
- root,
450
- ),
451
- ),
452
- ),
453
- )
454
- const finished = await runTestEffect(
455
- ClaimService.pipe(
456
+ await runTestEffect(
457
+ TaskService.pipe(
456
458
  Effect.flatMap((service) =>
457
- service.finish(
458
- {
459
- taskId: "finished-claim",
460
- sessionId: "session-1",
461
- revision: claimed.revision,
462
- outcome: "done",
463
- },
464
- root,
465
- ),
459
+ service.setStatus("working", "working", root),
466
460
  ),
467
461
  ),
468
462
  )
469
- expect(finished.data).toMatchObject({
470
- status: "working",
471
- claim: { state: "finished", outcome: "done" },
472
- })
473
463
 
474
464
  const synced = await runTestEffect(
475
465
  SyncService.pipe(
@@ -482,14 +472,11 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
482
472
  expect(
483
473
  await runTestEffect(
484
474
  TaskService.pipe(
485
- Effect.flatMap((service) => service.show("finished-claim", root)),
475
+ Effect.flatMap((service) => service.show("working", root)),
486
476
  ),
487
477
  ),
488
478
  ).toMatchObject({
489
- data: {
490
- status: "done",
491
- claim: { state: "finished", outcome: "done" },
492
- },
479
+ data: { status: "done" },
493
480
  })
494
481
  })
495
482