@markjaquith/agency 3.2.2 → 3.2.4
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 -62
- package/cli-main.ts +20 -83
- 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 +34 -132
- package/src/cli-parser.ts +45 -102
- package/src/cli.test.ts +60 -117
- package/src/commands/act.ts +2 -6
- 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 +0 -25
- package/src/protocol.ts +0 -11
- 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/GraphMutationService.ts +0 -26
- package/src/services/GraphService.test.ts +1 -1
- package/src/services/IntegrationService.test.ts +7 -4
- package/src/services/LifecycleTransaction.ts +5 -1
- package/src/services/PhaseService.ts +1 -8
- 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 +89 -5
- package/src/usage-log.ts +139 -31
- package/src/workbase/AGENTS.md +9 -15
- package/src/workbase/agent-command.test.ts +1 -3
- package/src/workbase/agent-command.ts +1 -6
- package/src/workbase/document-revision.ts +0 -4
- package/src/workbase/opencode-plugin-file.ts +1 -0
- 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
|
@@ -1,608 +0,0 @@
|
|
|
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, relative } from "node:path"
|
|
13
|
-
import { WorkbaseService } from "./WorkbaseService"
|
|
14
|
-
import {
|
|
15
|
-
documentRevision,
|
|
16
|
-
isDocumentRevision,
|
|
17
|
-
RevisionConflictError,
|
|
18
|
-
} from "../workbase/document-revision"
|
|
19
|
-
import {
|
|
20
|
-
formatMarkdownDocument,
|
|
21
|
-
parseFrontmatterSync,
|
|
22
|
-
} from "../workbase/frontmatter"
|
|
23
|
-
import {
|
|
24
|
-
PhaseFrontmatter,
|
|
25
|
-
TaskFrontmatter,
|
|
26
|
-
type ClaimRecord,
|
|
27
|
-
type PullRequestRecord,
|
|
28
|
-
type PhaseFrontmatter as PhaseData,
|
|
29
|
-
type TaskFrontmatter as TaskData,
|
|
30
|
-
} from "../workbase/schemas"
|
|
31
|
-
import {
|
|
32
|
-
buildNonPrCompletion,
|
|
33
|
-
type NonPrCompletionInput,
|
|
34
|
-
} from "../workbase/completion"
|
|
35
|
-
|
|
36
|
-
class ClaimError extends Data.TaggedError("ClaimError")<{
|
|
37
|
-
readonly message: string
|
|
38
|
-
readonly target?: string
|
|
39
|
-
}> {}
|
|
40
|
-
|
|
41
|
-
class ClaimConflictError extends Data.TaggedError("ClaimConflictError")<{
|
|
42
|
-
readonly message: string
|
|
43
|
-
readonly target: string
|
|
44
|
-
readonly currentRevision: string
|
|
45
|
-
readonly claim?: ClaimRecord
|
|
46
|
-
readonly legacyStatus?: "working" | "delegated"
|
|
47
|
-
}> {}
|
|
48
|
-
|
|
49
|
-
class ClaimOwnershipError extends Data.TaggedError("ClaimOwnershipError")<{
|
|
50
|
-
readonly message: string
|
|
51
|
-
readonly target: string
|
|
52
|
-
readonly currentRevision: string
|
|
53
|
-
readonly sessionId: string
|
|
54
|
-
readonly claim?: ClaimRecord
|
|
55
|
-
}> {}
|
|
56
|
-
|
|
57
|
-
interface ClaimTarget {
|
|
58
|
-
readonly kind: "task" | "phase"
|
|
59
|
-
readonly root: string
|
|
60
|
-
readonly taskId: string
|
|
61
|
-
readonly phaseId?: string
|
|
62
|
-
readonly path: string
|
|
63
|
-
readonly label: string
|
|
64
|
-
}
|
|
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
|
-
|
|
107
|
-
interface ClaimInput {
|
|
108
|
-
readonly taskId: string
|
|
109
|
-
readonly phaseId?: string
|
|
110
|
-
readonly claimant: string
|
|
111
|
-
readonly agent: string
|
|
112
|
-
readonly sessionId: string
|
|
113
|
-
readonly revision: string
|
|
114
|
-
readonly expiresAt?: string
|
|
115
|
-
readonly now?: Date
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
interface OwnedClaimInput {
|
|
119
|
-
readonly taskId: string
|
|
120
|
-
readonly phaseId?: string
|
|
121
|
-
readonly sessionId: string
|
|
122
|
-
readonly revision: string
|
|
123
|
-
readonly now?: Date
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
interface FinishInput extends OwnedClaimInput {
|
|
127
|
-
readonly outcome: "done" | "dropped"
|
|
128
|
-
readonly nonPrCompletion?: NonPrCompletionInput
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
interface ExpireClaimInput {
|
|
132
|
-
readonly taskId: string
|
|
133
|
-
readonly phaseId?: string
|
|
134
|
-
readonly revision: string
|
|
135
|
-
readonly now?: Date
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
interface ReconcileInput {
|
|
139
|
-
readonly taskId: string
|
|
140
|
-
readonly phaseId?: string
|
|
141
|
-
readonly revision: string
|
|
142
|
-
readonly pr?: string | PullRequestRecord
|
|
143
|
-
readonly status?: "done"
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
147
|
-
|
|
148
|
-
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
149
|
-
type ReviewTaskData = Extract<TaskData, { readonly review: unknown }>
|
|
150
|
-
type ExecutionData = SingleTaskData | ReviewTaskData | PhaseData
|
|
151
|
-
|
|
152
|
-
const isTaggedClaimError = (
|
|
153
|
-
error: unknown,
|
|
154
|
-
): error is
|
|
155
|
-
| ClaimError
|
|
156
|
-
| RevisionConflictError
|
|
157
|
-
| ClaimConflictError
|
|
158
|
-
| ClaimOwnershipError =>
|
|
159
|
-
typeof error === "object" &&
|
|
160
|
-
error !== null &&
|
|
161
|
-
"_tag" in error &&
|
|
162
|
-
typeof error._tag === "string" &&
|
|
163
|
-
[
|
|
164
|
-
"ClaimError",
|
|
165
|
-
"RevisionConflictError",
|
|
166
|
-
"ClaimConflictError",
|
|
167
|
-
"ClaimOwnershipError",
|
|
168
|
-
].includes(error._tag)
|
|
169
|
-
|
|
170
|
-
const decodeExecution = (target: ClaimTarget, input: unknown) => {
|
|
171
|
-
const schema: Schema.Schema<any> =
|
|
172
|
-
target.kind === "task" ? TaskFrontmatter : PhaseFrontmatter
|
|
173
|
-
const result = Schema.decodeUnknownEither(schema, {
|
|
174
|
-
errors: "all",
|
|
175
|
-
onExcessProperty: "error",
|
|
176
|
-
})(input)
|
|
177
|
-
if (Either.isLeft(result)) {
|
|
178
|
-
throw new ClaimError({
|
|
179
|
-
target: target.label,
|
|
180
|
-
message: TreeFormatter.formatErrorSync(result.left),
|
|
181
|
-
})
|
|
182
|
-
}
|
|
183
|
-
if (target.kind === "task" && "phases" in result.right) {
|
|
184
|
-
throw new ClaimError({
|
|
185
|
-
target: target.label,
|
|
186
|
-
message: `Task '${target.taskId}' has multiple phases; claim a phase instead`,
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
return result.right as ExecutionData
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const assertRevision = (revision: string) => {
|
|
193
|
-
if (!isDocumentRevision(revision)) {
|
|
194
|
-
throw new ClaimError({
|
|
195
|
-
message: "Revision must be a 64-character SHA-256 hash",
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const assertIdentity = (label: string, value: string) => {
|
|
201
|
-
if (!value.trim())
|
|
202
|
-
throw new ClaimError({ message: `${label} must not be empty` })
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const isIsoTimestamp = (value: string) =>
|
|
206
|
-
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) &&
|
|
207
|
-
Number.isFinite(Date.parse(value))
|
|
208
|
-
|
|
209
|
-
const isUnexpired = (claim: ClaimRecord, now: Date) =>
|
|
210
|
-
claim.state === "active" &&
|
|
211
|
-
(claim.expiresAt === undefined || Date.parse(claim.expiresAt) > now.getTime())
|
|
212
|
-
|
|
213
|
-
const acquireLock = async (lockPath: string, label: string) => {
|
|
214
|
-
for (let attempt = 0; attempt < 1_750; attempt += 1) {
|
|
215
|
-
try {
|
|
216
|
-
const handle = await open(lockPath, "wx")
|
|
217
|
-
return { handle, lockPath }
|
|
218
|
-
} catch (error) {
|
|
219
|
-
if (
|
|
220
|
-
!(error instanceof Error) ||
|
|
221
|
-
!("code" in error) ||
|
|
222
|
-
error.code !== "EEXIST"
|
|
223
|
-
) {
|
|
224
|
-
throw error
|
|
225
|
-
}
|
|
226
|
-
try {
|
|
227
|
-
const lock = await stat(lockPath)
|
|
228
|
-
if (Date.now() - lock.mtimeMs > 30_000) await unlink(lockPath)
|
|
229
|
-
} catch {
|
|
230
|
-
// Another process released the lock between checks.
|
|
231
|
-
}
|
|
232
|
-
await Bun.sleep(20)
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
throw new ClaimError({ message: `Timed out waiting to update ${label}` })
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const updateAtomically = async <T>(
|
|
239
|
-
target: ClaimTarget,
|
|
240
|
-
expectedRevision: string,
|
|
241
|
-
update: (
|
|
242
|
-
data: ExecutionData,
|
|
243
|
-
now: Date,
|
|
244
|
-
) => T & {
|
|
245
|
-
readonly data: ExecutionData
|
|
246
|
-
},
|
|
247
|
-
now: Date,
|
|
248
|
-
) => {
|
|
249
|
-
assertRevision(expectedRevision)
|
|
250
|
-
const graphLock = await acquireLock(
|
|
251
|
-
join(target.root, ".agency-graph-mutation.lock"),
|
|
252
|
-
target.root,
|
|
253
|
-
)
|
|
254
|
-
let documentLock: Awaited<ReturnType<typeof acquireLock>> | undefined
|
|
255
|
-
let temporaryPath: string | undefined
|
|
256
|
-
try {
|
|
257
|
-
documentLock = await acquireLock(`${target.path}.claim.lock`, target.path)
|
|
258
|
-
const content = await readFile(target.path, "utf8")
|
|
259
|
-
const currentRevision = documentRevision(content)
|
|
260
|
-
const parsed = parseFrontmatterSync(content, target.path)
|
|
261
|
-
const current = decodeExecution(target, parsed.data)
|
|
262
|
-
if (currentRevision !== expectedRevision) {
|
|
263
|
-
throw new RevisionConflictError({
|
|
264
|
-
path: relative(target.root, target.path),
|
|
265
|
-
target: target.label,
|
|
266
|
-
expectedRevision,
|
|
267
|
-
currentRevision,
|
|
268
|
-
claim: current.claim,
|
|
269
|
-
message: `Revision conflict for ${target.label}`,
|
|
270
|
-
})
|
|
271
|
-
}
|
|
272
|
-
const result = update(current, now)
|
|
273
|
-
const updatedContent = formatMarkdownDocument(result.data, parsed.body)
|
|
274
|
-
temporaryPath = join(
|
|
275
|
-
dirname(target.path),
|
|
276
|
-
`.${basename(target.path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
277
|
-
)
|
|
278
|
-
await writeFile(temporaryPath, updatedContent, { flag: "wx" })
|
|
279
|
-
await rename(temporaryPath, target.path)
|
|
280
|
-
temporaryPath = undefined
|
|
281
|
-
return {
|
|
282
|
-
...result,
|
|
283
|
-
target: target.label,
|
|
284
|
-
previousRevision: currentRevision,
|
|
285
|
-
revision: documentRevision(updatedContent),
|
|
286
|
-
}
|
|
287
|
-
} finally {
|
|
288
|
-
if (temporaryPath) await unlink(temporaryPath).catch(() => undefined)
|
|
289
|
-
await documentLock?.handle.close().catch(() => undefined)
|
|
290
|
-
if (documentLock) await unlink(documentLock.lockPath).catch(() => undefined)
|
|
291
|
-
await graphLock.handle.close().catch(() => undefined)
|
|
292
|
-
await unlink(graphLock.lockPath).catch(() => undefined)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const operation = <T>(run: () => Promise<T>) =>
|
|
297
|
-
Effect.tryPromise({
|
|
298
|
-
try: run,
|
|
299
|
-
catch: (error) =>
|
|
300
|
-
isTaggedClaimError(error)
|
|
301
|
-
? error
|
|
302
|
-
: new ClaimError({
|
|
303
|
-
message: error instanceof Error ? error.message : String(error),
|
|
304
|
-
}),
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
export class ClaimService extends Effect.Service<ClaimService>()(
|
|
308
|
-
"ClaimService",
|
|
309
|
-
{
|
|
310
|
-
sync: () => ({
|
|
311
|
-
inspect: (
|
|
312
|
-
taskId: string,
|
|
313
|
-
phaseId?: string,
|
|
314
|
-
startPath: string = process.cwd(),
|
|
315
|
-
) =>
|
|
316
|
-
Effect.gen(function* () {
|
|
317
|
-
const workbase = yield* WorkbaseService
|
|
318
|
-
const root = yield* workbase.discover(startPath)
|
|
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) {
|
|
326
|
-
return yield* new ClaimError({
|
|
327
|
-
target: target.label,
|
|
328
|
-
message: `Task '${taskId}' has multiple phases; claim a phase instead`,
|
|
329
|
-
})
|
|
330
|
-
}
|
|
331
|
-
return {
|
|
332
|
-
target,
|
|
333
|
-
revision: documentRevision(content),
|
|
334
|
-
data,
|
|
335
|
-
}
|
|
336
|
-
}),
|
|
337
|
-
|
|
338
|
-
expire: (input: ExpireClaimInput, startPath: string = process.cwd()) =>
|
|
339
|
-
Effect.gen(function* () {
|
|
340
|
-
const service = yield* ClaimService
|
|
341
|
-
const inspected = yield* service.inspect(
|
|
342
|
-
input.taskId,
|
|
343
|
-
input.phaseId,
|
|
344
|
-
startPath,
|
|
345
|
-
)
|
|
346
|
-
return yield* operation(() =>
|
|
347
|
-
updateAtomically(
|
|
348
|
-
inspected.target,
|
|
349
|
-
input.revision,
|
|
350
|
-
(data, now) => {
|
|
351
|
-
if (
|
|
352
|
-
data.claim?.state !== "active" ||
|
|
353
|
-
data.claim.expiresAt === undefined ||
|
|
354
|
-
Date.parse(data.claim.expiresAt) > now.getTime() ||
|
|
355
|
-
(data.status !== "working" && data.status !== "delegated")
|
|
356
|
-
) {
|
|
357
|
-
throw new ClaimError({
|
|
358
|
-
target: inspected.target.label,
|
|
359
|
-
message: `${inspected.target.label} does not have an expired active claim`,
|
|
360
|
-
})
|
|
361
|
-
}
|
|
362
|
-
const claim: ClaimRecord = {
|
|
363
|
-
...data.claim,
|
|
364
|
-
state: "released",
|
|
365
|
-
releasedAt: now.toISOString(),
|
|
366
|
-
}
|
|
367
|
-
return { data: { ...data, status: "open", claim }, claim }
|
|
368
|
-
},
|
|
369
|
-
input.now ?? new Date(),
|
|
370
|
-
),
|
|
371
|
-
)
|
|
372
|
-
}),
|
|
373
|
-
|
|
374
|
-
reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
|
|
375
|
-
Effect.gen(function* () {
|
|
376
|
-
if (
|
|
377
|
-
typeof input.pr === "string" &&
|
|
378
|
-
input.pr !== undefined &&
|
|
379
|
-
!PR_URL.test(input.pr)
|
|
380
|
-
) {
|
|
381
|
-
return yield* new ClaimError({
|
|
382
|
-
message: `Invalid GitHub pull request URL: ${input.pr}`,
|
|
383
|
-
})
|
|
384
|
-
}
|
|
385
|
-
const service = yield* ClaimService
|
|
386
|
-
const inspected = yield* service.inspect(
|
|
387
|
-
input.taskId,
|
|
388
|
-
input.phaseId,
|
|
389
|
-
startPath,
|
|
390
|
-
)
|
|
391
|
-
return yield* operation(() =>
|
|
392
|
-
updateAtomically(
|
|
393
|
-
inspected.target,
|
|
394
|
-
input.revision,
|
|
395
|
-
(data) => {
|
|
396
|
-
if (input.status === "done" && data.claim?.state === "active") {
|
|
397
|
-
throw new ClaimConflictError({
|
|
398
|
-
target: inspected.target.label,
|
|
399
|
-
currentRevision: input.revision,
|
|
400
|
-
claim: data.claim,
|
|
401
|
-
message: `${inspected.target.label} has an active claim`,
|
|
402
|
-
})
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
data: {
|
|
406
|
-
...data,
|
|
407
|
-
...(input.pr !== undefined ? { pr: input.pr } : {}),
|
|
408
|
-
...(input.status !== undefined
|
|
409
|
-
? { status: input.status }
|
|
410
|
-
: {}),
|
|
411
|
-
},
|
|
412
|
-
}
|
|
413
|
-
},
|
|
414
|
-
new Date(),
|
|
415
|
-
),
|
|
416
|
-
)
|
|
417
|
-
}),
|
|
418
|
-
|
|
419
|
-
claim: (input: ClaimInput, startPath: string = process.cwd()) =>
|
|
420
|
-
Effect.gen(function* () {
|
|
421
|
-
for (const [label, value] of [
|
|
422
|
-
["Claimant", input.claimant],
|
|
423
|
-
["Agent", input.agent],
|
|
424
|
-
["Session ID", input.sessionId],
|
|
425
|
-
] as const) {
|
|
426
|
-
assertIdentity(label, value)
|
|
427
|
-
}
|
|
428
|
-
const service = yield* ClaimService
|
|
429
|
-
const inspected = yield* service.inspect(
|
|
430
|
-
input.taskId,
|
|
431
|
-
input.phaseId,
|
|
432
|
-
startPath,
|
|
433
|
-
)
|
|
434
|
-
const now = input.now ?? new Date()
|
|
435
|
-
if (
|
|
436
|
-
input.expiresAt !== undefined &&
|
|
437
|
-
(!isIsoTimestamp(input.expiresAt) ||
|
|
438
|
-
Date.parse(input.expiresAt) <= now.getTime())
|
|
439
|
-
) {
|
|
440
|
-
return yield* new ClaimError({
|
|
441
|
-
message: "Claim expiry must be a future ISO-8601 timestamp",
|
|
442
|
-
})
|
|
443
|
-
}
|
|
444
|
-
return yield* operation(() =>
|
|
445
|
-
updateAtomically(
|
|
446
|
-
inspected.target,
|
|
447
|
-
input.revision,
|
|
448
|
-
(data, operationTime) => {
|
|
449
|
-
const replacingExpiredClaim =
|
|
450
|
-
data.claim?.state === "active" &&
|
|
451
|
-
!isUnexpired(data.claim, operationTime)
|
|
452
|
-
if (data.claim && isUnexpired(data.claim, operationTime)) {
|
|
453
|
-
throw new ClaimConflictError({
|
|
454
|
-
target: inspected.target.label,
|
|
455
|
-
currentRevision: input.revision,
|
|
456
|
-
claim: data.claim,
|
|
457
|
-
message: `${inspected.target.label} is claimed by '${data.claim.agent}'`,
|
|
458
|
-
})
|
|
459
|
-
}
|
|
460
|
-
if (
|
|
461
|
-
!data.claim &&
|
|
462
|
-
(data.status === "working" || data.status === "delegated")
|
|
463
|
-
) {
|
|
464
|
-
throw new ClaimConflictError({
|
|
465
|
-
target: inspected.target.label,
|
|
466
|
-
currentRevision: input.revision,
|
|
467
|
-
legacyStatus: data.status,
|
|
468
|
-
message: `${inspected.target.label} has legacy '${data.status}' ownership; reopen it before claiming`,
|
|
469
|
-
})
|
|
470
|
-
}
|
|
471
|
-
if (data.status !== "open" && !replacingExpiredClaim) {
|
|
472
|
-
throw new ClaimError({
|
|
473
|
-
target: inspected.target.label,
|
|
474
|
-
message: `${inspected.target.label} cannot be claimed while ${data.status}`,
|
|
475
|
-
})
|
|
476
|
-
}
|
|
477
|
-
const claim: ClaimRecord = {
|
|
478
|
-
claimant: input.claimant.trim(),
|
|
479
|
-
agent: input.agent.trim(),
|
|
480
|
-
sessionId: input.sessionId.trim(),
|
|
481
|
-
startedAt: operationTime.toISOString(),
|
|
482
|
-
targetRevision: input.revision,
|
|
483
|
-
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
484
|
-
state: "active",
|
|
485
|
-
}
|
|
486
|
-
return { data: { ...data, status: "working", claim }, claim }
|
|
487
|
-
},
|
|
488
|
-
now,
|
|
489
|
-
),
|
|
490
|
-
)
|
|
491
|
-
}),
|
|
492
|
-
|
|
493
|
-
release: (input: OwnedClaimInput, startPath: string = process.cwd()) =>
|
|
494
|
-
Effect.gen(function* () {
|
|
495
|
-
assertIdentity("Session ID", input.sessionId)
|
|
496
|
-
const service = yield* ClaimService
|
|
497
|
-
const inspected = yield* service.inspect(
|
|
498
|
-
input.taskId,
|
|
499
|
-
input.phaseId,
|
|
500
|
-
startPath,
|
|
501
|
-
)
|
|
502
|
-
return yield* operation(() =>
|
|
503
|
-
updateAtomically(
|
|
504
|
-
inspected.target,
|
|
505
|
-
input.revision,
|
|
506
|
-
(data, now) => {
|
|
507
|
-
if (
|
|
508
|
-
!data.claim ||
|
|
509
|
-
data.claim.state !== "active" ||
|
|
510
|
-
data.claim.sessionId !== input.sessionId
|
|
511
|
-
) {
|
|
512
|
-
throw new ClaimOwnershipError({
|
|
513
|
-
target: inspected.target.label,
|
|
514
|
-
currentRevision: input.revision,
|
|
515
|
-
sessionId: input.sessionId,
|
|
516
|
-
claim: data.claim,
|
|
517
|
-
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
518
|
-
})
|
|
519
|
-
}
|
|
520
|
-
const claim: ClaimRecord = {
|
|
521
|
-
...data.claim,
|
|
522
|
-
state: "released",
|
|
523
|
-
releasedAt: now.toISOString(),
|
|
524
|
-
}
|
|
525
|
-
return { data: { ...data, status: "open", claim }, claim }
|
|
526
|
-
},
|
|
527
|
-
input.now ?? new Date(),
|
|
528
|
-
),
|
|
529
|
-
)
|
|
530
|
-
}),
|
|
531
|
-
|
|
532
|
-
finish: (input: FinishInput, startPath: string = process.cwd()) =>
|
|
533
|
-
Effect.gen(function* () {
|
|
534
|
-
assertIdentity("Session ID", input.sessionId)
|
|
535
|
-
if (input.nonPrCompletion && input.outcome !== "done") {
|
|
536
|
-
return yield* new ClaimError({
|
|
537
|
-
message: "Non-PR completion is valid only with a done outcome",
|
|
538
|
-
})
|
|
539
|
-
}
|
|
540
|
-
const service = yield* ClaimService
|
|
541
|
-
const inspected = yield* service.inspect(
|
|
542
|
-
input.taskId,
|
|
543
|
-
input.phaseId,
|
|
544
|
-
startPath,
|
|
545
|
-
)
|
|
546
|
-
return yield* operation(() =>
|
|
547
|
-
updateAtomically(
|
|
548
|
-
inspected.target,
|
|
549
|
-
input.revision,
|
|
550
|
-
(data, now) => {
|
|
551
|
-
if (
|
|
552
|
-
!data.claim ||
|
|
553
|
-
data.claim.state !== "active" ||
|
|
554
|
-
data.claim.sessionId !== input.sessionId
|
|
555
|
-
) {
|
|
556
|
-
throw new ClaimOwnershipError({
|
|
557
|
-
target: inspected.target.label,
|
|
558
|
-
currentRevision: input.revision,
|
|
559
|
-
sessionId: input.sessionId,
|
|
560
|
-
claim: data.claim,
|
|
561
|
-
message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
|
|
562
|
-
})
|
|
563
|
-
}
|
|
564
|
-
if (input.nonPrCompletion && "pr" in data && data.pr !== null) {
|
|
565
|
-
throw new ClaimError({
|
|
566
|
-
target: inspected.target.label,
|
|
567
|
-
message:
|
|
568
|
-
"Cannot complete without a pull request while an authoritative pull request is recorded",
|
|
569
|
-
})
|
|
570
|
-
}
|
|
571
|
-
const completionResult = input.nonPrCompletion
|
|
572
|
-
? buildNonPrCompletion(input.nonPrCompletion, now)
|
|
573
|
-
: undefined
|
|
574
|
-
if (completionResult && "error" in completionResult) {
|
|
575
|
-
throw new ClaimError({
|
|
576
|
-
target: inspected.target.label,
|
|
577
|
-
message: completionResult.error,
|
|
578
|
-
})
|
|
579
|
-
}
|
|
580
|
-
const claim: ClaimRecord = {
|
|
581
|
-
...data.claim,
|
|
582
|
-
state: "finished",
|
|
583
|
-
finishedAt: now.toISOString(),
|
|
584
|
-
outcome: input.outcome,
|
|
585
|
-
}
|
|
586
|
-
return {
|
|
587
|
-
data: {
|
|
588
|
-
...data,
|
|
589
|
-
status: completionResult
|
|
590
|
-
? "done"
|
|
591
|
-
: input.outcome === "done"
|
|
592
|
-
? "working"
|
|
593
|
-
: "dropped",
|
|
594
|
-
claim,
|
|
595
|
-
...(completionResult
|
|
596
|
-
? { completion: completionResult.value }
|
|
597
|
-
: {}),
|
|
598
|
-
},
|
|
599
|
-
claim,
|
|
600
|
-
}
|
|
601
|
-
},
|
|
602
|
-
input.now ?? new Date(),
|
|
603
|
-
),
|
|
604
|
-
)
|
|
605
|
-
}),
|
|
606
|
-
}),
|
|
607
|
-
},
|
|
608
|
-
) {}
|