@markjaquith/agency 3.2.2 → 3.2.3

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 (45) hide show
  1. package/README.md +20 -58
  2. package/cli-main.ts +0 -72
  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 +13 -132
  7. package/src/cli-parser.ts +10 -101
  8. package/src/cli.test.ts +12 -111
  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 +4 -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/workbase/AGENTS.md +9 -15
  38. package/src/workbase/agent-command.test.ts +0 -3
  39. package/src/workbase/agent-command.ts +0 -6
  40. package/src/workbase/document-revision.ts +0 -4
  41. package/src/workbase/schemas.test.ts +12 -25
  42. package/src/workbase/schemas.ts +0 -16
  43. package/src/commands/claim.ts +0 -122
  44. package/src/services/ClaimService.test.ts +0 -415
  45. package/src/services/ClaimService.ts +0 -608
@@ -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
 
@@ -1,21 +1,28 @@
1
1
  import { Data, Effect, Either } from "effect"
2
2
  import { dirname, join, resolve } from "node:path"
3
3
  import type {
4
- ClaimRecord,
5
4
  PhaseFrontmatter,
6
5
  RepositoryReference,
7
6
  TaskFrontmatter,
8
7
  WorkStatus,
9
8
  PullRequestRecord,
10
9
  } from "../workbase/schemas"
10
+ import { documentRevision } from "../workbase/document-revision"
11
+ import {
12
+ formatMarkdownDocument,
13
+ parseFrontmatterSync,
14
+ } from "../workbase/frontmatter"
11
15
  import {
12
16
  normalizePullRequestRecord,
13
17
  parseOptionalPullRequestRecord,
14
18
  recordFromGitHubJson,
15
19
  resolveDeliveryCommand,
16
20
  } from "../workbase/delivery-command"
17
- import { ClaimService } from "./ClaimService"
18
21
  import { FileSystemService } from "./FileSystemService"
22
+ import {
23
+ documentWriteStep,
24
+ runLifecycleTransaction,
25
+ } from "./LifecycleTransaction"
19
26
  import { WorkbaseService } from "./WorkbaseService"
20
27
  import { WorktreeService } from "./WorktreeService"
21
28
  import {
@@ -37,6 +44,7 @@ interface ExecutionRecord {
37
44
  readonly taskId: string
38
45
  readonly phaseId?: string
39
46
  readonly path: string
47
+ readonly content: string
40
48
  readonly revision: string
41
49
  readonly data: ExecutionData
42
50
  }
@@ -49,11 +57,7 @@ interface RegisteredWorktree {
49
57
  }
50
58
 
51
59
  interface SyncChange {
52
- readonly kind:
53
- | "materialize-workspace"
54
- | "release-stale-claim"
55
- | "record-pr"
56
- | "mark-done"
60
+ readonly kind: "materialize-workspace" | "record-pr" | "mark-done"
57
61
  readonly target: string
58
62
  readonly message: string
59
63
  readonly status: "planned" | "applied"
@@ -84,7 +88,6 @@ interface ExecutionSyncState {
84
88
  readonly status: WorkStatus
85
89
  readonly branch: string | null
86
90
  readonly base: string | null
87
- readonly claim: ClaimRecord | null
88
91
  readonly checkouts: readonly CheckoutState[]
89
92
  readonly pr: Record<string, unknown>
90
93
  readonly review?: {
@@ -140,11 +143,6 @@ const parseJson = <T>(value: string, fallback: T): T => {
140
143
  }
141
144
  }
142
145
 
143
- const isExpired = (claim: ClaimRecord | undefined, now: Date) =>
144
- claim?.state === "active" &&
145
- claim.expiresAt !== undefined &&
146
- Date.parse(claim.expiresAt) <= now.getTime()
147
-
148
146
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
149
147
 
150
148
  const originRef = (ref: string) =>
@@ -199,13 +197,32 @@ const mergedPullRequestFromGitHub = (
199
197
  return current
200
198
  }
201
199
 
200
+ const mutateExecution = (
201
+ root: string,
202
+ record: ExecutionRecord,
203
+ revision: string,
204
+ data: ExecutionData,
205
+ ) => {
206
+ const parsed = parseFrontmatterSync(record.content, record.path)
207
+ const content = formatMarkdownDocument(data, parsed.body)
208
+ return runLifecycleTransaction({
209
+ root,
210
+ preconditions: [{ path: record.path, revision }],
211
+ steps: [documentWriteStep(root, [{ path: record.path, content }])],
212
+ }).pipe(
213
+ Effect.as({ data, revision: documentRevision(content) }),
214
+ Effect.catchTag("LifecycleTransactionError", (error) =>
215
+ Effect.fail(new SyncError({ message: error.message })),
216
+ ),
217
+ )
218
+ }
219
+
202
220
  export class SyncService extends Effect.Service<SyncService>()("SyncService", {
203
221
  sync: () => ({
204
222
  reconcile: (
205
223
  options: {
206
224
  readonly cwd?: string
207
225
  readonly apply?: boolean
208
- readonly now?: Date
209
226
  readonly onProgress?: (progress: SyncProgress) => void
210
227
  readonly taskId?: string
211
228
  readonly phaseId?: string
@@ -215,7 +232,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
215
232
  const fs = yield* FileSystemService
216
233
  const workbase = yield* WorkbaseService
217
234
  const worktrees = yield* WorktreeService
218
- const claims = yield* ClaimService
219
235
  const repositories = yield* RepositoryService
220
236
  const versionControl = yield* VersionControlService
221
237
  const { root, config } = yield* workbase.loadConfig(options.cwd)
@@ -255,6 +271,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
255
271
  taskId: task.id,
256
272
  phaseId: phase.id,
257
273
  path: phase.path,
274
+ content: phase.content,
258
275
  revision: phase.revision,
259
276
  data: phase.data,
260
277
  })
@@ -264,6 +281,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
264
281
  key: `task:${task.id}`,
265
282
  taskId: task.id,
266
283
  path: task.path,
284
+ content: task.content,
267
285
  revision: task.revision,
268
286
  data: task.data,
269
287
  })
@@ -299,7 +317,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
299
317
  })
300
318
 
301
319
  const apply = options.apply === true
302
- const now = options.now ?? new Date()
303
320
  const changes: SyncChange[] = []
304
321
  const warnings: SyncNotice[] = []
305
322
  const unresolved: SyncNotice[] = []
@@ -500,8 +517,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
500
517
  ? null
501
518
  : mergedPullRequestFromGitHub(data, query)
502
519
  const skipCheckoutReconciliation =
503
- Boolean(data.completion) ||
504
- (remoteMergedPr !== null && data.claim?.state !== "active")
520
+ Boolean(data.completion) || remoteMergedPr !== null
505
521
  let materialize = false
506
522
  let workspaceConflict = false
507
523
  const declared: readonly (
@@ -764,57 +780,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
764
780
  })
765
781
  }
766
782
 
767
- if (
768
- isExpired(data.claim, now) &&
769
- (data.status === "working" || data.status === "delegated")
770
- ) {
771
- const sessionId = data.claim!.sessionId
772
- if (apply) {
773
- const expired = yield* claims.expire(
774
- {
775
- taskId: record.taskId,
776
- phaseId: record.phaseId,
777
- revision,
778
- now,
779
- },
780
- root,
781
- )
782
- data = expired.data as ExecutionData
783
- revision = expired.revision
784
- } else {
785
- const claim: ClaimRecord = {
786
- ...data.claim!,
787
- state: "released",
788
- releasedAt: now.toISOString(),
789
- }
790
- data = { ...data, status: "open", claim }
791
- }
792
- changes.push({
793
- kind: "release-stale-claim",
794
- target: record.key,
795
- message: `Release expired claim '${sessionId}'`,
796
- status: apply ? "applied" : "planned",
797
- })
798
- } else if (
799
- data.claim?.state === "active" &&
800
- data.status !== "working" &&
801
- data.status !== "delegated"
802
- ) {
803
- unresolved.push({
804
- kind: "claim-status-conflict",
805
- target: record.key,
806
- message: `Active claim conflicts with '${data.status}' status`,
807
- action: "Release or finish the claim explicitly",
808
- })
809
- }
810
-
811
783
  if (data.completion) {
812
784
  executions.push({
813
785
  target: record.key,
814
786
  status: data.status,
815
787
  branch: data.branch,
816
788
  base: data.base,
817
- claim: data.claim ?? null,
818
789
  checkouts: checkoutStates,
819
790
  pr: { url: null, state: "none" },
820
791
  })
@@ -976,16 +947,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
976
947
 
977
948
  if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
978
949
  if (apply) {
979
- const recorded = yield* claims.reconcile(
980
- {
981
- taskId: record.taskId,
982
- phaseId: record.phaseId,
983
- revision,
984
- pr: current,
985
- },
986
- root,
987
- )
988
- data = recorded.data as ExecutionData
950
+ const recorded = yield* mutateExecution(root, record, revision, {
951
+ ...data,
952
+ pr: current,
953
+ })
954
+ data = recorded.data
989
955
  revision = recorded.revision
990
956
  }
991
957
  changes.push({
@@ -1002,35 +968,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1002
968
  data.status !== "done" &&
1003
969
  data.status !== "dropped"
1004
970
  ) {
1005
- if (data.claim?.state === "active") {
1006
- unresolved.push({
1007
- kind: "merged-with-active-claim",
1008
- target: record.key,
1009
- message:
1010
- "Pull request is merged while the execution unit remains claimed",
1011
- action: "Finish or release the active claim",
1012
- })
1013
- } else {
1014
- if (apply) {
1015
- const completed = yield* claims.reconcile(
1016
- {
1017
- taskId: record.taskId,
1018
- phaseId: record.phaseId,
1019
- revision,
1020
- status: "done",
1021
- },
1022
- root,
1023
- )
1024
- data = completed.data as ExecutionData
1025
- revision = completed.revision
1026
- }
1027
- changes.push({
1028
- kind: "mark-done",
1029
- target: record.key,
1030
- message: "Mark execution unit done from merged pull request",
1031
- status: apply ? "applied" : "planned",
971
+ if (apply) {
972
+ const completed = yield* mutateExecution(root, record, revision, {
973
+ ...data,
974
+ status: "done",
1032
975
  })
976
+ data = completed.data
977
+ revision = completed.revision
1033
978
  }
979
+ changes.push({
980
+ kind: "mark-done",
981
+ target: record.key,
982
+ message: "Mark execution unit done from merged pull request",
983
+ status: apply ? "applied" : "planned",
984
+ })
1034
985
  }
1035
986
 
1036
987
  executions.push({
@@ -1038,7 +989,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1038
989
  status: data.status,
1039
990
  branch: data.branch,
1040
991
  base: data.base,
1041
- claim: data.claim ?? null,
1042
992
  checkouts: checkoutStates,
1043
993
  pr,
1044
994
  })
@@ -1047,27 +997,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1047
997
 
1048
998
  for (const task of reviewRecords) {
1049
999
  if (!("review" in task.data)) continue
1050
- let data = task.data
1051
- let revision = task.revision
1052
- if (
1053
- isExpired(data.claim, now) &&
1054
- (data.status === "working" || data.status === "delegated")
1055
- ) {
1056
- if (apply) {
1057
- const expired = yield* claims.expire(
1058
- { taskId: task.id, revision, now },
1059
- root,
1060
- )
1061
- if ("review" in expired.data) data = expired.data
1062
- revision = expired.revision
1063
- }
1064
- changes.push({
1065
- kind: "release-stale-claim",
1066
- target: `task:${task.id}`,
1067
- message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
1068
- status: apply ? "applied" : "planned",
1069
- })
1070
- }
1000
+ const data = task.data
1071
1001
  const inspection = yield* worktrees.inspect(task.id, undefined, root)
1072
1002
  for (const conflict of inspection.conflicts) {
1073
1003
  unresolved.push({
@@ -1118,7 +1048,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1118
1048
  status: data.status,
1119
1049
  branch: null,
1120
1050
  base: null,
1121
- claim: data.claim ?? null,
1122
1051
  checkouts: checkout
1123
1052
  ? [
1124
1053
  {
@@ -615,7 +615,19 @@ describe("task and phase services", () => {
615
615
  ),
616
616
  ),
617
617
  ),
618
- ).rejects.toThrow("Delegation requires explicit ownership")
618
+ ).rejects.toThrow("Delegated status cannot be set directly")
619
+ await Bun.write(
620
+ createdTask.path,
621
+ workingTask.content.replace("status: working", "status: delegated"),
622
+ )
623
+ const reopenedDelegatedTask = await runTestEffect(
624
+ TaskService.pipe(
625
+ Effect.flatMap((service) =>
626
+ service.setStatus("single-status", "open", root),
627
+ ),
628
+ ),
629
+ )
630
+ expect(reopenedDelegatedTask.data.status).toBe("open")
619
631
  await expect(
620
632
  runTestEffect(
621
633
  TaskService.pipe(
@@ -610,8 +610,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
610
610
  const validStatus = yield* decodeStatus(status)
611
611
  if (validStatus === "delegated") {
612
612
  return yield* new TaskError({
613
- message:
614
- "Delegation requires explicit ownership; use 'agency claim'",
613
+ message: "Delegated status cannot be set directly",
615
614
  })
616
615
  }
617
616
  const record = yield* service.show(id, startPath)
@@ -620,11 +619,6 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
620
619
  message: `Task '${id}' has multiple phases; set status on a phase instead`,
621
620
  })
622
621
  }
623
- if (record.data.claim?.state === "active") {
624
- return yield* new TaskError({
625
- message: `Task '${id}' has an active claim; use agency release or agency finish`,
626
- })
627
- }
628
622
  if (nonPrCompletion && validStatus !== "done") {
629
623
  return yield* new TaskError({
630
624
  message: "Non-PR completion is valid only with a done status",
@@ -870,9 +870,6 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
870
870
  "Non-PR completion cannot have a recorded pull request",
871
871
  )
872
872
  }
873
- if (record.data.claim?.state === "active") {
874
- issue(record.path, "Completed work cannot have an active claim")
875
- }
876
873
  }
877
874
 
878
875
  for (const epic of epics.values()) {