@markjaquith/agency 2.12.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 +46 -7
- package/cli.ts +72 -3
- 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 +97 -0
- package/src/cli-parser.ts +100 -4
- package/src/cli.test.ts +155 -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 +65 -1
- package/src/commands/work.ts +94 -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/services/WorktreeService.test.ts +82 -7
- package/src/services/WorktreeService.ts +199 -41
- package/src/test-utils.ts +2 -0
- package/src/utils/command.ts +1 -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
package/src/cli.test.ts
CHANGED
|
@@ -81,6 +81,91 @@ describe("CLI", () => {
|
|
|
81
81
|
expect(taggedError.stderr).not.toContain("An error has occurred")
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
test("coordinates claims through revision-guarded machine commands", async () => {
|
|
85
|
+
const root = await createTempDir()
|
|
86
|
+
tempDirs.push(root)
|
|
87
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
88
|
+
await mkdir(join(root, "repos", "agency"), { recursive: true })
|
|
89
|
+
parseJson(
|
|
90
|
+
await runCli(
|
|
91
|
+
["task", "create", "claimed", "--repo", "agency", "--json"],
|
|
92
|
+
root,
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
const context = parseJson(
|
|
96
|
+
await runCli(["context", "tasks/claimed", "--json"], root),
|
|
97
|
+
)
|
|
98
|
+
const revision = context.documents.task.sha256
|
|
99
|
+
const claimed = parseJson(
|
|
100
|
+
await runCli(
|
|
101
|
+
[
|
|
102
|
+
"claim",
|
|
103
|
+
"claimed",
|
|
104
|
+
"--claimant",
|
|
105
|
+
"orchestrator",
|
|
106
|
+
"--runner",
|
|
107
|
+
"agent",
|
|
108
|
+
"--session-id",
|
|
109
|
+
"job-1",
|
|
110
|
+
"--revision",
|
|
111
|
+
revision,
|
|
112
|
+
"--json",
|
|
113
|
+
],
|
|
114
|
+
root,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
expect(claimed.claim).toMatchObject({
|
|
118
|
+
claimant: "orchestrator",
|
|
119
|
+
runner: "agent",
|
|
120
|
+
sessionId: "job-1",
|
|
121
|
+
state: "active",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const conflict = await runCli(
|
|
125
|
+
[
|
|
126
|
+
"claim",
|
|
127
|
+
"claimed",
|
|
128
|
+
"--claimant",
|
|
129
|
+
"other",
|
|
130
|
+
"--runner",
|
|
131
|
+
"other-agent",
|
|
132
|
+
"--session-id",
|
|
133
|
+
"job-2",
|
|
134
|
+
"--revision",
|
|
135
|
+
claimed.revision,
|
|
136
|
+
"--json",
|
|
137
|
+
],
|
|
138
|
+
root,
|
|
139
|
+
)
|
|
140
|
+
expect(conflict.exitCode).toBe(1)
|
|
141
|
+
expect(JSON.parse(conflict.stdout)).toMatchObject({
|
|
142
|
+
ok: false,
|
|
143
|
+
error: {
|
|
144
|
+
code: "CLAIM_CONFLICT",
|
|
145
|
+
retryable: true,
|
|
146
|
+
fields: { claim: { runner: "agent", sessionId: "job-1" } },
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const finished = parseJson(
|
|
151
|
+
await runCli(
|
|
152
|
+
[
|
|
153
|
+
"finish",
|
|
154
|
+
"claimed",
|
|
155
|
+
"--session-id",
|
|
156
|
+
"job-1",
|
|
157
|
+
"--revision",
|
|
158
|
+
claimed.revision,
|
|
159
|
+
"--outcome",
|
|
160
|
+
"done",
|
|
161
|
+
"--json",
|
|
162
|
+
],
|
|
163
|
+
root,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
expect(finished.claim).toMatchObject({ state: "finished", outcome: "done" })
|
|
167
|
+
})
|
|
168
|
+
|
|
84
169
|
test("emits one versioned error envelope for usage and command failures", async () => {
|
|
85
170
|
const usage = await runCli(["unknown", "--json"])
|
|
86
171
|
expect(usage.exitCode).toBe(1)
|
|
@@ -301,6 +386,76 @@ status: open
|
|
|
301
386
|
})
|
|
302
387
|
})
|
|
303
388
|
|
|
389
|
+
test("prepares a workspace and reports a non-mutating dry-run", async () => {
|
|
390
|
+
const parent = await createTempDir()
|
|
391
|
+
tempDirs.push(parent)
|
|
392
|
+
const root = join(parent, "workbase")
|
|
393
|
+
const source = join(parent, "source")
|
|
394
|
+
expect(
|
|
395
|
+
Bun.spawnSync(["git", "init", "--initial-branch=main", source]).exitCode,
|
|
396
|
+
).toBe(0)
|
|
397
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
398
|
+
for (const args of [
|
|
399
|
+
["config", "user.email", "test@example.com"],
|
|
400
|
+
["config", "user.name", "Test"],
|
|
401
|
+
["add", "README.md"],
|
|
402
|
+
["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
|
|
403
|
+
]) {
|
|
404
|
+
expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
parseJson(await runCli(["init", root, "--json"], parent))
|
|
408
|
+
parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
|
|
409
|
+
parseJson(
|
|
410
|
+
await runCli(
|
|
411
|
+
[
|
|
412
|
+
"task",
|
|
413
|
+
"create",
|
|
414
|
+
"example",
|
|
415
|
+
"--repo",
|
|
416
|
+
"agency",
|
|
417
|
+
"--branch",
|
|
418
|
+
"feat/example",
|
|
419
|
+
"--base",
|
|
420
|
+
"main",
|
|
421
|
+
"--json",
|
|
422
|
+
],
|
|
423
|
+
root,
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const planned = parseJson(
|
|
428
|
+
await runCli(["work", "prepare", "example", "--dry-run", "--json"], root),
|
|
429
|
+
)
|
|
430
|
+
const workbaseRoot = await realpath(root)
|
|
431
|
+
expect(planned).toMatchObject({
|
|
432
|
+
dryRun: true,
|
|
433
|
+
taskPath: join(workbaseRoot, "tasks/example/TASK.md"),
|
|
434
|
+
phasePath: null,
|
|
435
|
+
checkouts: [
|
|
436
|
+
{
|
|
437
|
+
repo: "agency",
|
|
438
|
+
kind: "writable",
|
|
439
|
+
action: "created",
|
|
440
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
})
|
|
444
|
+
await expect(access(join(root, "tasks/example/code"))).rejects.toThrow()
|
|
445
|
+
|
|
446
|
+
const prepared = parseJson(
|
|
447
|
+
await runCli(["work", "prepare", "example", "--json"], root),
|
|
448
|
+
)
|
|
449
|
+
expect(prepared).toMatchObject({
|
|
450
|
+
dryRun: false,
|
|
451
|
+
checkouts: [{ action: "created", kind: "writable" }],
|
|
452
|
+
})
|
|
453
|
+
const task = parseJson(
|
|
454
|
+
await runCli(["task", "show", "example", "--json"], root),
|
|
455
|
+
)
|
|
456
|
+
expect(task.data.status).toBe("open")
|
|
457
|
+
})
|
|
458
|
+
|
|
304
459
|
test("envelopes help and version output in machine mode", async () => {
|
|
305
460
|
const help = await runCli(["status", "--help", "--json"])
|
|
306
461
|
expect(parseJson(help)).toContain("Usage: agency status")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { ClaimService } from "../services/ClaimService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface ClaimCommandOptions extends BaseCommandOptions {
|
|
7
|
+
readonly operation: "claim" | "release" | "finish"
|
|
8
|
+
readonly taskId?: string
|
|
9
|
+
readonly phaseId?: string
|
|
10
|
+
readonly claimant?: string
|
|
11
|
+
readonly runner?: string
|
|
12
|
+
readonly sessionId?: string
|
|
13
|
+
readonly revision?: string
|
|
14
|
+
readonly expiresAt?: string
|
|
15
|
+
readonly outcome?: string
|
|
16
|
+
readonly json?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const claimCommand = (options: ClaimCommandOptions) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const claims = yield* ClaimService
|
|
22
|
+
const { log } = createLoggers(options)
|
|
23
|
+
const cwd = options.cwd ?? process.cwd()
|
|
24
|
+
if (!options.taskId || !options.sessionId || !options.revision) {
|
|
25
|
+
return yield* Effect.fail(new Error("Missing required claim arguments"))
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
options.operation === "claim" &&
|
|
29
|
+
(!options.claimant || !options.runner)
|
|
30
|
+
) {
|
|
31
|
+
return yield* Effect.fail(
|
|
32
|
+
new Error("Claimant and runner identities are required"),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
options.operation === "finish" &&
|
|
37
|
+
options.outcome !== "done" &&
|
|
38
|
+
options.outcome !== "dropped"
|
|
39
|
+
) {
|
|
40
|
+
return yield* Effect.fail(
|
|
41
|
+
new Error("Finish outcome must be done or dropped"),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const common = {
|
|
46
|
+
taskId: options.taskId,
|
|
47
|
+
...(options.phaseId ? { phaseId: options.phaseId } : {}),
|
|
48
|
+
sessionId: options.sessionId,
|
|
49
|
+
revision: options.revision,
|
|
50
|
+
}
|
|
51
|
+
const result =
|
|
52
|
+
options.operation === "claim"
|
|
53
|
+
? yield* claims.claim(
|
|
54
|
+
{
|
|
55
|
+
...common,
|
|
56
|
+
claimant: options.claimant!,
|
|
57
|
+
runner: options.runner!,
|
|
58
|
+
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
|
59
|
+
},
|
|
60
|
+
cwd,
|
|
61
|
+
)
|
|
62
|
+
: options.operation === "release"
|
|
63
|
+
? yield* claims.release(common, cwd)
|
|
64
|
+
: yield* claims.finish(
|
|
65
|
+
{
|
|
66
|
+
...common,
|
|
67
|
+
outcome: options.outcome as "done" | "dropped",
|
|
68
|
+
},
|
|
69
|
+
cwd,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const { data: _, ...output } = result
|
|
73
|
+
log(
|
|
74
|
+
options.json
|
|
75
|
+
? JSON.stringify(output, null, 2)
|
|
76
|
+
: `${options.operation === "claim" ? "Claimed" : options.operation === "release" ? "Released" : "Finished"} ${result.target} at revision ${result.revision}`,
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export const claimHelp = `
|
|
81
|
+
Usage: agency claim <task-id> [phase-id] --claimant <id> --runner <id> --session-id <id> --revision <sha256>
|
|
82
|
+
|
|
83
|
+
Claim an execution unit. Use distinct claimant and runner identities for delegated
|
|
84
|
+
work. --expires-at accepts an optional future ISO-8601 timestamp.
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
export const releaseHelp = `
|
|
88
|
+
Usage: agency release <task-id> [phase-id] --session-id <id> --revision <sha256>
|
|
89
|
+
|
|
90
|
+
Release an execution unit owned by the session.
|
|
91
|
+
`
|
|
92
|
+
|
|
93
|
+
export const finishHelp = `
|
|
94
|
+
Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped>
|
|
95
|
+
|
|
96
|
+
Finish an execution unit owned by the session.
|
|
97
|
+
`
|
package/src/commands/phase.ts
CHANGED
|
@@ -125,7 +125,7 @@ Subcommands:
|
|
|
125
125
|
list <task> List task phases
|
|
126
126
|
show <task> <phase> Show a phase
|
|
127
127
|
status <task> <phase> <status>
|
|
128
|
-
Set open,
|
|
128
|
+
Set open, done, or dropped
|
|
129
129
|
|
|
130
130
|
Create options:
|
|
131
131
|
--description <text> Short description of the phase
|
|
@@ -172,13 +172,13 @@ describe("task and phase command JSON output", () => {
|
|
|
172
172
|
runTestEffect(
|
|
173
173
|
phase({
|
|
174
174
|
subcommand: "status",
|
|
175
|
-
args: ["multi", "first", "
|
|
175
|
+
args: ["multi", "first", "done"],
|
|
176
176
|
cwd: root,
|
|
177
177
|
json: true,
|
|
178
178
|
}),
|
|
179
179
|
),
|
|
180
180
|
)
|
|
181
|
-
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("
|
|
181
|
+
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("done")
|
|
182
182
|
|
|
183
183
|
await runTestEffect(
|
|
184
184
|
task({
|
|
@@ -196,13 +196,13 @@ describe("task and phase command JSON output", () => {
|
|
|
196
196
|
runTestEffect(
|
|
197
197
|
task({
|
|
198
198
|
subcommand: "status",
|
|
199
|
-
args: ["single-status", "
|
|
199
|
+
args: ["single-status", "done"],
|
|
200
200
|
cwd: root,
|
|
201
201
|
json: true,
|
|
202
202
|
}),
|
|
203
203
|
),
|
|
204
204
|
)
|
|
205
|
-
expect(JSON.parse(taskLogs[0]!).data.status).toBe("
|
|
205
|
+
expect(JSON.parse(taskLogs[0]!).data.status).toBe("done")
|
|
206
206
|
})
|
|
207
207
|
|
|
208
208
|
test("converts a single-phase task with an explicit first phase ID", async () => {
|
package/src/commands/task.ts
CHANGED
|
@@ -269,7 +269,7 @@ Subcommands:
|
|
|
269
269
|
create <id> Create a task without prompting
|
|
270
270
|
list List tasks
|
|
271
271
|
show <id> Show a task
|
|
272
|
-
status <id> <status> Set open,
|
|
272
|
+
status <id> <status> Set open, done, or dropped
|
|
273
273
|
|
|
274
274
|
Create options:
|
|
275
275
|
--ticket-url <url> External ticket URL (optional)
|
|
@@ -6,8 +6,9 @@ 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
|
-
import { work } from "./work"
|
|
11
|
+
import { work, workPrepare } from "./work"
|
|
11
12
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
12
13
|
import type { PickWorkbase } from "../workbase/workbase-choice"
|
|
13
14
|
import type { Progress } from "../utils/progress"
|
|
@@ -24,6 +25,9 @@ const singlePhaseWorkspace: ExecutionWorkspace = {
|
|
|
24
25
|
writablePath: "/workbase/tasks/example/code/agency",
|
|
25
26
|
repo: "agency",
|
|
26
27
|
repos: [],
|
|
28
|
+
dryRun: false,
|
|
29
|
+
checkouts: [],
|
|
30
|
+
operations: [],
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
@@ -34,6 +38,9 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
34
38
|
writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
|
|
35
39
|
repo: "agency",
|
|
36
40
|
repos: [],
|
|
41
|
+
dryRun: false,
|
|
42
|
+
checkouts: [],
|
|
43
|
+
operations: [],
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
interface HarnessOptions {
|
|
@@ -127,6 +134,30 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
127
134
|
return Effect.void
|
|
128
135
|
},
|
|
129
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
|
+
}
|
|
130
161
|
const fs = {
|
|
131
162
|
isDirectory: (path: string) =>
|
|
132
163
|
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
@@ -165,6 +196,17 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
165
196
|
Effect.provideService(EpicService, epics as never),
|
|
166
197
|
Effect.provideService(TaskService, tasks as never),
|
|
167
198
|
Effect.provideService(PhaseService, phases as never),
|
|
199
|
+
Effect.provideService(ClaimService, claims as never),
|
|
200
|
+
) as Effect.Effect<void, unknown, never>,
|
|
201
|
+
)
|
|
202
|
+
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
203
|
+
Effect.runPromise(
|
|
204
|
+
workPrepare(commandOptions).pipe(
|
|
205
|
+
Effect.provideService(WorktreeService, worktrees as never),
|
|
206
|
+
Effect.provideService(FileSystemService, fs as never),
|
|
207
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
208
|
+
Effect.provideService(TaskService, tasks as never),
|
|
209
|
+
Effect.provideService(PhaseService, phases as never),
|
|
168
210
|
) as Effect.Effect<void, unknown, never>,
|
|
169
211
|
)
|
|
170
212
|
|
|
@@ -177,10 +219,32 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
177
219
|
shownTasks,
|
|
178
220
|
progressUpdates,
|
|
179
221
|
run,
|
|
222
|
+
runPrepare,
|
|
180
223
|
}
|
|
181
224
|
}
|
|
182
225
|
|
|
183
226
|
describe("work command", () => {
|
|
227
|
+
test("prepares without launching or changing lifecycle status", async () => {
|
|
228
|
+
const harness = createHarness({ existingDirectories: [] })
|
|
229
|
+
|
|
230
|
+
await captureLogs(() =>
|
|
231
|
+
harness.runPrepare({
|
|
232
|
+
cwd: "/workbase",
|
|
233
|
+
directory: "example",
|
|
234
|
+
json: true,
|
|
235
|
+
dryRun: true,
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
expect(harness.events).toEqual(["materialize"])
|
|
240
|
+
expect(harness.launches).toEqual([])
|
|
241
|
+
expect(harness.statusUpdates).toEqual([])
|
|
242
|
+
expect(harness.materializeOptions[0]).toMatchObject({
|
|
243
|
+
json: true,
|
|
244
|
+
dryRun: true,
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
184
248
|
test("launches an epic agent from an epic directory", async () => {
|
|
185
249
|
const harness = createHarness()
|
|
186
250
|
|
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,17 +261,85 @@ 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
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
export const workPrepare = (options: WorkOptions = {}) =>
|
|
276
|
+
Effect.gen(function* () {
|
|
277
|
+
const fs = yield* FileSystemService
|
|
278
|
+
const workbase = yield* WorkbaseService
|
|
279
|
+
const tasks = yield* TaskService
|
|
280
|
+
const phases = yield* PhaseService
|
|
281
|
+
const worktrees = yield* WorktreeService
|
|
282
|
+
const { log } = createLoggers(options)
|
|
283
|
+
const cwd = options.cwd ?? process.cwd()
|
|
284
|
+
const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
285
|
+
const isDirectory = yield* fs.isDirectory(targetPath)
|
|
286
|
+
const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
|
|
287
|
+
|
|
288
|
+
let taskId: string | undefined
|
|
289
|
+
let phaseId: string | undefined
|
|
290
|
+
if (options.directory && !isDirectory) {
|
|
291
|
+
const task = yield* tasks.show(options.directory, root)
|
|
292
|
+
taskId = task.id
|
|
293
|
+
} else {
|
|
294
|
+
const path = relative(root, targetPath)
|
|
295
|
+
const parts =
|
|
296
|
+
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
297
|
+
? []
|
|
298
|
+
: path.split(sep)
|
|
299
|
+
if (parts[0] === "tasks" && parts[1]) {
|
|
300
|
+
const task = yield* tasks.show(parts[1], root)
|
|
301
|
+
taskId = task.id
|
|
302
|
+
if (parts[2] === "phases" && parts[3]) {
|
|
303
|
+
const phase = yield* phases.show(task.id, parts[3], root)
|
|
304
|
+
phaseId = phase.id
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!taskId) {
|
|
310
|
+
return yield* Effect.fail(
|
|
311
|
+
new Error(
|
|
312
|
+
"Work preparation requires a task ID or a path inside an execution unit",
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const workspace = yield* worktrees.materialize(taskId, phaseId, root, {
|
|
318
|
+
...options,
|
|
319
|
+
dryRun: options.dryRun,
|
|
320
|
+
})
|
|
321
|
+
if (options.json) {
|
|
322
|
+
log(JSON.stringify(workspace, null, 2))
|
|
323
|
+
} else {
|
|
324
|
+
log(
|
|
325
|
+
`${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath}`,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
244
328
|
})
|
|
245
329
|
|
|
246
330
|
export const help = `
|
|
247
331
|
Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
|
|
332
|
+
agency work prepare [target] [--dry-run] [--json]
|
|
248
333
|
|
|
249
334
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
250
335
|
with fzf. A positional argument resolves as a directory first, then as a task
|
|
251
336
|
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
252
337
|
workbase first.
|
|
253
338
|
|
|
339
|
+
The prepare subcommand resolves and materializes an execution workspace without
|
|
340
|
+
launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
341
|
+
changes without fetching, creating branches, or creating worktrees.
|
|
342
|
+
|
|
254
343
|
Options:
|
|
255
344
|
--epic <id> Work on an epic
|
|
256
345
|
--opencode Require OpenCode
|
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 },
|