@markjaquith/agency 2.52.0 → 2.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.52.0",
3
+ "version": "2.52.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "index.ts",
22
22
  "cli.ts",
23
+ "cli-main.ts",
23
24
  "src",
24
25
  "schemas",
25
26
  "fixtures/protocol",
@@ -158,6 +158,77 @@ describe("GraphMutationService", () => {
158
158
  )
159
159
  })
160
160
 
161
+ test("sets and clears pull request metadata with materialized code", async () => {
162
+ await mkdir(join(root, "tasks/alpha/code/agency"), { recursive: true })
163
+ await mkdir(join(root, "tasks/multi/phases/build/code/agency"), {
164
+ recursive: true,
165
+ })
166
+ const taskPr = "https://github.com/example/agency/pull/12"
167
+ const phasePr = "https://github.com/example/agency/pull/13"
168
+
169
+ await runTestEffect(
170
+ Effect.gen(function* () {
171
+ const mutations = yield* GraphMutationService
172
+ yield* mutations.updateTask("alpha", { pr: taskPr }, root)
173
+ yield* mutations.updatePhase("multi", "build", { pr: phasePr }, root)
174
+ const tasks = yield* TaskService
175
+ const phases = yield* PhaseService
176
+ expect((yield* tasks.show("alpha", root)).data).toMatchObject({
177
+ pr: taskPr,
178
+ })
179
+ expect((yield* phases.show("multi", "build", root)).data.pr).toBe(
180
+ phasePr,
181
+ )
182
+
183
+ yield* mutations.updateTask("alpha", { pr: null }, root)
184
+ yield* mutations.updatePhase("multi", "build", { pr: null }, root)
185
+ expect((yield* tasks.show("alpha", root)).data).toMatchObject({
186
+ pr: null,
187
+ })
188
+ expect((yield* phases.show("multi", "build", root)).data.pr).toBeNull()
189
+ }),
190
+ )
191
+ })
192
+
193
+ test("rejects checkout topology updates with materialized code", async () => {
194
+ await mkdir(join(root, "tasks/alpha/code/agency"), { recursive: true })
195
+ await mkdir(join(root, "tasks/multi/phases/build/code/agency"), {
196
+ recursive: true,
197
+ })
198
+ const topologyUpdates = [
199
+ { repo: "docs" },
200
+ { repos: [{ repo: "docs", ref: "main" }] },
201
+ { branch: "feature/materialized" },
202
+ { base: "develop" },
203
+ ]
204
+
205
+ for (const updates of topologyUpdates) {
206
+ await expect(
207
+ runTestEffect(
208
+ Effect.gen(function* () {
209
+ return yield* (yield* GraphMutationService).updateTask(
210
+ "alpha",
211
+ updates,
212
+ root,
213
+ )
214
+ }),
215
+ ),
216
+ ).rejects.toThrow("materialized code")
217
+ await expect(
218
+ runTestEffect(
219
+ Effect.gen(function* () {
220
+ return yield* (yield* GraphMutationService).updatePhase(
221
+ "multi",
222
+ "build",
223
+ updates,
224
+ root,
225
+ )
226
+ }),
227
+ ),
228
+ ).rejects.toThrow("materialized code")
229
+ }
230
+ })
231
+
161
232
  test("preserves dependency order and rejects cycles", async () => {
162
233
  await runTestEffect(
163
234
  Effect.gen(function* () {
@@ -360,6 +360,12 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
360
360
  "base",
361
361
  "pr",
362
362
  ].some((key) => updates[key as keyof TaskUpdates] !== undefined)
363
+ const checkoutTopologyChange = [
364
+ "repo",
365
+ "repos",
366
+ "branch",
367
+ "base",
368
+ ].some((key) => updates[key as keyof TaskUpdates] !== undefined)
363
369
  if ("review" in record.data && executionChange) {
364
370
  return yield* new GraphMutationError({
365
371
  message: `Review task '${id}' does not have writable execution metadata`,
@@ -390,7 +396,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
390
396
  })
391
397
  }
392
398
  if (
393
- executionChange &&
399
+ checkoutTopologyChange &&
394
400
  (yield* fs.isDirectory(join(dirname(record.path), "code")))
395
401
  ) {
396
402
  return yield* new GraphMutationError({
@@ -506,8 +512,14 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
506
512
  "base",
507
513
  "pr",
508
514
  ].some((key) => updates[key as keyof PhaseUpdates] !== undefined)
515
+ const checkoutTopologyChange = [
516
+ "repo",
517
+ "repos",
518
+ "branch",
519
+ "base",
520
+ ].some((key) => updates[key as keyof PhaseUpdates] !== undefined)
509
521
  if (
510
- executionChange &&
522
+ checkoutTopologyChange &&
511
523
  (yield* fs.isDirectory(join(dirname(record.path), "code")))
512
524
  ) {
513
525
  return yield* new GraphMutationError({
@@ -34,7 +34,7 @@ class TaskError extends Data.TaggedError("TaskError")<{
34
34
  readonly message: string
35
35
  }> {}
36
36
 
37
- interface TaskRecord {
37
+ export interface TaskRecord {
38
38
  readonly id: string
39
39
  readonly path: string
40
40
  readonly content: string
@@ -7,6 +7,7 @@ import { ClaimService } from "./ClaimService"
7
7
  import { TaskService } from "./TaskService"
8
8
  import { VcsMigrationService } from "./VcsMigrationService"
9
9
  import { WorktreeService } from "./WorktreeService"
10
+ import { runVcsStatusFast } from "../vcs-status-fast"
10
11
 
11
12
  const run = async (args: string[], cwd?: string) => {
12
13
  const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
@@ -72,6 +73,51 @@ describe("VcsMigrationService", () => {
72
73
 
73
74
  afterEach(async () => cleanupTempDir(root))
74
75
 
76
+ test("reports same-backend status for materialized workspaces", async () => {
77
+ const status = await runTestEffect(
78
+ VcsMigrationService.pipe(
79
+ Effect.flatMap((service) => service.status(root)),
80
+ ),
81
+ )
82
+ expect(status).toMatchObject({
83
+ configured: "git",
84
+ source: "git",
85
+ target: "git",
86
+ workspaceCount: 2,
87
+ blockers: [],
88
+ })
89
+ })
90
+
91
+ test("fast jj status matches the validated service result", async () => {
92
+ if (!Bun.which("jj")) return
93
+ await runTestEffect(
94
+ VcsMigrationService.pipe(
95
+ Effect.flatMap((service) =>
96
+ service.migrate("jj", root, { apply: true }),
97
+ ),
98
+ ),
99
+ )
100
+ const taskPath = join(root, "tasks/example/TASK.md")
101
+ const content = await Bun.file(taskPath).text()
102
+ const withoutReference = content.replace(
103
+ /\nrepos:\n(?: .+\n)+(?=branch:)/,
104
+ "\n",
105
+ )
106
+ expect(withoutReference).not.toBe(content)
107
+ await Bun.write(taskPath, withoutReference)
108
+
109
+ const validated = await runTestEffect(
110
+ VcsMigrationService.pipe(
111
+ Effect.flatMap((service) => service.status(root)),
112
+ ),
113
+ )
114
+ const output: string[] = []
115
+ expect(
116
+ await runVcsStatusFast(true, root, (line) => output.push(line)),
117
+ ).toBe(true)
118
+ expect(JSON.parse(output.join("\n")).result).toEqual(validated)
119
+ })
120
+
75
121
  test("migrates Git worktrees to jj workspaces and back", async () => {
76
122
  if (!Bun.which("jj")) return
77
123
  const checkout = join(root, "tasks/example/code/agency")
@@ -8,7 +8,7 @@ import {
8
8
  runLifecycleTransaction,
9
9
  } from "./LifecycleTransaction"
10
10
  import { FileSystemService } from "./FileSystemService"
11
- import { PhaseService } from "./PhaseService"
11
+ import { PhaseService, type PhaseRecord } from "./PhaseService"
12
12
  import { RepositoryService } from "./RepositoryService"
13
13
  import { TaskService } from "./TaskService"
14
14
  import {
@@ -182,15 +182,19 @@ const executionRecords = (root: string) =>
182
182
  Effect.gen(function* () {
183
183
  const tasks = yield* TaskService
184
184
  const phases = yield* PhaseService
185
+ const taskRecords = yield* tasks.list(root)
186
+ const phasesByTask = new Map<string, readonly PhaseRecord[]>()
185
187
  const records: {
186
188
  taskId: string
187
189
  phaseId?: string
188
190
  status: WorkStatus
189
191
  claimActive: boolean
190
192
  }[] = []
191
- for (const task of yield* tasks.list(root)) {
193
+ for (const task of taskRecords) {
192
194
  if ("phases" in task.data) {
193
- for (const phase of yield* phases.list(task.id, root)) {
195
+ const phaseRecords = yield* phases.list(task.id, root)
196
+ phasesByTask.set(task.id, phaseRecords)
197
+ for (const phase of phaseRecords) {
194
198
  records.push({
195
199
  taskId: task.id,
196
200
  phaseId: phase.id,
@@ -206,7 +210,7 @@ const executionRecords = (root: string) =>
206
210
  })
207
211
  }
208
212
  }
209
- return records
213
+ return { records, taskRecords, phasesByTask }
210
214
  })
211
215
 
212
216
  const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
@@ -241,7 +245,8 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
241
245
  })
242
246
  }
243
247
 
244
- const records = yield* executionRecords(root)
248
+ const execution = yield* executionRecords(root)
249
+ const records = execution.records
245
250
  for (const record of records) {
246
251
  if (record.claimActive) {
247
252
  const label = record.phaseId
@@ -339,7 +344,15 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
339
344
  }
340
345
 
341
346
  const workspacePlans: WorkspacePlan[] = []
342
- const inspected = yield* Effect.either(worktrees.list(root))
347
+ const inspected = yield* Effect.either(
348
+ worktrees.list(root, {
349
+ materializedOnly: !blockers.some(
350
+ (blocker) => blocker.kind === "repository",
351
+ ),
352
+ tasks: execution.taskRecords,
353
+ phasesByTask: execution.phasesByTask,
354
+ }),
355
+ )
343
356
  if (Either.isLeft(inspected)) {
344
357
  blockers.push({
345
358
  kind: "workspace-conflict",
@@ -377,26 +390,27 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
377
390
  continue
378
391
  }
379
392
  const sourceName =
380
- source === "jj"
393
+ source !== target && source === "jj"
381
394
  ? ((yield* sourceBackend.listWorkspaces(
382
395
  join(root, "repos", checkout.repo),
383
396
  )).find((item) => item.path === checkout.registeredPath)
384
397
  ?.name ?? null)
385
398
  : null
386
- const previousBranchCommit = checkout.actualBranch
387
- ? yield* command(
388
- fs,
389
- [
390
- "git",
391
- "-C",
392
- join(root, "repos", checkout.repo),
393
- "rev-parse",
394
- "--verify",
395
- `${checkout.actualBranch}^{commit}`,
396
- ],
397
- `Failed to inspect branch '${checkout.actualBranch}'`,
398
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
399
- : null
399
+ const previousBranchCommit =
400
+ source !== target && checkout.actualBranch
401
+ ? yield* command(
402
+ fs,
403
+ [
404
+ "git",
405
+ "-C",
406
+ join(root, "repos", checkout.repo),
407
+ "rev-parse",
408
+ "--verify",
409
+ `${checkout.actualBranch}^{commit}`,
410
+ ],
411
+ `Failed to inspect branch '${checkout.actualBranch}'`,
412
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
413
+ : null
400
414
  workspacePlans.push({
401
415
  taskId: inspection.owner.taskId,
402
416
  ...(inspection.owner.phaseId
@@ -3,11 +3,13 @@ import { FileSystemService } from "./FileSystemService"
3
3
  import { WorkbaseService } from "./WorkbaseService"
4
4
  import type { VersionControlKind } from "../workbase/version-control"
5
5
 
6
- interface RegisteredWorkspace {
6
+ export interface RegisteredWorkspace {
7
7
  readonly name: string | null
8
8
  readonly path: string
9
9
  readonly commit: string | null
10
10
  readonly branch: string | null
11
+ readonly head?: string | null
12
+ readonly dirty?: boolean
11
13
  }
12
14
 
13
15
  export interface VersionControlBackend {
@@ -305,7 +307,7 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
305
307
  "workspace",
306
308
  "list",
307
309
  "-T",
308
- 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\n"',
310
+ 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\t" ++ target.parents().map(|commit| commit.commit_id()).join(",") ++ "\\t" ++ target.empty() ++ "\\n"',
309
311
  ]),
310
312
  { captureOutput: true },
311
313
  ),
@@ -315,12 +317,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
315
317
  .split("\n")
316
318
  .filter(Boolean)
317
319
  .map((line) => {
318
- const [name, path, commit] = line.split("\t")
320
+ const [name, path, commit, parents, empty] = line.split("\t")
321
+ const parentCommits = parents?.split(",").filter(Boolean) ?? []
319
322
  return {
320
323
  name: name || null,
321
324
  path: path!,
322
325
  commit: commit || null,
323
326
  branch: null,
327
+ head:
328
+ parentCommits.length === 1 ? parentCommits[0]! : undefined,
329
+ dirty: empty === "false",
324
330
  }
325
331
  })
326
332
  }),