@markjaquith/agency 2.48.1 → 2.50.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.
Files changed (44) hide show
  1. package/README.md +40 -9
  2. package/cli.ts +32 -0
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +141 -38
  5. package/src/cli-parser.test.ts +120 -0
  6. package/src/cli-parser.ts +122 -7
  7. package/src/cli.test.ts +71 -0
  8. package/src/commands/claim.ts +26 -2
  9. package/src/commands/phase.ts +23 -2
  10. package/src/commands/review.ts +38 -0
  11. package/src/commands/task.ts +49 -7
  12. package/src/commands/work.test.ts +2 -0
  13. package/src/commands/work.ts +2 -2
  14. package/src/graph-schema.ts +35 -13
  15. package/src/protocol.ts +1 -0
  16. package/src/services/ArchiveService.test.ts +2 -2
  17. package/src/services/ArchiveService.ts +1 -0
  18. package/src/services/ClaimService.test.ts +59 -0
  19. package/src/services/ClaimService.ts +36 -2
  20. package/src/services/ContextService.ts +56 -4
  21. package/src/services/DoctorService.ts +45 -1
  22. package/src/services/GraphMutationService.ts +21 -0
  23. package/src/services/GraphService.ts +118 -54
  24. package/src/services/IntegrationService.test.ts +4 -1
  25. package/src/services/PhaseService.ts +55 -3
  26. package/src/services/PullRequestService.test.ts +22 -2
  27. package/src/services/PullRequestService.ts +44 -3
  28. package/src/services/ReadinessService.ts +7 -3
  29. package/src/services/ReviewService.test.ts +472 -0
  30. package/src/services/ReviewService.ts +404 -0
  31. package/src/services/SyncService.test.ts +68 -2
  32. package/src/services/SyncService.ts +123 -6
  33. package/src/services/TaskPhaseService.test.ts +182 -0
  34. package/src/services/TaskService.ts +128 -11
  35. package/src/services/WorkbaseService.test.ts +58 -0
  36. package/src/services/WorkbaseService.ts +22 -1
  37. package/src/services/WorktreeService.test.ts +16 -16
  38. package/src/services/WorktreeService.ts +76 -40
  39. package/src/test-utils.ts +2 -0
  40. package/src/work-view.ts +14 -6
  41. package/src/workbase/AGENTS.md +7 -2
  42. package/src/workbase/completion.ts +28 -0
  43. package/src/workbase/schemas.test.ts +57 -0
  44. package/src/workbase/schemas.ts +65 -0
@@ -0,0 +1,404 @@
1
+ import { Data, Effect, Layer } from "effect"
2
+ import { randomUUID } from "node:crypto"
3
+ import { lstat, mkdir } from "node:fs/promises"
4
+ import { dirname } from "node:path"
5
+ import { FileSystemService } from "./FileSystemService"
6
+ import { PhaseService } from "./PhaseService"
7
+ import { RepositoryService } from "./RepositoryService"
8
+ import { TaskService } from "./TaskService"
9
+ import { WorkbaseService } from "./WorkbaseService"
10
+ import {
11
+ WorktreeService,
12
+ type WorktreeRemovalSnapshot,
13
+ } from "./WorktreeService"
14
+ import { withWorktreeLocks } from "./WorktreeLock"
15
+ import {
16
+ documentWriteStep,
17
+ runLifecycleTransaction,
18
+ type TransactionStep,
19
+ } from "./LifecycleTransaction"
20
+ import { RevisionConflictError } from "../workbase/document-revision"
21
+ import {
22
+ formatMarkdownDocument,
23
+ parseFrontmatter,
24
+ } from "../workbase/frontmatter"
25
+ import type { ReviewRecord, ReviewSource } from "../workbase/schemas"
26
+
27
+ class ReviewError extends Data.TaggedError("ReviewError")<{
28
+ readonly message: string
29
+ readonly cause?: unknown
30
+ }> {}
31
+
32
+ const githubRepository = (remote: string) => {
33
+ const match = remote
34
+ .replace(/\.git\/?$/, "")
35
+ .match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i)
36
+ return match?.[1]?.toLowerCase() ?? null
37
+ }
38
+
39
+ const pinRef = (taskId: string) =>
40
+ `refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
41
+
42
+ const runGit = async (args: readonly string[]) => {
43
+ const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
44
+ const [exitCode, stdout, stderr] = await Promise.all([
45
+ child.exited,
46
+ new Response(child.stdout).text(),
47
+ new Response(child.stderr).text(),
48
+ ])
49
+ if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
50
+ return stdout.trim()
51
+ }
52
+
53
+ const WorktreeLayer = Layer.mergeAll(
54
+ FileSystemService.Default,
55
+ WorkbaseService.Default,
56
+ TaskService.Default,
57
+ PhaseService.Default,
58
+ WorktreeService.Default,
59
+ )
60
+
61
+ const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
62
+ Effect.runPromise(
63
+ effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
64
+ )
65
+
66
+ const restoreSnapshots = async (
67
+ snapshots: readonly WorktreeRemovalSnapshot[],
68
+ ) => {
69
+ for (const snapshot of snapshots) {
70
+ try {
71
+ await lstat(snapshot.path)
72
+ continue
73
+ } catch {}
74
+ await mkdir(dirname(snapshot.path), { recursive: true })
75
+ await runGit(
76
+ snapshot.branch
77
+ ? [
78
+ "git",
79
+ "-C",
80
+ snapshot.repositoryPath,
81
+ "worktree",
82
+ "add",
83
+ snapshot.path,
84
+ snapshot.branch,
85
+ ]
86
+ : [
87
+ "git",
88
+ "-C",
89
+ snapshot.repositoryPath,
90
+ "worktree",
91
+ "add",
92
+ "--detach",
93
+ snapshot.path,
94
+ snapshot.head,
95
+ ],
96
+ )
97
+ }
98
+ }
99
+
100
+ const normalizeBranch = (input: string, repositoryPath: string) =>
101
+ Effect.gen(function* () {
102
+ const fs = yield* FileSystemService
103
+ if (
104
+ input.startsWith("refs/") &&
105
+ !input.startsWith("refs/heads/") &&
106
+ !input.startsWith("refs/remotes/origin/")
107
+ ) {
108
+ return yield* new ReviewError({
109
+ message: `Invalid review branch '${input}'`,
110
+ })
111
+ }
112
+ const name = input
113
+ .replace(/^refs\/remotes\/origin\//, "")
114
+ .replace(/^origin\//, "")
115
+ .replace(/^refs\/heads\//, "")
116
+ if (
117
+ !name ||
118
+ name === "HEAD" ||
119
+ name.startsWith("-") ||
120
+ /[\s:*?\[\\^~]/.test(name) ||
121
+ name.includes("..") ||
122
+ name.includes("@{")
123
+ ) {
124
+ return yield* new ReviewError({
125
+ message: `Invalid review branch '${input}'`,
126
+ })
127
+ }
128
+ const checked = yield* fs.runCommand(
129
+ ["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
130
+ { captureOutput: true },
131
+ )
132
+ if (checked.exitCode !== 0) {
133
+ return yield* new ReviewError({
134
+ message: `Invalid review branch '${input}'`,
135
+ })
136
+ }
137
+ return `refs/heads/${name}`
138
+ })
139
+
140
+ const fetchCommit = (repoPath: string, sourceRef: string) =>
141
+ Effect.gen(function* () {
142
+ const fs = yield* FileSystemService
143
+ const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
144
+ const fetched = yield* fs.runCommand(
145
+ [
146
+ "git",
147
+ "-C",
148
+ repoPath,
149
+ "fetch",
150
+ "--no-tags",
151
+ "origin",
152
+ `+${sourceRef}:${temporaryRef}`,
153
+ ],
154
+ { captureOutput: true },
155
+ )
156
+ if (fetched.exitCode !== 0) {
157
+ const cleanup = yield* fs.runCommand(
158
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
159
+ { captureOutput: true },
160
+ )
161
+ return yield* new ReviewError({
162
+ message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
163
+ })
164
+ }
165
+ const resolved = yield* fs.runCommand(
166
+ [
167
+ "git",
168
+ "-C",
169
+ repoPath,
170
+ "rev-parse",
171
+ "--verify",
172
+ `${temporaryRef}^{commit}`,
173
+ ],
174
+ { captureOutput: true },
175
+ )
176
+ const cleanup = yield* fs.runCommand(
177
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
178
+ { captureOutput: true },
179
+ )
180
+ if (cleanup.exitCode !== 0) {
181
+ return yield* new ReviewError({
182
+ message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
183
+ })
184
+ }
185
+ const commit = resolved.stdout.trim()
186
+ if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
187
+ return yield* new ReviewError({
188
+ message: `Review source '${sourceRef}' did not resolve to a commit`,
189
+ })
190
+ }
191
+ return commit
192
+ })
193
+
194
+ export class ReviewService extends Effect.Service<ReviewService>()(
195
+ "ReviewService",
196
+ {
197
+ sync: () => ({
198
+ resolve: (
199
+ repo: string,
200
+ input: { readonly pullRequest?: string; readonly ref?: string },
201
+ startPath: string = process.cwd(),
202
+ ) =>
203
+ Effect.gen(function* () {
204
+ const repositories = yield* RepositoryService
205
+ const repository = yield* repositories.show(repo, startPath)
206
+ if (!repository.remote || repository.states.includes("missing")) {
207
+ return yield* new ReviewError({
208
+ message: `Repository alias '${repo}' must be materialized with an origin remote`,
209
+ })
210
+ }
211
+ let source: ReviewSource
212
+ let sourceRef: string
213
+ if (input.pullRequest) {
214
+ const urlMatch = input.pullRequest.match(
215
+ /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/i,
216
+ )
217
+ const identifier =
218
+ urlMatch?.[2] ??
219
+ (/^\d+$/.test(input.pullRequest) ? input.pullRequest : null)
220
+ if (!identifier) {
221
+ return yield* new ReviewError({
222
+ message: `Invalid GitHub pull request '${input.pullRequest}'`,
223
+ })
224
+ }
225
+ const originRepository = githubRepository(repository.remote)
226
+ if (!originRepository) {
227
+ return yield* new ReviewError({
228
+ message: `Repository alias '${repo}' does not use a GitHub origin`,
229
+ })
230
+ }
231
+ if (urlMatch && urlMatch[1]!.toLowerCase() !== originRepository) {
232
+ return yield* new ReviewError({
233
+ message: `Pull request repository '${urlMatch[1]}' does not match alias '${repo}' origin '${originRepository}'`,
234
+ })
235
+ }
236
+ sourceRef = `refs/pull/${identifier}/head`
237
+ source = {
238
+ kind: "pull-request",
239
+ provider: "github",
240
+ repository: originRepository,
241
+ identifier,
242
+ url: `https://github.com/${originRepository}/pull/${identifier}`,
243
+ fetchRef: sourceRef,
244
+ }
245
+ } else if (input.ref) {
246
+ sourceRef = yield* normalizeBranch(input.ref, repository.path)
247
+ source = { kind: "branch", ref: sourceRef }
248
+ } else {
249
+ return yield* new ReviewError({
250
+ message: "Exactly one review source is required",
251
+ })
252
+ }
253
+ const commit = yield* fetchCommit(repository.path, sourceRef)
254
+ return {
255
+ repo,
256
+ source,
257
+ commit,
258
+ refreshedAt: new Date().toISOString(),
259
+ } satisfies ReviewRecord
260
+ }),
261
+
262
+ refresh: (
263
+ taskId: string,
264
+ startPath: string = process.cwd(),
265
+ ifRevision?: string,
266
+ ) =>
267
+ Effect.gen(function* () {
268
+ const workbase = yield* WorkbaseService
269
+ const tasks = yield* TaskService
270
+ const worktrees = yield* WorktreeService
271
+ const service = yield* ReviewService
272
+ const repositories = yield* RepositoryService
273
+ const root = yield* workbase.discover(startPath)
274
+ return yield* withWorktreeLocks(
275
+ root,
276
+ [{ taskId }],
277
+ Effect.gen(function* () {
278
+ const task = yield* tasks.show(taskId, root)
279
+ if (!("review" in task.data)) {
280
+ return yield* new ReviewError({
281
+ message: `Task '${taskId}' is not a review task`,
282
+ })
283
+ }
284
+ const previousReview = task.data.review
285
+ if (task.data.claim?.state === "active") {
286
+ return yield* new ReviewError({
287
+ message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
288
+ })
289
+ }
290
+ if (ifRevision && task.revision !== ifRevision) {
291
+ return yield* new RevisionConflictError({
292
+ path: task.path,
293
+ target: `task '${taskId}'`,
294
+ expectedRevision: ifRevision,
295
+ currentRevision: task.revision,
296
+ message: `Revision conflict for task '${taskId}'`,
297
+ })
298
+ }
299
+ const inspection = yield* worktrees.inspect(
300
+ taskId,
301
+ undefined,
302
+ root,
303
+ )
304
+ if (
305
+ inspection.conflicts.length ||
306
+ inspection.checkouts.some((checkout) => checkout.dirty)
307
+ ) {
308
+ return yield* new ReviewError({
309
+ message: `Cannot refresh review task '${taskId}'; its checkout is dirty or structurally unexpected`,
310
+ })
311
+ }
312
+ const latest = yield* service.resolve(
313
+ task.data.review.repo,
314
+ task.data.review.source.kind === "pull-request"
315
+ ? { pullRequest: task.data.review.source.url }
316
+ : { ref: task.data.review.source.ref },
317
+ root,
318
+ )
319
+ const parsed = yield* parseFrontmatter(task.content, task.path)
320
+ const content = formatMarkdownDocument(
321
+ { ...task.data, review: latest },
322
+ parsed.body,
323
+ )
324
+ const repository = yield* repositories.show(
325
+ task.data.review.repo,
326
+ root,
327
+ )
328
+ const hadCheckout = inspection.checkouts.some(
329
+ (checkout) => checkout.exists || checkout.registered,
330
+ )
331
+ const snapshots: WorktreeRemovalSnapshot[] = []
332
+ const steps: TransactionStep[] = []
333
+ if (hadCheckout) {
334
+ steps.push({
335
+ label: `remove review checkout for ${taskId}`,
336
+ apply: () =>
337
+ runWorktreeEffect(
338
+ worktrees.remove(taskId, undefined, root, {
339
+ snapshots,
340
+ lockHeld: true,
341
+ }),
342
+ ).then(() => undefined),
343
+ rollback: () => restoreSnapshots(snapshots),
344
+ manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
345
+ })
346
+ }
347
+ steps.push(
348
+ documentWriteStep(root, [{ path: task.path, content }]),
349
+ )
350
+ steps.push({
351
+ label: `advance review pin for ${taskId}`,
352
+ apply: () =>
353
+ runGit([
354
+ "git",
355
+ "-C",
356
+ repository.path,
357
+ "update-ref",
358
+ pinRef(taskId),
359
+ latest.commit,
360
+ previousReview.commit,
361
+ ]).then(() => undefined),
362
+ rollback: () =>
363
+ runGit([
364
+ "git",
365
+ "-C",
366
+ repository.path,
367
+ "update-ref",
368
+ pinRef(taskId),
369
+ previousReview.commit,
370
+ latest.commit,
371
+ ]).then(() => undefined),
372
+ manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
373
+ })
374
+ if (hadCheckout) {
375
+ steps.push({
376
+ label: `create refreshed review checkout for ${taskId}`,
377
+ apply: () =>
378
+ runWorktreeEffect(
379
+ worktrees.materialize(taskId, undefined, root, {
380
+ lockHeld: true,
381
+ }),
382
+ ).then(() => undefined),
383
+ manualRecovery: `Run agency work prepare for review task '${taskId}'`,
384
+ })
385
+ }
386
+ yield* runLifecycleTransaction({
387
+ root,
388
+ preconditions: [{ path: task.path, revision: task.revision }],
389
+ steps,
390
+ })
391
+ return {
392
+ taskId,
393
+ previousCommit: task.data.review.commit,
394
+ commit: latest.commit,
395
+ changed: latest.commit !== task.data.review.commit,
396
+ refreshedAt: latest.refreshedAt,
397
+ revision: (yield* tasks.show(taskId, root)).revision,
398
+ }
399
+ }),
400
+ )
401
+ }),
402
+ }),
403
+ },
404
+ ) {}
@@ -8,6 +8,7 @@ import { PullRequestService } from "./PullRequestService"
8
8
  import { SyncService } from "./SyncService"
9
9
  import { TaskService } from "./TaskService"
10
10
  import { WorktreeService } from "./WorktreeService"
11
+ import { WorkbaseService } from "./WorkbaseService"
11
12
 
12
13
  const git = async (args: string[], cwd?: string) => {
13
14
  const process = Bun.spawn(["git", ...args], {
@@ -582,7 +583,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
582
583
  ),
583
584
  ),
584
585
  )
585
- await rm(workspace.writablePath, { recursive: true, force: true })
586
+ await rm(workspace.writablePath!, { recursive: true, force: true })
586
587
 
587
588
  const observed = await runTestEffect(
588
589
  SyncService.pipe(
@@ -606,7 +607,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
606
607
  )
607
608
  expect(applied.changes).toEqual([])
608
609
  expect(
609
- await Bun.file(join(workspace.writablePath, "README.md")).exists(),
610
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
610
611
  ).toBe(false)
611
612
  })
612
613
 
@@ -663,4 +664,69 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
663
664
  )
664
665
  expect(task.data).toMatchObject({ status: "open" })
665
666
  })
667
+
668
+ test("leaves non-PR completion unchanged when a matching PR is discoverable", async () => {
669
+ await runTestEffect(
670
+ TaskService.pipe(
671
+ Effect.flatMap((service) =>
672
+ service.create(
673
+ {
674
+ id: "non-pr",
675
+ ticketUrl: null,
676
+ repo: "agency",
677
+ branch: "feat/example",
678
+ base: "main",
679
+ },
680
+ root,
681
+ ),
682
+ ),
683
+ ),
684
+ )
685
+ await runTestEffect(
686
+ TaskService.pipe(
687
+ Effect.flatMap((service) =>
688
+ service.setStatus("non-pr", "done", root, {
689
+ summary: "Investigation completed without changes.",
690
+ }),
691
+ ),
692
+ ),
693
+ )
694
+
695
+ for (let attempt = 0; attempt < 2; attempt += 1) {
696
+ const applied = await runTestEffect(
697
+ SyncService.pipe(
698
+ Effect.flatMap((service) =>
699
+ service.reconcile({ cwd: root, apply: true }),
700
+ ),
701
+ ),
702
+ )
703
+ expect(
704
+ applied.changes.some(
705
+ (change) =>
706
+ change.kind === "record-pr" || change.kind === "mark-done",
707
+ ),
708
+ ).toBe(false)
709
+ }
710
+
711
+ const task = await runTestEffect(
712
+ TaskService.pipe(
713
+ Effect.flatMap((service) => service.show("non-pr", root)),
714
+ ),
715
+ )
716
+ expect(task.data).toMatchObject({
717
+ status: "done",
718
+ pr: null,
719
+ completion: {
720
+ mode: "non-pr",
721
+ summary: "Investigation completed without changes.",
722
+ },
723
+ })
724
+ expect(
725
+ await runTestEffect(
726
+ WorkbaseService.pipe(
727
+ Effect.flatMap((service) => service.validate(root)),
728
+ ),
729
+ ),
730
+ ).toMatchObject({ valid: true, issues: [] })
731
+ })
666
732
  })
@@ -83,11 +83,16 @@ interface CheckoutState {
83
83
  interface ExecutionSyncState {
84
84
  readonly target: string
85
85
  readonly status: WorkStatus
86
- readonly branch: string
87
- readonly base: string
86
+ readonly branch: string | null
87
+ readonly base: string | null
88
88
  readonly claim: ClaimRecord | null
89
89
  readonly checkouts: readonly CheckoutState[]
90
90
  readonly pr: Record<string, unknown>
91
+ readonly review?: {
92
+ readonly pinnedCommit: string
93
+ readonly sourceCommit: string | null
94
+ readonly sourceAvailable: boolean
95
+ }
91
96
  }
92
97
 
93
98
  interface SyncResult {
@@ -219,7 +224,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
219
224
  data: phase.data,
220
225
  })
221
226
  }
222
- } else {
227
+ } else if (!("review" in task.data)) {
223
228
  records.push({
224
229
  key: `task:${task.id}`,
225
230
  taskId: task.id,
@@ -551,7 +556,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
551
556
  },
552
557
  root,
553
558
  )
554
- data = expired.data
559
+ data = expired.data as ExecutionData
555
560
  revision = expired.revision
556
561
  } else {
557
562
  const claim: ClaimRecord = {
@@ -580,6 +585,19 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
580
585
  })
581
586
  }
582
587
 
588
+ if (data.completion) {
589
+ executions.push({
590
+ target: record.key,
591
+ status: data.status,
592
+ branch: data.branch,
593
+ base: data.base,
594
+ claim: data.claim ?? null,
595
+ checkouts: checkoutStates,
596
+ pr: { url: null, state: "none" },
597
+ })
598
+ continue
599
+ }
600
+
583
601
  const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
584
602
  let current: PullRequestRecord | null = existing
585
603
  let pr: Record<string, unknown> = existing ?? {
@@ -774,7 +792,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
774
792
  },
775
793
  root,
776
794
  )
777
- data = recorded.data
795
+ data = recorded.data as ExecutionData
778
796
  revision = recorded.revision
779
797
  }
780
798
  changes.push({
@@ -810,7 +828,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
810
828
  },
811
829
  root,
812
830
  )
813
- data = completed.data
831
+ data = completed.data as ExecutionData
814
832
  revision = completed.revision
815
833
  }
816
834
  changes.push({
@@ -833,6 +851,105 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
833
851
  })
834
852
  }
835
853
 
854
+ for (const task of (yield* tasks.list(root)).filter(
855
+ (task) => "review" in task.data,
856
+ )) {
857
+ if (!("review" in task.data)) continue
858
+ let data = task.data
859
+ let revision = task.revision
860
+ if (
861
+ isExpired(data.claim, now) &&
862
+ (data.status === "working" || data.status === "delegated")
863
+ ) {
864
+ if (apply) {
865
+ const expired = yield* claims.expire(
866
+ { taskId: task.id, revision, now },
867
+ root,
868
+ )
869
+ if ("review" in expired.data) data = expired.data
870
+ revision = expired.revision
871
+ }
872
+ changes.push({
873
+ kind: "release-stale-claim",
874
+ target: `task:${task.id}`,
875
+ message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
876
+ status: apply ? "applied" : "planned",
877
+ })
878
+ }
879
+ const inspection = yield* worktrees.inspect(task.id, undefined, root)
880
+ for (const conflict of inspection.conflicts) {
881
+ unresolved.push({
882
+ kind: conflict.kind,
883
+ target: `task:${task.id}`,
884
+ message: conflict.message,
885
+ action: "Repair or remove the review checkout explicitly",
886
+ })
887
+ }
888
+ const checkout = inspection.checkouts[0]
889
+ if (
890
+ !checkout?.exists &&
891
+ inspection.conflicts.length === 0 &&
892
+ (data.status === "working" || data.status === "delegated")
893
+ ) {
894
+ if (apply) yield* worktrees.materialize(task.id, undefined, root)
895
+ changes.push({
896
+ kind: "materialize-workspace",
897
+ target: `task:${task.id}`,
898
+ message: `Materialize pinned review checkout under ${inspection.codePath}`,
899
+ status: apply ? "applied" : "planned",
900
+ })
901
+ }
902
+ const repositoryPath = join(root, "repos", data.review.repo)
903
+ const source = yield* runExternal([
904
+ "git",
905
+ "-C",
906
+ repositoryPath,
907
+ "ls-remote",
908
+ "origin",
909
+ data.review.source.kind === "pull-request"
910
+ ? data.review.source.fetchRef
911
+ : originRef(data.review.source.ref),
912
+ ])
913
+ const sourceCommit = source.stdout.trim().split(/\s+/)[0] || null
914
+ if (!sourceCommit) {
915
+ warnings.push({
916
+ kind: "review-source-unavailable",
917
+ target: `task:${task.id}`,
918
+ message:
919
+ "Review source is unavailable; the pinned commit is unchanged",
920
+ })
921
+ }
922
+ executions.push({
923
+ target: `task:${task.id}`,
924
+ status: data.status,
925
+ branch: null,
926
+ base: null,
927
+ claim: data.claim ?? null,
928
+ checkouts: checkout
929
+ ? [
930
+ {
931
+ repo: checkout.repo,
932
+ kind: "reference",
933
+ path: checkout.path,
934
+ requestedRef: data.review.commit,
935
+ resolvedCommit: checkout.expectedCommit,
936
+ registered: checkout.registered,
937
+ exists: checkout.exists,
938
+ head: checkout.actualCommit,
939
+ branch: checkout.actualBranch,
940
+ dirty: checkout.dirty,
941
+ },
942
+ ]
943
+ : [],
944
+ pr: { url: null, state: "none" },
945
+ review: {
946
+ pinnedCommit: data.review.commit,
947
+ sourceCommit,
948
+ sourceAvailable: sourceCommit !== null,
949
+ },
950
+ })
951
+ }
952
+
836
953
  return {
837
954
  root,
838
955
  mode: apply ? "apply" : "dry-run",