@markjaquith/agency 2.13.0 → 2.15.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 +62 -8
- 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 +32 -35
- package/src/commands/validate.ts +1 -6
- package/src/commands/work.test.ts +46 -12
- package/src/commands/work.ts +38 -19
- 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/utils/chooser.test.ts +108 -0
- package/src/utils/chooser.ts +222 -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 +39 -0
- package/src/workbase/schemas.ts +22 -0
- package/src/workbase/work-target.test.ts +10 -0
- package/src/workbase/work-target.ts +45 -35
- package/src/workbase/workbase-choice.ts +12 -37
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Data, Effect, Either } from "effect"
|
|
3
|
+
import { randomUUID } from "node:crypto"
|
|
4
|
+
import {
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises"
|
|
12
|
+
import { basename, dirname, join } from "node:path"
|
|
13
|
+
import { PhaseService } from "./PhaseService"
|
|
14
|
+
import { TaskService } from "./TaskService"
|
|
15
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
16
|
+
import { FileSystemService } from "./FileSystemService"
|
|
17
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
18
|
+
import {
|
|
19
|
+
formatMarkdownDocument,
|
|
20
|
+
parseFrontmatterSync,
|
|
21
|
+
} from "../workbase/frontmatter"
|
|
22
|
+
import {
|
|
23
|
+
PhaseFrontmatter,
|
|
24
|
+
TaskFrontmatter,
|
|
25
|
+
type ClaimRecord,
|
|
26
|
+
type PhaseFrontmatter as PhaseData,
|
|
27
|
+
type TaskFrontmatter as TaskData,
|
|
28
|
+
} from "../workbase/schemas"
|
|
29
|
+
|
|
30
|
+
class ClaimError extends Data.TaggedError("ClaimError")<{
|
|
31
|
+
readonly message: string
|
|
32
|
+
readonly target?: string
|
|
33
|
+
}> {}
|
|
34
|
+
|
|
35
|
+
class RevisionConflictError extends Data.TaggedError("RevisionConflictError")<{
|
|
36
|
+
readonly message: string
|
|
37
|
+
readonly target: string
|
|
38
|
+
readonly expectedRevision: string
|
|
39
|
+
readonly actualRevision: string
|
|
40
|
+
readonly claim?: ClaimRecord
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
|
|
44
|
+
readonly message: string
|
|
45
|
+
readonly target: string
|
|
46
|
+
readonly currentRevision: string
|
|
47
|
+
readonly claim?: ClaimRecord
|
|
48
|
+
readonly legacyStatus?: "working" | "delegated"
|
|
49
|
+
}> {}
|
|
50
|
+
|
|
51
|
+
class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
|
|
52
|
+
readonly message: string
|
|
53
|
+
readonly target: string
|
|
54
|
+
readonly currentRevision: string
|
|
55
|
+
readonly sessionId: string
|
|
56
|
+
readonly claim?: ClaimRecord
|
|
57
|
+
}> {}
|
|
58
|
+
|
|
59
|
+
interface ClaimTarget {
|
|
60
|
+
readonly kind: "task" | "phase"
|
|
61
|
+
readonly taskId: string
|
|
62
|
+
readonly phaseId?: string
|
|
63
|
+
readonly path: string
|
|
64
|
+
readonly label: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ClaimInput {
|
|
68
|
+
readonly taskId: string
|
|
69
|
+
readonly phaseId?: string
|
|
70
|
+
readonly claimant: string
|
|
71
|
+
readonly runner: string
|
|
72
|
+
readonly sessionId: string
|
|
73
|
+
readonly revision: string
|
|
74
|
+
readonly expiresAt?: string
|
|
75
|
+
readonly now?: Date
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface OwnedClaimInput {
|
|
79
|
+
readonly taskId: string
|
|
80
|
+
readonly phaseId?: string
|
|
81
|
+
readonly sessionId: string
|
|
82
|
+
readonly revision: string
|
|
83
|
+
readonly now?: Date
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface FinishInput extends OwnedClaimInput {
|
|
87
|
+
readonly outcome: "done" | "dropped"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
91
|
+
type ExecutionData = SingleTaskData | PhaseData
|
|
92
|
+
|
|
93
|
+
const isTaggedClaimError = (
|
|
94
|
+
error: unknown,
|
|
95
|
+
): error is
|
|
96
|
+
| ClaimError
|
|
97
|
+
| RevisionConflictError
|
|
98
|
+
| ClaimConflictError
|
|
99
|
+
| ClaimOwnershipError =>
|
|
100
|
+
typeof error === "object" &&
|
|
101
|
+
error !== null &&
|
|
102
|
+
"_tag" in error &&
|
|
103
|
+
typeof error._tag === "string" &&
|
|
104
|
+
[
|
|
105
|
+
"ClaimError",
|
|
106
|
+
"RevisionConflictError",
|
|
107
|
+
"ClaimConflictError",
|
|
108
|
+
"ClaimOwnershipError",
|
|
109
|
+
].includes(error._tag)
|
|
110
|
+
|
|
111
|
+
const decodeExecution = (target: ClaimTarget, input: unknown) => {
|
|
112
|
+
const schema: Schema.Schema<any> =
|
|
113
|
+
target.kind === "task" ? TaskFrontmatter : PhaseFrontmatter
|
|
114
|
+
const result = Schema.decodeUnknownEither(schema, {
|
|
115
|
+
errors: "all",
|
|
116
|
+
onExcessProperty: "error",
|
|
117
|
+
})(input)
|
|
118
|
+
if (Either.isLeft(result)) {
|
|
119
|
+
throw new ClaimError({
|
|
120
|
+
target: target.label,
|
|
121
|
+
message: TreeFormatter.formatErrorSync(result.left),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
if (target.kind === "task" && "phases" in result.right) {
|
|
125
|
+
throw new ClaimError({
|
|
126
|
+
target: target.label,
|
|
127
|
+
message: `Task '${target.taskId}' has multiple phases; claim a phase instead`,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return result.right as ExecutionData
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const assertRevision = (revision: string) => {
|
|
134
|
+
if (!/^[a-f0-9]{64}$/.test(revision)) {
|
|
135
|
+
throw new ClaimError({
|
|
136
|
+
message: "Revision must be a 64-character SHA-256 hash",
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const assertIdentity = (label: string, value: string) => {
|
|
142
|
+
if (!value.trim())
|
|
143
|
+
throw new ClaimError({ message: `${label} must not be empty` })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const isIsoTimestamp = (value: string) =>
|
|
147
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
|
|
148
|
+
Number.isFinite(Date.parse(value))
|
|
149
|
+
|
|
150
|
+
const isUnexpired = (claim: ClaimRecord, now: Date) =>
|
|
151
|
+
claim.state === "active" &&
|
|
152
|
+
(claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
|
|
153
|
+
|
|
154
|
+
const acquireLock = async (path: string) => {
|
|
155
|
+
const lockPath = `${path}.claim.lock`
|
|
156
|
+
for (let attempt = 0; attempt < 1_750; attempt += 1) {
|
|
157
|
+
try {
|
|
158
|
+
const handle = await open(lockPath, "wx")
|
|
159
|
+
return { handle, lockPath }
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (
|
|
162
|
+
!(error instanceof Error) ||
|
|
163
|
+
!("code" in error) ||
|
|
164
|
+
error.code !== "EEXIST"
|
|
165
|
+
) {
|
|
166
|
+
throw error
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const lock = await stat(lockPath)
|
|
170
|
+
if (Date.now() - lock.mtimeMs > 30_000) await unlink(lockPath)
|
|
171
|
+
} catch {
|
|
172
|
+
// Another process released the lock between checks.
|
|
173
|
+
}
|
|
174
|
+
await Bun.sleep(20)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new ClaimError({ message: `Timed out waiting to update ${path}` })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const updateAtomically = async <T>(
|
|
181
|
+
target: ClaimTarget,
|
|
182
|
+
expectedRevision: string,
|
|
183
|
+
update: (
|
|
184
|
+
data: ExecutionData,
|
|
185
|
+
now: Date,
|
|
186
|
+
) => T & {
|
|
187
|
+
readonly data: ExecutionData
|
|
188
|
+
},
|
|
189
|
+
now: Date,
|
|
190
|
+
) => {
|
|
191
|
+
assertRevision(expectedRevision)
|
|
192
|
+
const { handle, lockPath } = await acquireLock(target.path)
|
|
193
|
+
let temporaryPath: string | undefined
|
|
194
|
+
try {
|
|
195
|
+
const content = await readFile(target.path, "utf8")
|
|
196
|
+
const actualRevision = documentRevision(content)
|
|
197
|
+
const parsed = parseFrontmatterSync(content, target.path)
|
|
198
|
+
const current = decodeExecution(target, parsed.data)
|
|
199
|
+
if (actualRevision !== expectedRevision) {
|
|
200
|
+
throw new RevisionConflictError({
|
|
201
|
+
target: target.label,
|
|
202
|
+
expectedRevision,
|
|
203
|
+
actualRevision,
|
|
204
|
+
claim: current.claim,
|
|
205
|
+
message: `Revision conflict for ${target.label}`,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const result = update(current, now)
|
|
209
|
+
const updatedContent = formatMarkdownDocument(result.data, parsed.body)
|
|
210
|
+
temporaryPath = join(
|
|
211
|
+
dirname(target.path),
|
|
212
|
+
`.${basename(target.path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
213
|
+
)
|
|
214
|
+
await writeFile(temporaryPath, updatedContent, { flag: "wx" })
|
|
215
|
+
await rename(temporaryPath, target.path)
|
|
216
|
+
temporaryPath = undefined
|
|
217
|
+
return {
|
|
218
|
+
...result,
|
|
219
|
+
target: target.label,
|
|
220
|
+
previousRevision: actualRevision,
|
|
221
|
+
revision: documentRevision(updatedContent),
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
|
|
225
|
+
await handle.close().catch(() => undefined)
|
|
226
|
+
await unlink(lockPath).catch(() => undefined)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const operation = <T>(run: () => Promise<T>) =>
|
|
231
|
+
Effect.tryPromise({
|
|
232
|
+
try: run,
|
|
233
|
+
catch: (error) =>
|
|
234
|
+
isTaggedClaimError(error)
|
|
235
|
+
? error
|
|
236
|
+
: new ClaimError({
|
|
237
|
+
message: error instanceof Error ? error.message : String(error),
|
|
238
|
+
}),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
export class ClaimService extends Effect.Service<ClaimService>()(
|
|
242
|
+
"ClaimService",
|
|
243
|
+
{
|
|
244
|
+
sync: () => ({
|
|
245
|
+
inspect: (
|
|
246
|
+
taskId: string,
|
|
247
|
+
phaseId?: string,
|
|
248
|
+
startPath: string = process.cwd(),
|
|
249
|
+
) =>
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
const fs = yield* FileSystemService
|
|
252
|
+
const workbase = yield* WorkbaseService
|
|
253
|
+
const tasks = yield* TaskService
|
|
254
|
+
const phases = yield* PhaseService
|
|
255
|
+
const root = yield* workbase.discover(startPath)
|
|
256
|
+
const task = yield* tasks.show(taskId, root)
|
|
257
|
+
const phase = phaseId
|
|
258
|
+
? yield* phases.show(task.id, phaseId, root)
|
|
259
|
+
: undefined
|
|
260
|
+
const target: ClaimTarget = phaseId
|
|
261
|
+
? {
|
|
262
|
+
kind: "phase",
|
|
263
|
+
taskId: task.id,
|
|
264
|
+
phaseId,
|
|
265
|
+
path: phase!.path,
|
|
266
|
+
label: `phase '${task.id}/${phaseId}'`,
|
|
267
|
+
}
|
|
268
|
+
: {
|
|
269
|
+
kind: "task",
|
|
270
|
+
taskId: task.id,
|
|
271
|
+
path: task.path,
|
|
272
|
+
label: `task '${task.id}'`,
|
|
273
|
+
}
|
|
274
|
+
if (!phaseId && "phases" in task.data) {
|
|
275
|
+
return yield* new ClaimError({
|
|
276
|
+
target: target.label,
|
|
277
|
+
message: `Task '${task.id}' has multiple phases; claim a phase instead`,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
const content = yield* fs.readFile(target.path)
|
|
281
|
+
const parsed = parseFrontmatterSync(content, target.path)
|
|
282
|
+
return {
|
|
283
|
+
target,
|
|
284
|
+
revision: documentRevision(content),
|
|
285
|
+
data: decodeExecution(target, parsed.data),
|
|
286
|
+
}
|
|
287
|
+
}),
|
|
288
|
+
|
|
289
|
+
claim: (input: ClaimInput, startPath: string = process.cwd()) =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
for (const [label, value] of [
|
|
292
|
+
["Claimant", input.claimant],
|
|
293
|
+
["Runner", input.runner],
|
|
294
|
+
["Session ID", input.sessionId],
|
|
295
|
+
] as const) {
|
|
296
|
+
assertIdentity(label, value)
|
|
297
|
+
}
|
|
298
|
+
const service = yield* ClaimService
|
|
299
|
+
const inspected = yield* service.inspect(
|
|
300
|
+
input.taskId,
|
|
301
|
+
input.phaseId,
|
|
302
|
+
startPath,
|
|
303
|
+
)
|
|
304
|
+
const now = input.now ?? new Date()
|
|
305
|
+
if (
|
|
306
|
+
input.expiresAt !== undefined &&
|
|
307
|
+
(!isIsoTimestamp(input.expiresAt) ||
|
|
308
|
+
Date.parse(input.expiresAt) <= now.getTime())
|
|
309
|
+
) {
|
|
310
|
+
return yield* new ClaimError({
|
|
311
|
+
message: "Claim expiry must be a future ISO-8601 timestamp",
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
return yield* operation(() =>
|
|
315
|
+
updateAtomically(
|
|
316
|
+
inspected.target,
|
|
317
|
+
input.revision,
|
|
318
|
+
(data, operationTime) => {
|
|
319
|
+
const replacingExpiredClaim =
|
|
320
|
+
data.claim?.state === "active" &&
|
|
321
|
+
!isUnexpired(data.claim, operationTime)
|
|
322
|
+
if (data.claim && isUnexpired(data.claim, operationTime)) {
|
|
323
|
+
throw new ClaimConflictError({
|
|
324
|
+
target: inspected.target.label,
|
|
325
|
+
currentRevision: input.revision,
|
|
326
|
+
claim: data.claim,
|
|
327
|
+
message: `${inspected.target.label} is claimed by '${data.claim.runner}'`,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
!data.claim &&
|
|
332
|
+
(data.status === "working" || data.status === "delegated")
|
|
333
|
+
) {
|
|
334
|
+
throw new ClaimConflictError({
|
|
335
|
+
target: inspected.target.label,
|
|
336
|
+
currentRevision: input.revision,
|
|
337
|
+
legacyStatus: data.status,
|
|
338
|
+
message: `${inspected.target.label} has legacy '${data.status}' ownership; reopen it before claiming`,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
if (data.status !== "open" && !replacingExpiredClaim) {
|
|
342
|
+
throw new ClaimError({
|
|
343
|
+
target: inspected.target.label,
|
|
344
|
+
message: `${inspected.target.label} cannot be claimed while ${data.status}`,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
const claim: ClaimRecord = {
|
|
348
|
+
claimant: input.claimant.trim(),
|
|
349
|
+
runner: input.runner.trim(),
|
|
350
|
+
sessionId: input.sessionId.trim(),
|
|
351
|
+
startedAt: operationTime.toISOString(),
|
|
352
|
+
targetRevision: input.revision,
|
|
353
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
354
|
+
state: "active",
|
|
355
|
+
}
|
|
356
|
+
return { data: { ...data, status: "working", claim }, claim }
|
|
357
|
+
},
|
|
358
|
+
now,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
}),
|
|
362
|
+
|
|
363
|
+
release: (input: OwnedClaimInput, startPath: string = process.cwd()) =>
|
|
364
|
+
Effect.gen(function* () {
|
|
365
|
+
assertIdentity("Session ID", input.sessionId)
|
|
366
|
+
const service = yield* ClaimService
|
|
367
|
+
const inspected = yield* service.inspect(
|
|
368
|
+
input.taskId,
|
|
369
|
+
input.phaseId,
|
|
370
|
+
startPath,
|
|
371
|
+
)
|
|
372
|
+
return yield* operation(() =>
|
|
373
|
+
updateAtomically(
|
|
374
|
+
inspected.target,
|
|
375
|
+
input.revision,
|
|
376
|
+
(data, now) => {
|
|
377
|
+
if (
|
|
378
|
+
!data.claim ||
|
|
379
|
+
data.claim.state !== "active" ||
|
|
380
|
+
data.claim.sessionId !== input.sessionId
|
|
381
|
+
) {
|
|
382
|
+
throw new ClaimOwnershipError({
|
|
383
|
+
target: inspected.target.label,
|
|
384
|
+
currentRevision: input.revision,
|
|
385
|
+
sessionId: input.sessionId,
|
|
386
|
+
claim: data.claim,
|
|
387
|
+
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
const claim: ClaimRecord = {
|
|
391
|
+
...data.claim,
|
|
392
|
+
state: "released",
|
|
393
|
+
releasedAt: now.toISOString(),
|
|
394
|
+
}
|
|
395
|
+
return { data: { ...data, status: "open", claim }, claim }
|
|
396
|
+
},
|
|
397
|
+
input.now ?? new Date(),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
}),
|
|
401
|
+
|
|
402
|
+
finish: (input: FinishInput, startPath: string = process.cwd()) =>
|
|
403
|
+
Effect.gen(function* () {
|
|
404
|
+
assertIdentity("Session ID", input.sessionId)
|
|
405
|
+
const service = yield* ClaimService
|
|
406
|
+
const inspected = yield* service.inspect(
|
|
407
|
+
input.taskId,
|
|
408
|
+
input.phaseId,
|
|
409
|
+
startPath,
|
|
410
|
+
)
|
|
411
|
+
return yield* operation(() =>
|
|
412
|
+
updateAtomically(
|
|
413
|
+
inspected.target,
|
|
414
|
+
input.revision,
|
|
415
|
+
(data, now) => {
|
|
416
|
+
if (
|
|
417
|
+
!data.claim ||
|
|
418
|
+
data.claim.state !== "active" ||
|
|
419
|
+
data.claim.sessionId !== input.sessionId
|
|
420
|
+
) {
|
|
421
|
+
throw new ClaimOwnershipError({
|
|
422
|
+
target: inspected.target.label,
|
|
423
|
+
currentRevision: input.revision,
|
|
424
|
+
sessionId: input.sessionId,
|
|
425
|
+
claim: data.claim,
|
|
426
|
+
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
const claim: ClaimRecord = {
|
|
430
|
+
...data.claim,
|
|
431
|
+
state: "finished",
|
|
432
|
+
finishedAt: now.toISOString(),
|
|
433
|
+
outcome: input.outcome,
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
data: { ...data, status: input.outcome, claim },
|
|
437
|
+
claim,
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
input.now ?? new Date(),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
}),
|
|
444
|
+
}),
|
|
445
|
+
},
|
|
446
|
+
) {}
|