@markjaquith/agency 2.58.0 → 2.58.1

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.58.0",
3
+ "version": "2.58.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { ArchiveService } from "./ArchiveService"
@@ -22,6 +22,17 @@ const git = async (args: string[], cwd?: string) => {
22
22
  }
23
23
  }
24
24
 
25
+ const jj = async (args: string[]) => {
26
+ const process = Bun.spawn(["jj", ...args], {
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ })
30
+ await process.exited
31
+ if (process.exitCode !== 0) {
32
+ throw new Error(await new Response(process.stderr).text())
33
+ }
34
+ }
35
+
25
36
  describe("ArchiveService", () => {
26
37
  let root: string
27
38
  const dropTask = (id: string) =>
@@ -265,6 +276,166 @@ describe("ArchiveService", () => {
265
276
  ).toBe(0)
266
277
  })
267
278
 
279
+ test("retries archive after a jj workspace was forgotten but not deleted", async () => {
280
+ if (!Bun.which("jj")) return
281
+ const repository = join(root, "repos/agency")
282
+ await rm(repository, { recursive: true, force: true })
283
+ await git(["clone", join(root, "source"), repository])
284
+ await jj(["git", "init", "--colocate", repository])
285
+ await Bun.write(
286
+ join(root, "agency.json"),
287
+ JSON.stringify({ version: 2, vcs: "jj" }),
288
+ )
289
+ await runTestEffect(
290
+ TaskService.pipe(
291
+ Effect.flatMap((service) =>
292
+ service.create(
293
+ {
294
+ id: "interrupted",
295
+ ticketUrl: null,
296
+ repo: "agency",
297
+ branch: "task/interrupted",
298
+ base: "main",
299
+ },
300
+ root,
301
+ ),
302
+ ),
303
+ ),
304
+ )
305
+ await runTestEffect(
306
+ TaskService.pipe(
307
+ Effect.flatMap((service) =>
308
+ service.setStatus("interrupted", "dropped", root),
309
+ ),
310
+ ),
311
+ )
312
+ const workspace = await runTestEffect(
313
+ WorktreeService.pipe(
314
+ Effect.flatMap((service) =>
315
+ service.materialize("interrupted", undefined, root),
316
+ ),
317
+ ),
318
+ )
319
+ await jj([
320
+ "-R",
321
+ repository,
322
+ "workspace",
323
+ "forget",
324
+ "agency-interrupted-task-agency",
325
+ ])
326
+
327
+ const preview = await runTestEffect(
328
+ ArchiveService.pipe(
329
+ Effect.flatMap((service) =>
330
+ service.archiveTask("interrupted", root, { dryRun: true }),
331
+ ),
332
+ ),
333
+ )
334
+ expect(preview.removedWorktrees).toEqual([workspace.writablePath!])
335
+
336
+ await runTestEffect(
337
+ ArchiveService.pipe(
338
+ Effect.flatMap((service) => service.archiveTask("interrupted", root)),
339
+ ),
340
+ )
341
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
342
+ expect(
343
+ await Bun.file(join(root, "archive/tasks/interrupted/TASK.md")).exists(),
344
+ ).toBe(true)
345
+ })
346
+
347
+ test("refuses a modified residual jj checkout after workspace removal", async () => {
348
+ if (!Bun.which("jj")) return
349
+ const repository = join(root, "repos/agency")
350
+ await rm(repository, { recursive: true, force: true })
351
+ await git(["clone", join(root, "source"), repository])
352
+ await jj(["git", "init", "--colocate", repository])
353
+ await Bun.write(
354
+ join(root, "agency.json"),
355
+ JSON.stringify({ version: 2, vcs: "jj" }),
356
+ )
357
+ await runTestEffect(
358
+ TaskService.pipe(
359
+ Effect.flatMap((service) =>
360
+ service.create(
361
+ {
362
+ id: "modified-residual",
363
+ ticketUrl: null,
364
+ repo: "agency",
365
+ branch: "task/modified-residual",
366
+ base: "main",
367
+ },
368
+ root,
369
+ ),
370
+ ),
371
+ ),
372
+ )
373
+ await runTestEffect(
374
+ TaskService.pipe(
375
+ Effect.flatMap((service) =>
376
+ service.setStatus("modified-residual", "dropped", root),
377
+ ),
378
+ ),
379
+ )
380
+ const workspace = await runTestEffect(
381
+ WorktreeService.pipe(
382
+ Effect.flatMap((service) =>
383
+ service.materialize("modified-residual", undefined, root),
384
+ ),
385
+ ),
386
+ )
387
+ await jj([
388
+ "-R",
389
+ repository,
390
+ "workspace",
391
+ "forget",
392
+ "agency-modified-residual-task-agency",
393
+ ])
394
+ await Bun.write(join(workspace.writablePath!, "keep.txt"), "keep me\n")
395
+
396
+ await expect(
397
+ runTestEffect(
398
+ ArchiveService.pipe(
399
+ Effect.flatMap((service) =>
400
+ service.archiveTask("modified-residual", root),
401
+ ),
402
+ ),
403
+ ),
404
+ ).rejects.toThrow("contents do not match expected revision")
405
+ expect(
406
+ await Bun.file(join(workspace.writablePath!, "keep.txt")).text(),
407
+ ).toBe("keep me\n")
408
+ expect(
409
+ await Bun.file(join(root, "tasks/modified-residual/TASK.md")).exists(),
410
+ ).toBe(true)
411
+ expect(
412
+ await Bun.file(
413
+ join(
414
+ root,
415
+ `.agency-worktree-${Buffer.from("modified-residual:task").toString("hex")}.lock`,
416
+ ),
417
+ ).exists(),
418
+ ).toBe(false)
419
+
420
+ await rm(join(workspace.writablePath!, "keep.txt"))
421
+ await rm(join(workspace.writablePath!, ".jj"), {
422
+ recursive: true,
423
+ force: true,
424
+ })
425
+ await expect(
426
+ runTestEffect(
427
+ ArchiveService.pipe(
428
+ Effect.flatMap((service) =>
429
+ service.archiveTask("modified-residual", root),
430
+ ),
431
+ ),
432
+ ),
433
+ ).rejects.toThrow("is not registered as a jj workspace")
434
+ expect(
435
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
436
+ ).toBe(true)
437
+ })
438
+
268
439
  test("archives a phase in the mirrored task hierarchy", async () => {
269
440
  await runTestEffect(
270
441
  TaskService.pipe(
@@ -770,6 +770,81 @@ const jjWorkspaceName = (
770
770
  repo: string,
771
771
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
772
772
 
773
+ // A process can stop after `jj workspace forget` but before checkout deletion.
774
+ // Only recover that residual when its repository and complete tree still match.
775
+ const inspectJjResidual = (
776
+ root: string,
777
+ repositoryPath: string,
778
+ workspacePath: string,
779
+ revision: string,
780
+ ) =>
781
+ Effect.gen(function* () {
782
+ const fs = yield* FileSystemService
783
+ const repoPointer = join(workspacePath, ".jj", "repo")
784
+ if (!(yield* fs.exists(repoPointer))) return "unowned" as const
785
+
786
+ const pointer = (yield* fs.readFile(repoPointer)).trim()
787
+ if (!pointer) return "unowned" as const
788
+ const linkedRepository = resolve(dirname(repoPointer), pointer)
789
+ const expectedRepository = join(repositoryPath, ".jj", "repo")
790
+ if (
791
+ !(yield* fs.exists(linkedRepository)) ||
792
+ (yield* fs.realPath(linkedRepository)) !==
793
+ (yield* fs.realPath(expectedRepository))
794
+ )
795
+ return "unowned" as const
796
+
797
+ const comparisonPath = join(
798
+ root,
799
+ `.agency-jj-residual-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
800
+ )
801
+ const comparisonName = basename(comparisonPath)
802
+ return yield* Effect.gen(function* () {
803
+ const created = yield* fs.runCommand(
804
+ [
805
+ "jj",
806
+ "-R",
807
+ repositoryPath,
808
+ "workspace",
809
+ "add",
810
+ "--name",
811
+ comparisonName,
812
+ "-r",
813
+ revision,
814
+ comparisonPath,
815
+ ],
816
+ { captureOutput: true },
817
+ )
818
+ if (created.exitCode !== 0) {
819
+ return yield* new WorktreeError({
820
+ message: `Cannot verify unregistered jj checkout ${workspacePath}: ${created.stderr}`,
821
+ })
822
+ }
823
+ const comparison = yield* fs.runCommand(
824
+ ["diff", "-qr", "-x", ".jj", workspacePath, comparisonPath],
825
+ { captureOutput: true },
826
+ )
827
+ if (comparison.exitCode > 1) {
828
+ return yield* new WorktreeError({
829
+ message: `Cannot verify unregistered jj checkout ${workspacePath}: ${comparison.stderr}`,
830
+ })
831
+ }
832
+ return comparison.exitCode === 0
833
+ ? ("clean" as const)
834
+ : ("modified" as const)
835
+ }).pipe(
836
+ Effect.ensuring(
837
+ Effect.gen(function* () {
838
+ yield* fs.runCommand(
839
+ ["jj", "-R", repositoryPath, "workspace", "forget", comparisonName],
840
+ { captureOutput: true },
841
+ )
842
+ yield* fs.deleteDirectory(comparisonPath)
843
+ }).pipe(Effect.ignore),
844
+ ),
845
+ )
846
+ })
847
+
773
848
  const buildInspectionContext = (
774
849
  startPath: string,
775
850
  skipWritableRevisionResolution: boolean,
@@ -1103,10 +1178,63 @@ const removeJj = (
1103
1178
  Effect.gen(function* () {
1104
1179
  const fs = yield* FileSystemService
1105
1180
  const versionControl = yield* VersionControlService
1181
+ const tasks = yield* TaskService
1182
+ const phases = yield* PhaseService
1106
1183
  const backend = yield* versionControl.forWorkbase(root)
1107
1184
  const inspection = yield* inspectExecution(taskId, phaseId, root)
1108
- const blocking = inspection.conflicts.filter(
1109
- (conflict) => conflict.kind !== "stale-registration",
1185
+ const task = yield* tasks.show(taskId, root)
1186
+ const execution =
1187
+ "phases" in task.data && phaseId
1188
+ ? (yield* phases.show(taskId, phaseId, root)).data
1189
+ : task.data
1190
+ const recoverable = new Map<string, string>()
1191
+ const modifiedResiduals = new Map<string, string>()
1192
+ for (const checkout of inspection.checkouts) {
1193
+ const recoveryRevision =
1194
+ checkout.expectedCommit ??
1195
+ (checkout.kind === "writable" && "base" in execution
1196
+ ? yield* backend.resolveRevision(
1197
+ join(root, "repos", checkout.repo),
1198
+ execution.base,
1199
+ )
1200
+ : null)
1201
+ if (
1202
+ !checkout.conflicts.some(
1203
+ (conflict) => conflict.kind === "unregistered-checkout",
1204
+ ) ||
1205
+ !recoveryRevision
1206
+ )
1207
+ continue
1208
+ const repositoryPath = join(root, "repos", checkout.repo)
1209
+ const residual = yield* inspectJjResidual(
1210
+ root,
1211
+ repositoryPath,
1212
+ checkout.path,
1213
+ recoveryRevision,
1214
+ )
1215
+ if (residual === "clean") recoverable.set(checkout.path, recoveryRevision)
1216
+ else if (residual === "modified")
1217
+ modifiedResiduals.set(checkout.path, recoveryRevision)
1218
+ }
1219
+ const blocking = inspection.checkouts.flatMap((checkout) =>
1220
+ checkout.conflicts
1221
+ .filter(
1222
+ (conflict) =>
1223
+ conflict.kind !== "stale-registration" &&
1224
+ !(
1225
+ conflict.kind === "unregistered-checkout" &&
1226
+ recoverable.has(checkout.path)
1227
+ ),
1228
+ )
1229
+ .map((conflict) =>
1230
+ conflict.kind === "unregistered-checkout" &&
1231
+ modifiedResiduals.has(checkout.path)
1232
+ ? {
1233
+ ...conflict,
1234
+ message: `Cannot remove unregistered jj checkout ${checkout.path}; its contents do not match expected revision '${modifiedResiduals.get(checkout.path)}'`,
1235
+ }
1236
+ : conflict,
1237
+ ),
1110
1238
  )
1111
1239
  if (blocking.length > 0) {
1112
1240
  return yield* new WorktreeError({
@@ -1121,6 +1249,7 @@ const removeJj = (
1121
1249
  workspaceName: string
1122
1250
  head: string
1123
1251
  branch: string | null
1252
+ registered: boolean
1124
1253
  }[] = []
1125
1254
  for (const checkout of inspection.checkouts) {
1126
1255
  if (checkout.dirty) {
@@ -1128,8 +1257,20 @@ const removeJj = (
1128
1257
  message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1129
1258
  })
1130
1259
  }
1131
- if (!checkout.registeredPath) continue
1132
1260
  const repositoryPath = join(root, "repos", checkout.repo)
1261
+ if (!checkout.registeredPath) {
1262
+ const recoveryRevision = recoverable.get(checkout.path)
1263
+ if (!recoveryRevision) continue
1264
+ plans.push({
1265
+ repositoryPath,
1266
+ workspacePath: checkout.path,
1267
+ workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1268
+ head: recoveryRevision,
1269
+ branch: checkout.actualBranch,
1270
+ registered: false,
1271
+ })
1272
+ continue
1273
+ }
1133
1274
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1134
1275
  (workspace) => workspace.path === checkout.registeredPath,
1135
1276
  )
@@ -1144,6 +1285,7 @@ const removeJj = (
1144
1285
  workspaceName: registered.name,
1145
1286
  head: checkout.actualCommit,
1146
1287
  branch: checkout.actualBranch,
1288
+ registered: true,
1147
1289
  })
1148
1290
  }
1149
1291
 
@@ -1162,11 +1304,15 @@ const removeJj = (
1162
1304
  const completed: typeof plans = []
1163
1305
  return yield* Effect.gen(function* () {
1164
1306
  for (const plan of plans) {
1165
- yield* backend.removeWorkspace({
1166
- repositoryPath: plan.repositoryPath,
1167
- workspacePath: plan.workspacePath,
1168
- workspaceName: plan.workspaceName,
1169
- })
1307
+ if (plan.registered) {
1308
+ yield* backend.removeWorkspace({
1309
+ repositoryPath: plan.repositoryPath,
1310
+ workspacePath: plan.workspacePath,
1311
+ workspaceName: plan.workspaceName,
1312
+ })
1313
+ } else {
1314
+ yield* fs.deleteDirectory(plan.workspacePath)
1315
+ }
1170
1316
  completed.push(plan)
1171
1317
  }
1172
1318
  yield* fs.deleteDirectoryIfEmpty(inspection.codePath)