@markjaquith/agency 2.71.6 → 2.71.8
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/package.json +3 -1
- package/src/services/ClaimService.test.ts +32 -0
- package/src/services/ClaimService.ts +50 -31
- package/src/services/PhaseService.ts +47 -23
- package/src/services/TaskService.ts +14 -6
- package/src/services/WorktreePerformance.test.ts +71 -0
- package/src/services/WorktreeService.ts +2 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.71.
|
|
3
|
+
"version": "2.71.8",
|
|
4
4
|
"description": "Manage agentic work across repositories with durable workbases",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -68,8 +68,10 @@
|
|
|
68
68
|
"benchmark:workbase": "bun scripts/benchmark-workbase.ts",
|
|
69
69
|
"benchmark:doctor": "bun scripts/benchmark-doctor.ts",
|
|
70
70
|
"benchmark:context": "bun scripts/benchmark-context.ts",
|
|
71
|
+
"benchmark:finish": "bun scripts/benchmark-finish.ts",
|
|
71
72
|
"benchmark:sync": "bun scripts/benchmark-sync.ts",
|
|
72
73
|
"benchmark:task": "bun scripts/benchmark-task.ts",
|
|
74
|
+
"benchmark:worktree": "bun scripts/benchmark-worktree.ts",
|
|
73
75
|
"test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
|
|
74
76
|
"test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
|
|
75
77
|
"format": "oxfmt",
|
|
@@ -234,6 +234,38 @@ describe("claim service", () => {
|
|
|
234
234
|
)
|
|
235
235
|
})
|
|
236
236
|
|
|
237
|
+
test("inspects a task without parsing unrelated task documents", async () => {
|
|
238
|
+
await mkdir(join(root, "tasks/broken"), { recursive: true })
|
|
239
|
+
await Bun.write(join(root, "tasks/broken/TASK.md"), "not frontmatter\n")
|
|
240
|
+
|
|
241
|
+
const inspected = await inspect()
|
|
242
|
+
expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
|
|
243
|
+
expect(inspected.data).toMatchObject({ branch: "task/single" })
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("inspects a phase without parsing unrelated phase documents", async () => {
|
|
247
|
+
await mkdir(join(root, "tasks/phased/phases/target"), { recursive: true })
|
|
248
|
+
await mkdir(join(root, "tasks/phased/phases/broken"), { recursive: true })
|
|
249
|
+
await Bun.write(
|
|
250
|
+
join(root, "tasks/phased/TASK.md"),
|
|
251
|
+
"---\nticketUrl: null\nphases:\n - id: target\n - id: broken\nstatus: working\n---\n",
|
|
252
|
+
)
|
|
253
|
+
await Bun.write(
|
|
254
|
+
join(root, "tasks/phased/phases/target/PHASE.md"),
|
|
255
|
+
"---\nrepo: agency\nbranch: phase/target\nbase: main\npr: null\nstatus: open\n---\n",
|
|
256
|
+
)
|
|
257
|
+
await Bun.write(
|
|
258
|
+
join(root, "tasks/phased/phases/broken/PHASE.md"),
|
|
259
|
+
"not frontmatter\n",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const inspected = await inspect("phased", "target")
|
|
263
|
+
expect(inspected.target.path).toBe(
|
|
264
|
+
join(root, "tasks/phased/phases/target/PHASE.md"),
|
|
265
|
+
)
|
|
266
|
+
expect(inspected.data).toMatchObject({ branch: "phase/target" })
|
|
267
|
+
})
|
|
268
|
+
|
|
237
269
|
test("serializes concurrent claims and allows expired ownership replacement", async () => {
|
|
238
270
|
const initial = await inspect()
|
|
239
271
|
const attempts = await Promise.allSettled([
|
|
@@ -10,10 +10,7 @@ import {
|
|
|
10
10
|
writeFile,
|
|
11
11
|
} from "node:fs/promises"
|
|
12
12
|
import { basename, dirname, join, relative } from "node:path"
|
|
13
|
-
import { PhaseService } from "./PhaseService"
|
|
14
|
-
import { TaskService } from "./TaskService"
|
|
15
13
|
import { WorkbaseService } from "./WorkbaseService"
|
|
16
|
-
import { FileSystemService } from "./FileSystemService"
|
|
17
14
|
import {
|
|
18
15
|
documentRevision,
|
|
19
16
|
isDocumentRevision,
|
|
@@ -66,6 +63,47 @@ interface ClaimTarget {
|
|
|
66
63
|
readonly label: string
|
|
67
64
|
}
|
|
68
65
|
|
|
66
|
+
const resolveTarget = async (
|
|
67
|
+
root: string,
|
|
68
|
+
taskId: string,
|
|
69
|
+
phaseId?: string,
|
|
70
|
+
): Promise<ClaimTarget> => {
|
|
71
|
+
const taskPath = join(root, "tasks", taskId, "TASK.md")
|
|
72
|
+
const path = phaseId
|
|
73
|
+
? join(root, "tasks", taskId, "phases", phaseId, "PHASE.md")
|
|
74
|
+
: taskPath
|
|
75
|
+
try {
|
|
76
|
+
await stat(taskPath)
|
|
77
|
+
} catch {
|
|
78
|
+
throw new ClaimError({ message: `Task '${taskId}' does not exist` })
|
|
79
|
+
}
|
|
80
|
+
if (phaseId) {
|
|
81
|
+
try {
|
|
82
|
+
await stat(path)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new ClaimError({
|
|
85
|
+
message: `Phase '${phaseId}' does not exist on task '${taskId}'`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return phaseId
|
|
90
|
+
? {
|
|
91
|
+
kind: "phase",
|
|
92
|
+
root,
|
|
93
|
+
taskId,
|
|
94
|
+
phaseId,
|
|
95
|
+
path,
|
|
96
|
+
label: `phase '${taskId}/${phaseId}'`,
|
|
97
|
+
}
|
|
98
|
+
: {
|
|
99
|
+
kind: "task",
|
|
100
|
+
root,
|
|
101
|
+
taskId,
|
|
102
|
+
path,
|
|
103
|
+
label: `task '${taskId}'`,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
69
107
|
interface ClaimInput {
|
|
70
108
|
readonly taskId: string
|
|
71
109
|
readonly phaseId?: string
|
|
@@ -276,43 +314,24 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
276
314
|
startPath: string = process.cwd(),
|
|
277
315
|
) =>
|
|
278
316
|
Effect.gen(function* () {
|
|
279
|
-
const fs = yield* FileSystemService
|
|
280
317
|
const workbase = yield* WorkbaseService
|
|
281
|
-
const tasks = yield* TaskService
|
|
282
|
-
const phases = yield* PhaseService
|
|
283
318
|
const root = yield* workbase.discover(startPath)
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
root,
|
|
292
|
-
taskId: task.id,
|
|
293
|
-
phaseId,
|
|
294
|
-
path: phase!.path,
|
|
295
|
-
label: `phase '${task.id}/${phaseId}'`,
|
|
296
|
-
}
|
|
297
|
-
: {
|
|
298
|
-
kind: "task",
|
|
299
|
-
root,
|
|
300
|
-
taskId: task.id,
|
|
301
|
-
path: task.path,
|
|
302
|
-
label: `task '${task.id}'`,
|
|
303
|
-
}
|
|
304
|
-
if (!phaseId && "phases" in task.data) {
|
|
319
|
+
const target = yield* operation(() =>
|
|
320
|
+
resolveTarget(root, taskId, phaseId),
|
|
321
|
+
)
|
|
322
|
+
const content = yield* operation(() => readFile(target.path, "utf8"))
|
|
323
|
+
const parsed = parseFrontmatterSync(content, target.path)
|
|
324
|
+
const data = decodeExecution(target, parsed.data)
|
|
325
|
+
if (!phaseId && "phases" in data) {
|
|
305
326
|
return yield* new ClaimError({
|
|
306
327
|
target: target.label,
|
|
307
|
-
message: `Task '${
|
|
328
|
+
message: `Task '${taskId}' has multiple phases; claim a phase instead`,
|
|
308
329
|
})
|
|
309
330
|
}
|
|
310
|
-
const content = yield* fs.readFile(target.path)
|
|
311
|
-
const parsed = parseFrontmatterSync(content, target.path)
|
|
312
331
|
return {
|
|
313
332
|
target,
|
|
314
333
|
revision: documentRevision(content),
|
|
315
|
-
data
|
|
334
|
+
data,
|
|
316
335
|
}
|
|
317
336
|
}),
|
|
318
337
|
|
|
@@ -105,7 +105,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
105
105
|
message: `Review task '${taskId}' cannot be converted to phases`,
|
|
106
106
|
})
|
|
107
107
|
}
|
|
108
|
-
const
|
|
108
|
+
const taskData = task.data
|
|
109
|
+
const isMultiPhase = "phases" in taskData
|
|
109
110
|
let firstPhaseId: string | undefined
|
|
110
111
|
if (!isMultiPhase) {
|
|
111
112
|
if (!input.firstPhase) {
|
|
@@ -129,7 +130,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
129
130
|
|
|
130
131
|
if (
|
|
131
132
|
isMultiPhase &&
|
|
132
|
-
|
|
133
|
+
taskData.phases.some((phase) => phase.id === id)
|
|
133
134
|
) {
|
|
134
135
|
return yield* new PhaseError({
|
|
135
136
|
message: `Phase '${id}' already exists on task '${taskId}'`,
|
|
@@ -142,7 +143,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
142
143
|
}
|
|
143
144
|
const knownPhases = new Set(
|
|
144
145
|
isMultiPhase
|
|
145
|
-
?
|
|
146
|
+
? taskData.phases.map((phase) => phase.id)
|
|
146
147
|
: [firstPhaseId!],
|
|
147
148
|
)
|
|
148
149
|
for (const dependency of input.dependsOn ?? []) {
|
|
@@ -163,7 +164,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
163
164
|
base: input.base,
|
|
164
165
|
pr: null,
|
|
165
166
|
})
|
|
166
|
-
const existingExecution = isMultiPhase ? undefined :
|
|
167
|
+
const existingExecution = isMultiPhase ? undefined : taskData
|
|
167
168
|
const aliases = new Set([
|
|
168
169
|
data.repo,
|
|
169
170
|
...(data.repos ?? []).map((reference) => reference.repo),
|
|
@@ -226,15 +227,15 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
226
227
|
})
|
|
227
228
|
}
|
|
228
229
|
const firstData = yield* decodePhase({
|
|
229
|
-
repo:
|
|
230
|
-
...(
|
|
231
|
-
branch:
|
|
232
|
-
base:
|
|
233
|
-
pr:
|
|
234
|
-
status:
|
|
235
|
-
...(
|
|
236
|
-
...(
|
|
237
|
-
? { completion:
|
|
230
|
+
repo: taskData.repo,
|
|
231
|
+
...(taskData.repos?.length ? { repos: taskData.repos } : {}),
|
|
232
|
+
branch: taskData.branch,
|
|
233
|
+
base: taskData.base,
|
|
234
|
+
pr: taskData.pr,
|
|
235
|
+
status: taskData.status,
|
|
236
|
+
...(taskData.claim ? { claim: taskData.claim } : {}),
|
|
237
|
+
...(taskData.completion
|
|
238
|
+
? { completion: taskData.completion }
|
|
238
239
|
: {}),
|
|
239
240
|
})
|
|
240
241
|
const firstTitle = firstPhaseId!
|
|
@@ -514,9 +515,9 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
514
515
|
}
|
|
515
516
|
|
|
516
517
|
const updatedTaskData = {
|
|
517
|
-
...
|
|
518
|
+
...taskData,
|
|
518
519
|
phases: [
|
|
519
|
-
...
|
|
520
|
+
...taskData.phases,
|
|
520
521
|
{
|
|
521
522
|
id,
|
|
522
523
|
...(input.dependsOn?.length
|
|
@@ -552,14 +553,18 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
552
553
|
} satisfies PhaseRecord
|
|
553
554
|
}),
|
|
554
555
|
|
|
555
|
-
list: (
|
|
556
|
+
list: (
|
|
557
|
+
taskId: string,
|
|
558
|
+
startPath: string = process.cwd(),
|
|
559
|
+
parentAlreadyLoaded = false,
|
|
560
|
+
) =>
|
|
556
561
|
Effect.gen(function* () {
|
|
557
562
|
const fs = yield* FileSystemService
|
|
558
563
|
const workbase = yield* WorkbaseService
|
|
559
564
|
const tasks = yield* TaskService
|
|
560
565
|
const root = yield* workbase.discover(startPath)
|
|
561
566
|
const validTaskId = yield* decodeId(taskId, "task")
|
|
562
|
-
yield* tasks.show(validTaskId, root)
|
|
567
|
+
if (!parentAlreadyLoaded) yield* tasks.show(validTaskId, root)
|
|
563
568
|
const directory = join(root, "tasks", validTaskId, "phases")
|
|
564
569
|
if (!(yield* fs.isDirectory(directory))) return [] as PhaseRecord[]
|
|
565
570
|
const entries = (yield* fs.readDirectory(directory))
|
|
@@ -586,17 +591,36 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
586
591
|
|
|
587
592
|
show: (taskId: string, id: string, startPath: string = process.cwd()) =>
|
|
588
593
|
Effect.gen(function* () {
|
|
589
|
-
const
|
|
594
|
+
const fs = yield* FileSystemService
|
|
595
|
+
const workbase = yield* WorkbaseService
|
|
596
|
+
const tasks = yield* TaskService
|
|
597
|
+
const validTaskId = yield* decodeId(taskId, "task")
|
|
590
598
|
const validId = yield* decodeId(id, "phase")
|
|
591
|
-
const
|
|
592
|
-
|
|
599
|
+
const root = yield* workbase.discover(startPath)
|
|
600
|
+
yield* tasks.show(validTaskId, root)
|
|
601
|
+
const path = join(
|
|
602
|
+
root,
|
|
603
|
+
"tasks",
|
|
604
|
+
validTaskId,
|
|
605
|
+
"phases",
|
|
606
|
+
validId,
|
|
607
|
+
"PHASE.md",
|
|
593
608
|
)
|
|
594
|
-
if (!
|
|
609
|
+
if (!(yield* fs.exists(path))) {
|
|
595
610
|
return yield* new PhaseError({
|
|
596
|
-
message: `Phase '${validId}' does not exist on task '${
|
|
611
|
+
message: `Phase '${validId}' does not exist on task '${validTaskId}'`,
|
|
597
612
|
})
|
|
598
613
|
}
|
|
599
|
-
|
|
614
|
+
const content = yield* fs.readFile(path)
|
|
615
|
+
const parsed = yield* parseFrontmatter(content, path)
|
|
616
|
+
return {
|
|
617
|
+
taskId: validTaskId,
|
|
618
|
+
id: validId,
|
|
619
|
+
path,
|
|
620
|
+
content,
|
|
621
|
+
revision: documentRevision(content),
|
|
622
|
+
data: yield* decodePhase(parsed.data),
|
|
623
|
+
} satisfies PhaseRecord
|
|
600
624
|
}),
|
|
601
625
|
|
|
602
626
|
setStatus: (
|
|
@@ -582,17 +582,25 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
582
582
|
|
|
583
583
|
show: (id: string, startPath: string = process.cwd()) =>
|
|
584
584
|
Effect.gen(function* () {
|
|
585
|
-
const
|
|
585
|
+
const fs = yield* FileSystemService
|
|
586
|
+
const workbase = yield* WorkbaseService
|
|
586
587
|
const validId = yield* decodeId(id)
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
)
|
|
590
|
-
if (!record) {
|
|
588
|
+
const root = yield* workbase.discover(startPath)
|
|
589
|
+
const path = join(root, "tasks", validId, "TASK.md")
|
|
590
|
+
if (!(yield* fs.exists(path))) {
|
|
591
591
|
return yield* new TaskError({
|
|
592
592
|
message: `Task '${validId}' does not exist`,
|
|
593
593
|
})
|
|
594
594
|
}
|
|
595
|
-
|
|
595
|
+
const content = yield* fs.readFile(path)
|
|
596
|
+
const parsed = yield* parseFrontmatter(content, path)
|
|
597
|
+
return {
|
|
598
|
+
id: validId,
|
|
599
|
+
path,
|
|
600
|
+
content,
|
|
601
|
+
revision: documentRevision(content),
|
|
602
|
+
data: yield* decodeTask(parsed.data),
|
|
603
|
+
} satisfies TaskRecord
|
|
596
604
|
}),
|
|
597
605
|
|
|
598
606
|
setStatus: (
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { PhaseService } from "./PhaseService"
|
|
7
|
+
import { TaskService } from "./TaskService"
|
|
8
|
+
|
|
9
|
+
const taskDocument = (id: string) => `---
|
|
10
|
+
ticketUrl: null
|
|
11
|
+
repo: agency
|
|
12
|
+
branch: task/${id}
|
|
13
|
+
base: main
|
|
14
|
+
pr: null
|
|
15
|
+
status: open
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# ${id}
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
describe("worktree document loading", () => {
|
|
22
|
+
let root: string
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
root = await createTempDir()
|
|
26
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(async () => cleanupTempDir(root))
|
|
30
|
+
|
|
31
|
+
test("shows a task without parsing sibling task documents", async () => {
|
|
32
|
+
await mkdir(join(root, "tasks/target"), { recursive: true })
|
|
33
|
+
await mkdir(join(root, "tasks/broken"), { recursive: true })
|
|
34
|
+
await Bun.write(join(root, "tasks/target/TASK.md"), taskDocument("target"))
|
|
35
|
+
await Bun.write(join(root, "tasks/broken/TASK.md"), "invalid frontmatter")
|
|
36
|
+
|
|
37
|
+
const task = await runTestEffect(
|
|
38
|
+
TaskService.pipe(
|
|
39
|
+
Effect.flatMap((service) => service.show("target", root)),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(task.id).toBe("target")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("lists phases from a preloaded task without rereading its parent", async () => {
|
|
47
|
+
const phasePath = join(root, "tasks/multi/phases/build")
|
|
48
|
+
await mkdir(phasePath, { recursive: true })
|
|
49
|
+
await Bun.write(
|
|
50
|
+
join(phasePath, "PHASE.md"),
|
|
51
|
+
`---
|
|
52
|
+
repo: agency
|
|
53
|
+
branch: task/multi-build
|
|
54
|
+
base: main
|
|
55
|
+
pr: null
|
|
56
|
+
status: open
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Build
|
|
60
|
+
`,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const phases = await runTestEffect(
|
|
64
|
+
PhaseService.pipe(
|
|
65
|
+
Effect.flatMap((service) => service.list("multi", root, true)),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
expect(phases.map(({ id }) => id)).toEqual(["build"])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -1089,7 +1089,7 @@ const buildInspectionContext = (
|
|
|
1089
1089
|
if ("phases" in task.data) {
|
|
1090
1090
|
const phaseRecords =
|
|
1091
1091
|
preloadedPhasesByTask?.get(task.id) ??
|
|
1092
|
-
(yield* phaseService.list(task.id, root))
|
|
1092
|
+
(yield* phaseService.list(task.id, root, true))
|
|
1093
1093
|
phasesByTask.set(task.id, phaseRecords)
|
|
1094
1094
|
for (const phase of phaseRecords) {
|
|
1095
1095
|
addExecution(phase.data, {
|
|
@@ -3593,12 +3593,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
3593
3593
|
})
|
|
3594
3594
|
}
|
|
3595
3595
|
|
|
3596
|
-
|
|
3597
|
-
taskId,
|
|
3598
|
-
phaseId,
|
|
3599
|
-
inspection.root,
|
|
3600
|
-
)
|
|
3601
|
-
if (current.checkouts.some((checkout) => !checkout.exists)) {
|
|
3596
|
+
if (repaired.checkouts.some((checkout) => !checkout.exists)) {
|
|
3602
3597
|
const workspace = yield* service
|
|
3603
3598
|
.materialize(taskId, phaseId, inspection.root, {
|
|
3604
3599
|
...options,
|