@markjaquith/agency 2.58.1 → 2.58.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.58.1",
3
+ "version": "2.58.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1882,6 +1882,111 @@ pr: null
1882
1882
  ).toBe(true)
1883
1883
  })
1884
1884
 
1885
+ test("rebuilds clean drifted jj references without discarding changes", async () => {
1886
+ if (!Bun.which("jj")) return
1887
+ const first = new TextDecoder()
1888
+ .decode(
1889
+ Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
1890
+ stdout: "pipe",
1891
+ }).stdout,
1892
+ )
1893
+ .trim()
1894
+ await Bun.write(join(source, "README.md"), "updated\n")
1895
+ await git(["add", "README.md"], source)
1896
+ await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
1897
+ const second = new TextDecoder()
1898
+ .decode(
1899
+ Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
1900
+ stdout: "pipe",
1901
+ }).stdout,
1902
+ )
1903
+ .trim()
1904
+ const repository = join(root, "repos/agency")
1905
+ await rm(repository, { recursive: true, force: true })
1906
+ await jj(["git", "clone", "--no-colocate", source, repository])
1907
+ await Bun.write(
1908
+ join(root, "agency.json"),
1909
+ JSON.stringify({ version: 2, vcs: "jj" }),
1910
+ )
1911
+ const createDriftedReview = async (id: string) => {
1912
+ const task = await runTestEffect(
1913
+ TaskService.pipe(
1914
+ Effect.flatMap((service) =>
1915
+ service.create(
1916
+ {
1917
+ id,
1918
+ ticketUrl: null,
1919
+ review: {
1920
+ repo: "agency",
1921
+ source: {
1922
+ kind: "branch",
1923
+ ref: "refs/heads/main",
1924
+ },
1925
+ commit: first,
1926
+ refreshedAt: "2026-08-10T00:00:00.000Z",
1927
+ },
1928
+ },
1929
+ root,
1930
+ ),
1931
+ ),
1932
+ ),
1933
+ )
1934
+ const workspace = await runTestEffect(
1935
+ WorktreeService.pipe(
1936
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
1937
+ ),
1938
+ )
1939
+ await Bun.write(task.path, task.content.replace(first, second))
1940
+ return workspace
1941
+ }
1942
+
1943
+ const workspace = await createDriftedReview("drifted-review")
1944
+
1945
+ await expect(
1946
+ runTestEffect(
1947
+ WorktreeService.pipe(
1948
+ Effect.flatMap((service) =>
1949
+ service.materialize("drifted-review", undefined, root),
1950
+ ),
1951
+ ),
1952
+ ),
1953
+ ).rejects.toThrow("does not match reference")
1954
+ const inspection = await runTestEffect(
1955
+ WorktreeService.pipe(
1956
+ Effect.flatMap((service) =>
1957
+ service.inspect("drifted-review", undefined, root),
1958
+ ),
1959
+ ),
1960
+ )
1961
+ expect(inspection.conflicts).toEqual([
1962
+ expect.objectContaining({ kind: "reference-drift" }),
1963
+ ])
1964
+ await runTestEffect(
1965
+ WorktreeService.pipe(
1966
+ Effect.flatMap((service) =>
1967
+ service.rebuild("drifted-review", undefined, root),
1968
+ ),
1969
+ ),
1970
+ )
1971
+ expect(
1972
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
1973
+ ).toBe("updated\n")
1974
+
1975
+ const dirtyWorkspace = await createDriftedReview("dirty-drifted-review")
1976
+ const dirtyPath = join(dirtyWorkspace.reviewPath!, "dirty.txt")
1977
+ await Bun.write(dirtyPath, "preserve me\n")
1978
+ await expect(
1979
+ runTestEffect(
1980
+ WorktreeService.pipe(
1981
+ Effect.flatMap((service) =>
1982
+ service.rebuild("dirty-drifted-review", undefined, root),
1983
+ ),
1984
+ ),
1985
+ ),
1986
+ ).rejects.toThrow("checkout has uncommitted changes")
1987
+ expect(await Bun.file(dirtyPath).text()).toBe("preserve me\n")
1988
+ })
1989
+
1885
1990
  test("restores removed worktrees when rebuild creation fails", async () => {
1886
1991
  await runTestEffect(
1887
1992
  TaskService.pipe(
@@ -254,11 +254,13 @@ const originRef = (ref: string) =>
254
254
  interface MaterializeOptions extends BaseCommandOptions {
255
255
  readonly force?: boolean
256
256
  readonly lockHeld?: boolean
257
+ readonly allowReferenceDrift?: boolean
257
258
  }
258
259
 
259
260
  interface RemoveOptions extends BaseCommandOptions {
260
261
  readonly snapshots?: WorktreeRemovalSnapshot[]
261
262
  readonly lockHeld?: boolean
263
+ readonly allowReferenceDrift?: boolean
262
264
  }
263
265
 
264
266
  interface LifecycleOptions extends BaseCommandOptions {
@@ -1010,12 +1012,32 @@ const materializeJj = (options: {
1010
1012
  })
1011
1013
  }
1012
1014
  if (exists && registered) {
1015
+ const actualCommit = yield* backend.workspaceHead(workspacePath)
1016
+ if ("ref" in checkout) {
1017
+ const expectedCommit = yield* backend.resolveRevision(
1018
+ repositoryPath,
1019
+ checkout.ref,
1020
+ )
1021
+ if (!expectedCommit) {
1022
+ return yield* new WorktreeError({
1023
+ message: `Reference '${checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit; run 'agency repo fetch ${checkout.repo}' and retry`,
1024
+ })
1025
+ }
1026
+ if (
1027
+ actualCommit !== expectedCommit &&
1028
+ !options.commandOptions.allowReferenceDrift
1029
+ ) {
1030
+ return yield* new WorktreeError({
1031
+ message: `Existing checkout ${workspacePath} does not match reference '${checkout.ref}' (${expectedCommit})`,
1032
+ })
1033
+ }
1034
+ }
1013
1035
  reports.push({
1014
1036
  repo: checkout.repo,
1015
1037
  kind: "branch" in checkout ? "writable" : "reference",
1016
1038
  path: workspacePath,
1017
1039
  requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
1018
- resolvedCommit: yield* backend.workspaceHead(workspacePath),
1040
+ resolvedCommit: actualCommit,
1019
1041
  action: "reused",
1020
1042
  })
1021
1043
  continue
@@ -1221,6 +1243,9 @@ const removeJj = (
1221
1243
  .filter(
1222
1244
  (conflict) =>
1223
1245
  conflict.kind !== "stale-registration" &&
1246
+ !(
1247
+ options.allowReferenceDrift && conflict.kind === "reference-drift"
1248
+ ) &&
1224
1249
  !(
1225
1250
  conflict.kind === "unregistered-checkout" &&
1226
1251
  recoverable.has(checkout.path)
@@ -2392,7 +2417,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2392
2417
  root,
2393
2418
  )
2394
2419
  const blockingConflicts = inspection.conflicts.filter(
2395
- (conflict) => conflict.kind !== "stale-registration",
2420
+ (conflict) =>
2421
+ conflict.kind !== "stale-registration" &&
2422
+ !(
2423
+ options.allowReferenceDrift &&
2424
+ conflict.kind === "reference-drift"
2425
+ ),
2396
2426
  )
2397
2427
  if (blockingConflicts.length > 0) {
2398
2428
  return yield* new WorktreeError({
@@ -2723,21 +2753,29 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2723
2753
  )
2724
2754
  }
2725
2755
  const inspection = yield* inspectExecution(taskId, phaseId, root)
2726
- const dirty = inspection.checkouts.filter(
2727
- (checkout) => checkout.dirty === true,
2756
+ const notProvablyClean = inspection.checkouts.filter(
2757
+ (checkout) => checkout.exists && checkout.dirty !== false,
2728
2758
  )
2729
- if (dirty.length > 0) {
2759
+ if (notProvablyClean.length > 0) {
2760
+ const unknown = notProvablyClean.some(
2761
+ (checkout) => checkout.dirty === null,
2762
+ )
2730
2763
  return yield* new WorktreeError({
2731
- message: `Cannot rebuild ${dirty.map((checkout) => checkout.path).join(", ")}; checkout has uncommitted changes`,
2732
- conflicts: dirty.flatMap((checkout) => checkout.conflicts),
2764
+ message: `Cannot rebuild ${notProvablyClean.map((checkout) => checkout.path).join(", ")}; ${unknown ? "checkout cleanliness could not be verified" : "checkout has uncommitted changes"}`,
2765
+ conflicts: notProvablyClean.flatMap(
2766
+ (checkout) => checkout.conflicts,
2767
+ ),
2733
2768
  })
2734
2769
  }
2735
- if (inspection.conflicts.length > 0) {
2770
+ const blockingConflicts = inspection.conflicts.filter(
2771
+ (conflict) => conflict.kind !== "reference-drift",
2772
+ )
2773
+ if (blockingConflicts.length > 0) {
2736
2774
  return yield* new WorktreeError({
2737
- message: inspection.conflicts
2775
+ message: blockingConflicts
2738
2776
  .map(({ message }) => message)
2739
2777
  .join("\n"),
2740
- conflicts: inspection.conflicts,
2778
+ conflicts: blockingConflicts,
2741
2779
  })
2742
2780
  }
2743
2781
  yield* service.materialize(taskId, phaseId, inspection.root, {
@@ -2745,6 +2783,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2745
2783
  dryRun: true,
2746
2784
  silent: true,
2747
2785
  lockHeld: true,
2786
+ allowReferenceDrift: true,
2748
2787
  })
2749
2788
  if (options.dryRun) {
2750
2789
  return {
@@ -2768,6 +2807,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2768
2807
  ...options,
2769
2808
  snapshots,
2770
2809
  lockHeld: true,
2810
+ allowReferenceDrift: true,
2771
2811
  },
2772
2812
  )
2773
2813
  const workspace = yield* service