@markjaquith/agency 2.13.0 → 2.14.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.
@@ -6,6 +6,7 @@ import { EpicService } from "../services/EpicService"
6
6
  import { TaskService } from "../services/TaskService"
7
7
  import { PhaseService } from "../services/PhaseService"
8
8
  import { WorktreeService } from "../services/WorktreeService"
9
+ import { ClaimService } from "../services/ClaimService"
9
10
  import { captureErrors, captureLogs } from "../test-utils"
10
11
  import { work, workPrepare } from "./work"
11
12
  import type { PickWorkTarget } from "../workbase/work-target"
@@ -133,6 +134,30 @@ const createHarness = (options: HarnessOptions = {}) => {
133
134
  return Effect.void
134
135
  },
135
136
  }
137
+ const claims = {
138
+ inspect: (taskId: string, phaseId?: string) =>
139
+ Effect.succeed({
140
+ target: {
141
+ kind: phaseId ? "phase" : "task",
142
+ taskId,
143
+ phaseId,
144
+ path: phaseId
145
+ ? `/workbase/tasks/${taskId}/phases/${phaseId}/PHASE.md`
146
+ : `/workbase/tasks/${taskId}/TASK.md`,
147
+ label: phaseId ? `phase '${taskId}/${phaseId}'` : `task '${taskId}'`,
148
+ },
149
+ revision: "0".repeat(64),
150
+ data: {},
151
+ }),
152
+ claim: (input: { taskId: string; phaseId?: string }) => {
153
+ statusUpdates.push(
154
+ input.phaseId
155
+ ? `phase:${input.taskId}:${input.phaseId}:working`
156
+ : `task:${input.taskId}:working`,
157
+ )
158
+ return Effect.succeed({ revision: "1".repeat(64) })
159
+ },
160
+ }
136
161
  const fs = {
137
162
  isDirectory: (path: string) =>
138
163
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
@@ -171,6 +196,7 @@ const createHarness = (options: HarnessOptions = {}) => {
171
196
  Effect.provideService(EpicService, epics as never),
172
197
  Effect.provideService(TaskService, tasks as never),
173
198
  Effect.provideService(PhaseService, phases as never),
199
+ Effect.provideService(ClaimService, claims as never),
174
200
  ) as Effect.Effect<void, unknown, never>,
175
201
  )
176
202
  const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
@@ -7,6 +7,7 @@ import { WorkbaseService } from "../services/WorkbaseService"
7
7
  import { EpicService } from "../services/EpicService"
8
8
  import { TaskService } from "../services/TaskService"
9
9
  import { PhaseService } from "../services/PhaseService"
10
+ import { ClaimService } from "../services/ClaimService"
10
11
  import { createLoggers } from "../utils/effect"
11
12
  import { execvp } from "../utils/exec"
12
13
  import { createProgress, type Progress } from "../utils/progress"
@@ -60,6 +61,8 @@ export const work = (
60
61
  new Error("Cannot use both --opencode and --claude"),
61
62
  )
62
63
  }
64
+ const previousSessionId = process.env.AGENCY_SESSION_ID
65
+ const previousClaimRevision = process.env.AGENCY_CLAIM_REVISION
63
66
  if (
64
67
  options.epicId &&
65
68
  (options.directory || options.taskId || options.phaseId)
@@ -75,6 +78,7 @@ export const work = (
75
78
  const epics = yield* EpicService
76
79
  const tasks = yield* TaskService
77
80
  const phases = yield* PhaseService
81
+ const claims = yield* ClaimService
78
82
  const { log, verboseLog } = createLoggers(options)
79
83
  const cwd = options.cwd ?? process.cwd()
80
84
  const directoryPath = options.directory
@@ -229,10 +233,27 @@ export const work = (
229
233
  if (available.exitCode !== 0) {
230
234
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
231
235
  }
232
- if (target.kind === "phase") {
233
- yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
234
- } else if (target.kind === "task" && !target.multiPhase) {
235
- yield* tasks.setStatus(target.taskId, "working", root)
236
+ if (
237
+ target.kind === "phase" ||
238
+ (target.kind === "task" && !target.multiPhase)
239
+ ) {
240
+ const phaseId = target.kind === "phase" ? target.phaseId : undefined
241
+ const current = yield* claims.inspect(target.taskId, phaseId, root)
242
+ const sessionId =
243
+ process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
244
+ const acquired = yield* claims.claim(
245
+ {
246
+ taskId: target.taskId,
247
+ ...(phaseId ? { phaseId } : {}),
248
+ claimant: process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency",
249
+ runner: process.env.AGENCY_RUNNER ?? cli,
250
+ sessionId,
251
+ revision: current.revision,
252
+ },
253
+ root,
254
+ )
255
+ process.env.AGENCY_SESSION_ID = sessionId
256
+ process.env.AGENCY_CLAIM_REVISION = acquired.revision
236
257
  }
237
258
 
238
259
  const args =
@@ -240,7 +261,15 @@ export const work = (
240
261
  verboseLog(
241
262
  `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
242
263
  )
243
- launch(cli, [cli, ...args], launchPath)
264
+ try {
265
+ launch(cli, [cli, ...args], launchPath)
266
+ } finally {
267
+ if (previousSessionId === undefined) delete process.env.AGENCY_SESSION_ID
268
+ else process.env.AGENCY_SESSION_ID = previousSessionId
269
+ if (previousClaimRevision === undefined)
270
+ delete process.env.AGENCY_CLAIM_REVISION
271
+ else process.env.AGENCY_CLAIM_REVISION = previousClaimRevision
272
+ }
244
273
  })
245
274
 
246
275
  export const workPrepare = (options: WorkOptions = {}) =>
@@ -83,5 +83,30 @@ describe("machine protocol", () => {
83
83
  },
84
84
  },
85
85
  })
86
+ expect(
87
+ errorEnvelope({
88
+ _tag: "ClaimConflictError",
89
+ message: "already claimed",
90
+ target: "task 'example'",
91
+ currentRevision: "a".repeat(64),
92
+ claim: {
93
+ claimant: "orchestrator",
94
+ runner: "agent",
95
+ sessionId: "job-1",
96
+ startedAt: "2026-07-17T12:00:00.000Z",
97
+ targetRevision: "0".repeat(64),
98
+ state: "active",
99
+ },
100
+ }),
101
+ ).toMatchObject({
102
+ error: {
103
+ code: "CLAIM_CONFLICT",
104
+ retryable: true,
105
+ fields: {
106
+ target: "task 'example'",
107
+ claim: { runner: "agent", sessionId: "job-1" },
108
+ },
109
+ },
110
+ })
86
111
  })
87
112
  })
package/src/protocol.ts CHANGED
@@ -81,6 +81,22 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
81
81
  EpicError: { code: "EPIC_ERROR", retryable: false },
82
82
  TaskError: { code: "TASK_ERROR", retryable: false },
83
83
  PhaseError: { code: "PHASE_ERROR", retryable: false },
84
+ ClaimError: { code: "CLAIM_ERROR", retryable: false },
85
+ ClaimConflictError: {
86
+ code: "CLAIM_CONFLICT",
87
+ retryable: true,
88
+ remediation: "Inspect the current ownership details before retrying.",
89
+ },
90
+ RevisionConflictError: {
91
+ code: "REVISION_CONFLICT",
92
+ retryable: true,
93
+ remediation: "Read the current document revision and retry intentionally.",
94
+ },
95
+ ClaimOwnershipError: {
96
+ code: "CLAIM_OWNERSHIP",
97
+ retryable: false,
98
+ remediation: "Use the session that owns the claim.",
99
+ },
84
100
  ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
85
101
  WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
86
102
  PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },
@@ -0,0 +1,270 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { ClaimService } from "./ClaimService"
7
+ import { PhaseService } from "./PhaseService"
8
+ import { TaskService } from "./TaskService"
9
+
10
+ const at = (value: string) => new Date(value)
11
+
12
+ describe("claim service", () => {
13
+ let root: string
14
+
15
+ beforeEach(async () => {
16
+ root = await createTempDir()
17
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
18
+ await mkdir(join(root, "repos/agency"), { recursive: true })
19
+ await runTestEffect(
20
+ TaskService.pipe(
21
+ Effect.flatMap((service) =>
22
+ service.create(
23
+ {
24
+ id: "single",
25
+ ticketUrl: null,
26
+ repo: "agency",
27
+ branch: "task/single",
28
+ base: "main",
29
+ },
30
+ root,
31
+ ),
32
+ ),
33
+ ),
34
+ )
35
+ })
36
+
37
+ afterEach(async () => cleanupTempDir(root))
38
+
39
+ const inspect = (taskId = "single", phaseId?: string) =>
40
+ runTestEffect(
41
+ ClaimService.pipe(
42
+ Effect.flatMap((service) => service.inspect(taskId, phaseId, root)),
43
+ ),
44
+ )
45
+
46
+ const claim = async (
47
+ revision: string,
48
+ sessionId = "session-1",
49
+ now = at("2026-07-17T12:00:00.000Z"),
50
+ expiresAt?: string,
51
+ ) =>
52
+ runTestEffect(
53
+ ClaimService.pipe(
54
+ Effect.flatMap((service) =>
55
+ service.claim(
56
+ {
57
+ taskId: "single",
58
+ claimant: "orchestrator-1",
59
+ runner: "agent-1",
60
+ sessionId,
61
+ revision,
62
+ now,
63
+ ...(expiresAt ? { expiresAt } : {}),
64
+ },
65
+ root,
66
+ ),
67
+ ),
68
+ ),
69
+ )
70
+
71
+ test("records ownership and guarded release and finish transitions", async () => {
72
+ const initial = await inspect()
73
+ const acquired = await claim(
74
+ initial.revision,
75
+ "session-1",
76
+ at("2026-07-17T12:00:00.000Z"),
77
+ "2026-07-17T13:00:00.000Z",
78
+ )
79
+
80
+ expect(acquired.claim).toEqual({
81
+ claimant: "orchestrator-1",
82
+ runner: "agent-1",
83
+ sessionId: "session-1",
84
+ startedAt: "2026-07-17T12:00:00.000Z",
85
+ targetRevision: initial.revision,
86
+ expiresAt: "2026-07-17T13:00:00.000Z",
87
+ state: "active",
88
+ })
89
+ expect((await inspect()).data.status).toBe("working")
90
+ await expect(
91
+ runTestEffect(
92
+ TaskService.pipe(
93
+ Effect.flatMap((service) =>
94
+ service.setStatus("single", "done", root),
95
+ ),
96
+ ),
97
+ ),
98
+ ).rejects.toThrow("has an active claim")
99
+
100
+ const released = await runTestEffect(
101
+ ClaimService.pipe(
102
+ Effect.flatMap((service) =>
103
+ service.release(
104
+ {
105
+ taskId: "single",
106
+ sessionId: "session-1",
107
+ revision: acquired.revision,
108
+ now: at("2026-07-17T12:15:00.000Z"),
109
+ },
110
+ root,
111
+ ),
112
+ ),
113
+ ),
114
+ )
115
+ expect(released.data.status).toBe("open")
116
+ expect(released.claim).toMatchObject({
117
+ state: "released",
118
+ releasedAt: "2026-07-17T12:15:00.000Z",
119
+ })
120
+
121
+ const reacquired = await claim(
122
+ released.revision,
123
+ "session-2",
124
+ at("2026-07-17T12:20:00.000Z"),
125
+ )
126
+ const finished = await runTestEffect(
127
+ ClaimService.pipe(
128
+ Effect.flatMap((service) =>
129
+ service.finish(
130
+ {
131
+ taskId: "single",
132
+ sessionId: "session-2",
133
+ revision: reacquired.revision,
134
+ outcome: "done",
135
+ now: at("2026-07-17T12:45:00.000Z"),
136
+ },
137
+ root,
138
+ ),
139
+ ),
140
+ ),
141
+ )
142
+ expect(finished.data.status).toBe("done")
143
+ expect(finished.claim).toMatchObject({
144
+ state: "finished",
145
+ finishedAt: "2026-07-17T12:45:00.000Z",
146
+ outcome: "done",
147
+ })
148
+ })
149
+
150
+ test("returns structured ownership and revision conflicts", async () => {
151
+ const initial = await inspect()
152
+ const acquired = await claim(initial.revision)
153
+
154
+ await expect(claim(acquired.revision, "session-2")).rejects.toThrow(
155
+ "is claimed by 'agent-1'",
156
+ )
157
+ await expect(
158
+ runTestEffect(
159
+ ClaimService.pipe(
160
+ Effect.flatMap((service) =>
161
+ service.release(
162
+ {
163
+ taskId: "single",
164
+ sessionId: "session-2",
165
+ revision: acquired.revision,
166
+ },
167
+ root,
168
+ ),
169
+ ),
170
+ ),
171
+ ),
172
+ ).rejects.toThrow("does not own")
173
+ await expect(claim(initial.revision, "session-3")).rejects.toThrow(
174
+ "Revision conflict",
175
+ )
176
+ })
177
+
178
+ test("serializes concurrent claims and allows expired ownership replacement", async () => {
179
+ const initial = await inspect()
180
+ const attempts = await Promise.allSettled([
181
+ claim(initial.revision, "session-a"),
182
+ claim(initial.revision, "session-b"),
183
+ ])
184
+ expect(
185
+ attempts.filter((result) => result.status === "fulfilled"),
186
+ ).toHaveLength(1)
187
+ expect(
188
+ attempts.filter((result) => result.status === "rejected"),
189
+ ).toHaveLength(1)
190
+ const current = await inspect()
191
+ expect(current.data.claim?.state).toBe("active")
192
+
193
+ await runTestEffect(
194
+ ClaimService.pipe(
195
+ Effect.flatMap((service) =>
196
+ service.release(
197
+ {
198
+ taskId: "single",
199
+ sessionId: current.data.claim!.sessionId,
200
+ revision: current.revision,
201
+ },
202
+ root,
203
+ ),
204
+ ),
205
+ ),
206
+ )
207
+ const released = await inspect()
208
+ const expiring = await claim(
209
+ released.revision,
210
+ "expiring",
211
+ at("2026-07-17T12:00:00.000Z"),
212
+ "2026-07-17T12:01:00.000Z",
213
+ )
214
+ const replacement = await claim(
215
+ expiring.revision,
216
+ "replacement",
217
+ at("2026-07-17T12:02:00.000Z"),
218
+ )
219
+ expect(replacement.claim.sessionId).toBe("replacement")
220
+ })
221
+
222
+ test("claims phases and rejects multi-phase task containers", async () => {
223
+ await runTestEffect(
224
+ TaskService.pipe(
225
+ Effect.flatMap((service) =>
226
+ service.create(
227
+ { id: "multi", ticketUrl: null, multiPhase: true },
228
+ root,
229
+ ),
230
+ ),
231
+ ),
232
+ )
233
+ await runTestEffect(
234
+ PhaseService.pipe(
235
+ Effect.flatMap((service) =>
236
+ service.create(
237
+ {
238
+ taskId: "multi",
239
+ id: "implementation",
240
+ repo: "agency",
241
+ branch: "task/multi",
242
+ base: "main",
243
+ },
244
+ root,
245
+ ),
246
+ ),
247
+ ),
248
+ )
249
+ await expect(inspect("multi")).rejects.toThrow("claim a phase instead")
250
+ const phase = await inspect("multi", "implementation")
251
+ const acquired = await runTestEffect(
252
+ ClaimService.pipe(
253
+ Effect.flatMap((service) =>
254
+ service.claim(
255
+ {
256
+ taskId: "multi",
257
+ phaseId: "implementation",
258
+ claimant: "orchestrator",
259
+ runner: "agent",
260
+ sessionId: "phase-session",
261
+ revision: phase.revision,
262
+ },
263
+ root,
264
+ ),
265
+ ),
266
+ ),
267
+ )
268
+ expect(acquired.target).toBe("phase 'multi/implementation'")
269
+ })
270
+ })