@markjaquith/agency 3.2.1 → 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.
- package/README.md +30 -60
- package/cli-main.ts +38 -72
- package/fixtures/protocol/orchestration-recipes.json +9 -34
- package/package.json +1 -4
- package/schemas/agency-graph-v1.schema.json +2 -31
- package/src/cli-parser.test.ts +13 -132
- package/src/cli-parser.ts +10 -101
- package/src/cli.test.ts +12 -111
- package/src/commands/act.ts +2 -6
- package/src/commands/push.test.ts +4 -2
- package/src/commands/push.ts +2 -0
- package/src/commands/sync.ts +3 -3
- package/src/commands/validate.ts +3 -1
- package/src/commands/work.test.ts +70 -8
- package/src/commands/work.ts +15 -8
- package/src/graph-schema.ts +0 -2
- package/src/protocol.test.ts +24 -25
- package/src/protocol.ts +56 -15
- package/src/readiness.test.ts +2 -2
- package/src/services/ArchiveBulkService.test.ts +0 -88
- package/src/services/ArchiveService.ts +0 -32
- package/src/services/FileSystemService.ts +2 -0
- package/src/services/GraphMutationService.ts +0 -26
- package/src/services/GraphService.test.ts +1 -1
- package/src/services/IntegrationService.test.ts +4 -4
- package/src/services/LifecycleTransaction.ts +5 -1
- package/src/services/PhaseService.ts +1 -8
- package/src/services/PushService.test.ts +110 -4
- package/src/services/PushService.ts +435 -85
- package/src/services/ReadinessService.test.ts +0 -34
- package/src/services/ReadinessService.ts +1 -20
- package/src/services/ReviewService.test.ts +0 -64
- package/src/services/ReviewService.ts +0 -5
- package/src/services/SyncService.test.ts +75 -88
- package/src/services/SyncService.ts +52 -123
- package/src/services/TaskPhaseService.test.ts +13 -1
- package/src/services/TaskService.ts +1 -7
- package/src/services/WorkbaseService.ts +0 -3
- package/src/services/WorktreeService.test.ts +192 -1
- package/src/services/WorktreeService.ts +169 -34
- package/src/test-utils.ts +0 -2
- package/src/usage-log.test.ts +11 -1
- package/src/usage-log.ts +22 -4
- package/src/utils/process.test.ts +10 -0
- package/src/utils/process.ts +59 -6
- package/src/workbase/AGENTS.md +9 -15
- package/src/workbase/agent-command.test.ts +0 -3
- package/src/workbase/agent-command.ts +0 -6
- package/src/workbase/document-revision.ts +0 -4
- package/src/workbase/schemas.test.ts +12 -25
- package/src/workbase/schemas.ts +0 -16
- package/src/commands/claim.ts +0 -122
- package/src/services/ClaimService.test.ts +0 -415
- package/src/services/ClaimService.ts +0 -608
|
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
|
|
2
2
|
import { Schema } from "@effect/schema"
|
|
3
3
|
import {
|
|
4
4
|
EntityId,
|
|
5
|
-
ClaimRecord,
|
|
6
5
|
EpicFrontmatter,
|
|
7
6
|
PhaseFrontmatter,
|
|
8
7
|
TaskFrontmatter,
|
|
@@ -401,30 +400,18 @@ describe("work status", () => {
|
|
|
401
400
|
})
|
|
402
401
|
})
|
|
403
402
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
expect(Schema.decodeUnknownSync(ClaimRecord)(record)).toEqual(record)
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
test("rejects malformed timestamps, revisions, and empty identities", () => {
|
|
420
|
-
for (const invalid of [
|
|
421
|
-
{ ...record, claimant: "" },
|
|
422
|
-
{ ...record, startedAt: "today" },
|
|
423
|
-
{ ...record, targetRevision: "abc" },
|
|
424
|
-
]) {
|
|
425
|
-
expect(() => Schema.decodeUnknownSync(ClaimRecord)(invalid)).toThrow()
|
|
426
|
-
}
|
|
427
|
-
})
|
|
403
|
+
test("rejects removed claim frontmatter", () => {
|
|
404
|
+
expect(() =>
|
|
405
|
+
Schema.decodeUnknownSync(TaskFrontmatter, { onExcessProperty: "error" })({
|
|
406
|
+
ticketUrl: null,
|
|
407
|
+
repo: "agency",
|
|
408
|
+
branch: "task/example",
|
|
409
|
+
base: "main",
|
|
410
|
+
pr: null,
|
|
411
|
+
status: "working",
|
|
412
|
+
claim: { state: "active" },
|
|
413
|
+
}),
|
|
414
|
+
).toThrow()
|
|
428
415
|
})
|
|
429
416
|
|
|
430
417
|
describe("workbase registry", () => {
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -50,19 +50,6 @@ export const DocumentRevision = Schema.String.pipe(
|
|
|
50
50
|
Schema.pattern(/^[a-f0-9]{64}$/),
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
-
export const ClaimRecord = Schema.Struct({
|
|
54
|
-
claimant: NonEmptyString,
|
|
55
|
-
agent: NonEmptyString,
|
|
56
|
-
sessionId: NonEmptyString,
|
|
57
|
-
startedAt: IsoTimestamp,
|
|
58
|
-
targetRevision: DocumentRevision,
|
|
59
|
-
expiresAt: Schema.optional(IsoTimestamp),
|
|
60
|
-
state: Schema.Literal("active", "released", "finished"),
|
|
61
|
-
releasedAt: Schema.optional(IsoTimestamp),
|
|
62
|
-
finishedAt: Schema.optional(IsoTimestamp),
|
|
63
|
-
outcome: Schema.optional(Schema.Literal("done", "dropped")),
|
|
64
|
-
})
|
|
65
|
-
|
|
66
53
|
const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
|
|
67
54
|
|
|
68
55
|
const GitHubPullRequestUrl = NonEmptyString.pipe(
|
|
@@ -163,7 +150,6 @@ const ExecutionUnit = {
|
|
|
163
150
|
base: NonEmptyString,
|
|
164
151
|
pr: Schema.NullOr(Schema.Union(GitHubPullRequestUrl, PullRequestRecord)),
|
|
165
152
|
status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
|
|
166
|
-
claim: Schema.optional(ClaimRecord),
|
|
167
153
|
completion: Schema.optional(CompletionRecord),
|
|
168
154
|
}
|
|
169
155
|
|
|
@@ -262,7 +248,6 @@ const ReviewTaskFrontmatter = Schema.Struct({
|
|
|
262
248
|
...TaskMetadata,
|
|
263
249
|
review: ReviewRecord,
|
|
264
250
|
status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
|
|
265
|
-
claim: Schema.optional(ClaimRecord),
|
|
266
251
|
completion: Schema.optional(CompletionRecord),
|
|
267
252
|
})
|
|
268
253
|
|
|
@@ -288,7 +273,6 @@ export type RepositoryDeclaration = Schema.Schema.Type<
|
|
|
288
273
|
typeof RepositoryDeclaration
|
|
289
274
|
>
|
|
290
275
|
export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
|
|
291
|
-
export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
|
|
292
276
|
export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>
|
|
293
277
|
export type ReviewSource = Schema.Schema.Type<typeof ReviewSource>
|
|
294
278
|
export type ReviewRecord = Schema.Schema.Type<typeof ReviewRecord>
|
package/src/commands/claim.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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 agent?: string
|
|
12
|
-
readonly sessionId?: string
|
|
13
|
-
readonly revision?: string
|
|
14
|
-
readonly expiresAt?: string
|
|
15
|
-
readonly outcome?: string
|
|
16
|
-
readonly noPullRequest?: boolean
|
|
17
|
-
readonly summary?: string
|
|
18
|
-
readonly evidenceUrl?: string
|
|
19
|
-
readonly json?: boolean
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const claimCommand = (options: ClaimCommandOptions) =>
|
|
23
|
-
Effect.gen(function* () {
|
|
24
|
-
const claims = yield* ClaimService
|
|
25
|
-
const { log } = createLoggers(options)
|
|
26
|
-
const cwd = options.cwd ?? process.cwd()
|
|
27
|
-
if (!options.taskId || !options.sessionId || !options.revision) {
|
|
28
|
-
return yield* Effect.fail(new Error("Missing required claim arguments"))
|
|
29
|
-
}
|
|
30
|
-
if (
|
|
31
|
-
options.operation === "claim" &&
|
|
32
|
-
(!options.claimant || !options.agent)
|
|
33
|
-
) {
|
|
34
|
-
return yield* Effect.fail(
|
|
35
|
-
new Error("Claimant and agent identities are required"),
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
if (options.noPullRequest && !options.summary?.trim()) {
|
|
39
|
-
return yield* Effect.fail(
|
|
40
|
-
new Error("Non-PR completion requires a non-empty summary"),
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
if (options.noPullRequest && options.outcome !== "done") {
|
|
44
|
-
return yield* Effect.fail(
|
|
45
|
-
new Error("Non-PR completion is valid only with a done outcome"),
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
if (
|
|
49
|
-
options.operation === "finish" &&
|
|
50
|
-
options.outcome !== "done" &&
|
|
51
|
-
options.outcome !== "dropped"
|
|
52
|
-
) {
|
|
53
|
-
return yield* Effect.fail(
|
|
54
|
-
new Error("Finish outcome must be done or dropped"),
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const common = {
|
|
59
|
-
taskId: options.taskId,
|
|
60
|
-
...(options.phaseId ? { phaseId: options.phaseId } : {}),
|
|
61
|
-
sessionId: options.sessionId,
|
|
62
|
-
revision: options.revision,
|
|
63
|
-
}
|
|
64
|
-
const result =
|
|
65
|
-
options.operation === "claim"
|
|
66
|
-
? yield* claims.claim(
|
|
67
|
-
{
|
|
68
|
-
...common,
|
|
69
|
-
claimant: options.claimant!,
|
|
70
|
-
agent: options.agent!,
|
|
71
|
-
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
|
72
|
-
},
|
|
73
|
-
cwd,
|
|
74
|
-
)
|
|
75
|
-
: options.operation === "release"
|
|
76
|
-
? yield* claims.release(common, cwd)
|
|
77
|
-
: yield* claims.finish(
|
|
78
|
-
{
|
|
79
|
-
...common,
|
|
80
|
-
outcome: options.outcome as "done" | "dropped",
|
|
81
|
-
...(options.noPullRequest
|
|
82
|
-
? {
|
|
83
|
-
nonPrCompletion: {
|
|
84
|
-
summary: options.summary!,
|
|
85
|
-
...(options.evidenceUrl
|
|
86
|
-
? { evidenceUrl: options.evidenceUrl }
|
|
87
|
-
: {}),
|
|
88
|
-
},
|
|
89
|
-
}
|
|
90
|
-
: {}),
|
|
91
|
-
},
|
|
92
|
-
cwd,
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
const { data: _, ...output } = result
|
|
96
|
-
log(
|
|
97
|
-
options.json
|
|
98
|
-
? JSON.stringify(output, null, 2)
|
|
99
|
-
: `${options.operation === "claim" ? "Claimed" : options.operation === "release" ? "Released" : "Finished"} ${result.target} at revision ${result.revision}`,
|
|
100
|
-
)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
export const claimHelp = `
|
|
104
|
-
Usage: agency claim <task-id> [phase-id] --claimant <id> --agent <id> --session-id <id> --revision <sha256>
|
|
105
|
-
|
|
106
|
-
Claim an execution unit. Use distinct claimant and agent identities for delegated
|
|
107
|
-
work. --expires-at accepts an optional future ISO-8601 timestamp.
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
export const releaseHelp = `
|
|
111
|
-
Usage: agency release <task-id> [phase-id] --session-id <id> --revision <sha256>
|
|
112
|
-
|
|
113
|
-
Release an execution unit owned by the session.
|
|
114
|
-
`
|
|
115
|
-
|
|
116
|
-
export const finishHelp = `
|
|
117
|
-
Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [--no-pull-request --summary <text> [--evidence-url <url>]]
|
|
118
|
-
|
|
119
|
-
Finish a claim owned by the session. A done claim outcome leaves unmerged work
|
|
120
|
-
working; agency sync marks the execution unit done after merge. Use
|
|
121
|
-
--no-pull-request with a summary for an explicit non-PR completion.
|
|
122
|
-
`
|
|
@@ -1,415 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { Effect } from "effect"
|
|
3
|
-
import { mkdir, readFile, writeFile } 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
|
-
agent: "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
|
-
agent: "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("working")
|
|
143
|
-
expect(finished.claim).toMatchObject({
|
|
144
|
-
state: "finished",
|
|
145
|
-
finishedAt: "2026-07-17T12:45:00.000Z",
|
|
146
|
-
outcome: "done",
|
|
147
|
-
})
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
test("finishes claimed non-PR work with durable completion evidence", async () => {
|
|
151
|
-
const initial = await inspect()
|
|
152
|
-
const acquired = await claim(initial.revision)
|
|
153
|
-
const finished = await runTestEffect(
|
|
154
|
-
ClaimService.pipe(
|
|
155
|
-
Effect.flatMap((service) =>
|
|
156
|
-
service.finish(
|
|
157
|
-
{
|
|
158
|
-
taskId: "single",
|
|
159
|
-
sessionId: "session-1",
|
|
160
|
-
revision: acquired.revision,
|
|
161
|
-
outcome: "done",
|
|
162
|
-
nonPrCompletion: {
|
|
163
|
-
summary: "Review completed; no code change was needed.",
|
|
164
|
-
evidenceUrl: "https://example.com/review",
|
|
165
|
-
},
|
|
166
|
-
now: at("2026-07-17T12:45:00.000Z"),
|
|
167
|
-
},
|
|
168
|
-
root,
|
|
169
|
-
),
|
|
170
|
-
),
|
|
171
|
-
),
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
expect(finished.data).toMatchObject({
|
|
175
|
-
status: "done",
|
|
176
|
-
claim: { state: "finished", outcome: "done" },
|
|
177
|
-
completion: {
|
|
178
|
-
mode: "non-pr",
|
|
179
|
-
completedAt: "2026-07-17T12:45:00.000Z",
|
|
180
|
-
summary: "Review completed; no code change was needed.",
|
|
181
|
-
evidenceUrl: "https://example.com/review",
|
|
182
|
-
},
|
|
183
|
-
})
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
test("rejects invalid claimed non-PR completion", async () => {
|
|
187
|
-
const initial = await inspect()
|
|
188
|
-
const acquired = await claim(initial.revision)
|
|
189
|
-
await expect(
|
|
190
|
-
runTestEffect(
|
|
191
|
-
ClaimService.pipe(
|
|
192
|
-
Effect.flatMap((service) =>
|
|
193
|
-
service.finish(
|
|
194
|
-
{
|
|
195
|
-
taskId: "single",
|
|
196
|
-
sessionId: "session-1",
|
|
197
|
-
revision: acquired.revision,
|
|
198
|
-
outcome: "done",
|
|
199
|
-
nonPrCompletion: { summary: " " },
|
|
200
|
-
},
|
|
201
|
-
root,
|
|
202
|
-
),
|
|
203
|
-
),
|
|
204
|
-
),
|
|
205
|
-
),
|
|
206
|
-
).rejects.toThrow("summary must not be empty")
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test("returns structured ownership and revision conflicts", async () => {
|
|
210
|
-
const initial = await inspect()
|
|
211
|
-
const acquired = await claim(initial.revision)
|
|
212
|
-
|
|
213
|
-
await expect(claim(acquired.revision, "session-2")).rejects.toThrow(
|
|
214
|
-
"is claimed by 'agent-1'",
|
|
215
|
-
)
|
|
216
|
-
await expect(
|
|
217
|
-
runTestEffect(
|
|
218
|
-
ClaimService.pipe(
|
|
219
|
-
Effect.flatMap((service) =>
|
|
220
|
-
service.release(
|
|
221
|
-
{
|
|
222
|
-
taskId: "single",
|
|
223
|
-
sessionId: "session-2",
|
|
224
|
-
revision: acquired.revision,
|
|
225
|
-
},
|
|
226
|
-
root,
|
|
227
|
-
),
|
|
228
|
-
),
|
|
229
|
-
),
|
|
230
|
-
),
|
|
231
|
-
).rejects.toThrow("does not own")
|
|
232
|
-
await expect(claim(initial.revision, "session-3")).rejects.toThrow(
|
|
233
|
-
"Revision conflict",
|
|
234
|
-
)
|
|
235
|
-
})
|
|
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
|
-
test("serializes concurrent claims and allows expired ownership replacement", async () => {
|
|
269
|
-
const initial = await inspect()
|
|
270
|
-
const attempts = await Promise.allSettled([
|
|
271
|
-
claim(initial.revision, "session-a"),
|
|
272
|
-
claim(initial.revision, "session-b"),
|
|
273
|
-
])
|
|
274
|
-
expect(
|
|
275
|
-
attempts.filter((result) => result.status === "fulfilled"),
|
|
276
|
-
).toHaveLength(1)
|
|
277
|
-
expect(
|
|
278
|
-
attempts.filter((result) => result.status === "rejected"),
|
|
279
|
-
).toHaveLength(1)
|
|
280
|
-
const current = await inspect()
|
|
281
|
-
expect(current.data.claim?.state).toBe("active")
|
|
282
|
-
|
|
283
|
-
await runTestEffect(
|
|
284
|
-
ClaimService.pipe(
|
|
285
|
-
Effect.flatMap((service) =>
|
|
286
|
-
service.release(
|
|
287
|
-
{
|
|
288
|
-
taskId: "single",
|
|
289
|
-
sessionId: current.data.claim!.sessionId,
|
|
290
|
-
revision: current.revision,
|
|
291
|
-
},
|
|
292
|
-
root,
|
|
293
|
-
),
|
|
294
|
-
),
|
|
295
|
-
),
|
|
296
|
-
)
|
|
297
|
-
const released = await inspect()
|
|
298
|
-
const expiring = await claim(
|
|
299
|
-
released.revision,
|
|
300
|
-
"expiring",
|
|
301
|
-
at("2026-07-17T12:00:00.000Z"),
|
|
302
|
-
"2026-07-17T12:01:00.000Z",
|
|
303
|
-
)
|
|
304
|
-
const replacement = await claim(
|
|
305
|
-
expiring.revision,
|
|
306
|
-
"replacement",
|
|
307
|
-
at("2026-07-17T12:02:00.000Z"),
|
|
308
|
-
)
|
|
309
|
-
expect(replacement.claim.sessionId).toBe("replacement")
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
test("claims phases and rejects multi-phase task containers", async () => {
|
|
313
|
-
await runTestEffect(
|
|
314
|
-
TaskService.pipe(
|
|
315
|
-
Effect.flatMap((service) =>
|
|
316
|
-
service.create(
|
|
317
|
-
{ id: "multi", ticketUrl: null, multiPhase: true },
|
|
318
|
-
root,
|
|
319
|
-
),
|
|
320
|
-
),
|
|
321
|
-
),
|
|
322
|
-
)
|
|
323
|
-
await runTestEffect(
|
|
324
|
-
PhaseService.pipe(
|
|
325
|
-
Effect.flatMap((service) =>
|
|
326
|
-
service.create(
|
|
327
|
-
{
|
|
328
|
-
taskId: "multi",
|
|
329
|
-
id: "implementation",
|
|
330
|
-
repo: "agency",
|
|
331
|
-
branch: "task/multi",
|
|
332
|
-
base: "main",
|
|
333
|
-
},
|
|
334
|
-
root,
|
|
335
|
-
),
|
|
336
|
-
),
|
|
337
|
-
),
|
|
338
|
-
)
|
|
339
|
-
await expect(inspect("multi")).rejects.toThrow("claim a phase instead")
|
|
340
|
-
const phase = await inspect("multi", "implementation")
|
|
341
|
-
const acquired = await runTestEffect(
|
|
342
|
-
ClaimService.pipe(
|
|
343
|
-
Effect.flatMap((service) =>
|
|
344
|
-
service.claim(
|
|
345
|
-
{
|
|
346
|
-
taskId: "multi",
|
|
347
|
-
phaseId: "implementation",
|
|
348
|
-
claimant: "orchestrator",
|
|
349
|
-
agent: "agent",
|
|
350
|
-
sessionId: "phase-session",
|
|
351
|
-
revision: phase.revision,
|
|
352
|
-
},
|
|
353
|
-
root,
|
|
354
|
-
),
|
|
355
|
-
),
|
|
356
|
-
),
|
|
357
|
-
)
|
|
358
|
-
expect(acquired.target).toBe("phase 'multi/implementation'")
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
test("inspects only the requested task on large workbases", async () => {
|
|
362
|
-
for (let index = 0; index < 100; index += 1) {
|
|
363
|
-
const taskRoot = join(root, "tasks", `unrelated-${index}`)
|
|
364
|
-
await mkdir(taskRoot, { recursive: true })
|
|
365
|
-
await writeFile(join(taskRoot, "TASK.md"), "not valid frontmatter\n")
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const inspected = await inspect()
|
|
369
|
-
expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
|
|
370
|
-
expect(inspected.data.status).toBe("open")
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
test("inspects only the requested phase", async () => {
|
|
374
|
-
await runTestEffect(
|
|
375
|
-
TaskService.pipe(
|
|
376
|
-
Effect.flatMap((service) =>
|
|
377
|
-
service.create(
|
|
378
|
-
{ id: "multi", ticketUrl: null, multiPhase: true },
|
|
379
|
-
root,
|
|
380
|
-
),
|
|
381
|
-
),
|
|
382
|
-
),
|
|
383
|
-
)
|
|
384
|
-
await runTestEffect(
|
|
385
|
-
PhaseService.pipe(
|
|
386
|
-
Effect.flatMap((service) =>
|
|
387
|
-
service.create(
|
|
388
|
-
{
|
|
389
|
-
taskId: "multi",
|
|
390
|
-
id: "requested",
|
|
391
|
-
repo: "agency",
|
|
392
|
-
branch: "task/multi",
|
|
393
|
-
base: "main",
|
|
394
|
-
},
|
|
395
|
-
root,
|
|
396
|
-
),
|
|
397
|
-
),
|
|
398
|
-
),
|
|
399
|
-
)
|
|
400
|
-
const taskPath = join(root, "tasks/multi/TASK.md")
|
|
401
|
-
await writeFile(
|
|
402
|
-
taskPath,
|
|
403
|
-
(await readFile(taskPath, "utf8")).replace("multi", "["),
|
|
404
|
-
)
|
|
405
|
-
const unrelatedPhase = join(root, "tasks/multi/phases/unrelated")
|
|
406
|
-
await mkdir(unrelatedPhase, { recursive: true })
|
|
407
|
-
await writeFile(join(unrelatedPhase, "PHASE.md"), "not valid frontmatter\n")
|
|
408
|
-
|
|
409
|
-
const inspected = await inspect("multi", "requested")
|
|
410
|
-
expect(inspected.target.path).toBe(
|
|
411
|
-
join(root, "tasks/multi/phases/requested/PHASE.md"),
|
|
412
|
-
)
|
|
413
|
-
expect(inspected.data.status).toBe("open")
|
|
414
|
-
})
|
|
415
|
-
})
|