@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.
- package/README.md +39 -7
- package/cli.ts +66 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +28 -1
- package/skills/agency/SKILL.md +14 -3
- package/src/cli-parser.test.ts +81 -0
- package/src/cli-parser.ts +71 -0
- package/src/cli.test.ts +85 -0
- package/src/commands/claim.ts +97 -0
- package/src/commands/phase.ts +1 -1
- package/src/commands/task-phase.test.ts +4 -4
- package/src/commands/task.ts +1 -1
- package/src/commands/work.test.ts +26 -0
- package/src/commands/work.ts +34 -5
- package/src/protocol.test.ts +25 -0
- package/src/protocol.ts +16 -0
- package/src/services/ClaimService.test.ts +270 -0
- package/src/services/ClaimService.ts +446 -0
- package/src/services/PhaseService.ts +13 -0
- package/src/services/TaskPhaseService.test.ts +10 -9
- package/src/services/TaskService.ts +11 -0
- package/src/test-utils.ts +2 -0
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/document-revision.ts +2 -0
- package/src/workbase/frontmatter.ts +59 -53
- package/src/workbase/schemas.test.ts +27 -0
- package/src/workbase/schemas.ts +21 -0
|
@@ -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]) =>
|
package/src/commands/work.ts
CHANGED
|
@@ -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 (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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 = {}) =>
|
package/src/protocol.test.ts
CHANGED
|
@@ -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
|
+
})
|