@markjaquith/agency 2.56.0 → 2.56.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.56.0",
3
+ "version": "2.56.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -58,6 +58,7 @@
58
58
  "tag": "latest"
59
59
  },
60
60
  "scripts": {
61
+ "benchmark:sync": "bun scripts/benchmark-sync.ts",
61
62
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
62
63
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
63
64
  "format": "oxfmt",
@@ -9,6 +9,7 @@ import {
9
9
  createTempDir,
10
10
  runTestEffect,
11
11
  } from "../test-utils"
12
+ import type { Progress } from "../utils/progress"
12
13
  import { sync } from "./sync"
13
14
 
14
15
  const git = async (args: string[], cwd: string) => {
@@ -97,4 +98,30 @@ describe("sync command", () => {
97
98
  ],
98
99
  })
99
100
  })
101
+
102
+ test("reports human-readable progress without polluting JSON output", async () => {
103
+ const updates: string[] = []
104
+ const progress: Progress = {
105
+ start: (message) => updates.push(`start:${message}`),
106
+ succeed: (message) => updates.push(`succeed:${message}`),
107
+ fail: (message) => updates.push(`fail:${message}`),
108
+ }
109
+
110
+ await captureLogs(() =>
111
+ runTestEffect(sync({ cwd: root, silent: false }, progress)),
112
+ )
113
+ expect(updates).toEqual([
114
+ "start:Validating workbase",
115
+ "start:Inspected 1 repositories",
116
+ "start:Queried pull requests 1/1 (task:example)",
117
+ "start:Reconciled execution units 1/1 (task:example)",
118
+ "succeed:Synchronized 1 execution units",
119
+ ])
120
+
121
+ updates.length = 0
122
+ await captureLogs(() =>
123
+ runTestEffect(sync({ cwd: root, dryRun: true, json: true }, progress)),
124
+ )
125
+ expect(updates).toEqual([])
126
+ })
100
127
  })
@@ -2,19 +2,54 @@ import { Effect } from "effect"
2
2
  import { SyncService } from "../services/SyncService"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import { createProgress, type Progress } from "../utils/progress"
5
6
 
6
7
  interface SyncCommandOptions extends BaseCommandOptions {
7
8
  readonly dryRun?: boolean
8
9
  }
9
10
 
10
- export const sync = (options: SyncCommandOptions = {}) =>
11
+ export const sync = (
12
+ options: SyncCommandOptions = {},
13
+ progress: Progress = createProgress({
14
+ silent: options.silent || options.json,
15
+ }),
16
+ ) =>
11
17
  Effect.gen(function* () {
12
18
  const service = yield* SyncService
13
19
  const { log } = createLoggers(options)
14
- const result = yield* service.reconcile({
15
- cwd: options.cwd,
16
- apply: options.dryRun !== true,
17
- })
20
+ const showProgress = !options.silent && !options.json
21
+ if (showProgress) progress.start("Validating workbase")
22
+ const result = yield* service
23
+ .reconcile({
24
+ cwd: options.cwd,
25
+ apply: options.dryRun !== true,
26
+ onProgress: showProgress
27
+ ? ({ stage, current, total, target }) => {
28
+ if (stage === "repositories") {
29
+ progress.start(`Inspected ${total} repositories`)
30
+ } else if (stage === "pull-requests") {
31
+ progress.start(
32
+ `Queried pull requests ${current}/${total}${target ? ` (${target})` : ""}`,
33
+ )
34
+ } else {
35
+ progress.start(
36
+ `Reconciled execution units ${current}/${total}${target ? ` (${target})` : ""}`,
37
+ )
38
+ }
39
+ }
40
+ : undefined,
41
+ })
42
+ .pipe(
43
+ Effect.tapError(() =>
44
+ Effect.sync(() => {
45
+ if (showProgress) progress.fail("Workbase sync failed")
46
+ }),
47
+ ),
48
+ )
49
+ if (showProgress)
50
+ progress.succeed(
51
+ `Synchronized ${result.executions.length} execution units`,
52
+ )
18
53
  if (options.json) return log(JSON.stringify(result, null, 2))
19
54
 
20
55
  for (const action of result.repositories.actions) {
@@ -796,4 +796,119 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
796
796
  ),
797
797
  ).toMatchObject({ valid: true, issues: [] })
798
798
  })
799
+
800
+ test("reads each repository workspace inventory once", async () => {
801
+ for (const id of ["first", "second"]) {
802
+ await runTestEffect(
803
+ TaskService.pipe(
804
+ Effect.flatMap((service) =>
805
+ service.create(
806
+ {
807
+ id,
808
+ ticketUrl: null,
809
+ repo: "agency",
810
+ branch: `feat/${id}`,
811
+ base: "main",
812
+ },
813
+ root,
814
+ ),
815
+ ),
816
+ ),
817
+ )
818
+ await runTestEffect(
819
+ WorktreeService.pipe(
820
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
821
+ ),
822
+ )
823
+ await runTestEffect(
824
+ TaskService.pipe(
825
+ Effect.flatMap((service) =>
826
+ service.setStatus(id, "done", root, {
827
+ summary: `Completed ${id}`,
828
+ }),
829
+ ),
830
+ ),
831
+ )
832
+ }
833
+
834
+ const callsPath = join(root, "workspace-list-calls")
835
+ const realGit = Bun.which("git")!
836
+ const gitWrapper = join(root, "bin", "git")
837
+ await Bun.write(
838
+ gitWrapper,
839
+ `#!/bin/sh
840
+ case "$*" in
841
+ *"worktree list --porcelain -z"*) printf 'call\\n' >> ${JSON.stringify(callsPath)} ;;
842
+ esac
843
+ exec ${JSON.stringify(realGit)} "$@"
844
+ `,
845
+ )
846
+ await chmod(gitWrapper, 0o755)
847
+
848
+ await runTestEffect(
849
+ SyncService.pipe(
850
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
851
+ ),
852
+ )
853
+ expect((await Bun.file(callsPath).text()).trim().split("\n")).toHaveLength(
854
+ 1,
855
+ )
856
+ })
857
+
858
+ test("queries pull request providers concurrently", async () => {
859
+ for (const id of ["first", "second", "third"]) {
860
+ await runTestEffect(
861
+ TaskService.pipe(
862
+ Effect.flatMap((service) =>
863
+ service.create(
864
+ {
865
+ id,
866
+ ticketUrl: null,
867
+ repo: "agency",
868
+ branch: `feat/${id}`,
869
+ base: "main",
870
+ },
871
+ root,
872
+ ),
873
+ ),
874
+ ),
875
+ )
876
+ }
877
+
878
+ const barrier = join(root, "query-barrier")
879
+ await mkdir(barrier)
880
+ await Bun.write(
881
+ join(root, "bin", "gh"),
882
+ `#!/bin/sh
883
+ branch=""
884
+ while [ "$#" -gt 0 ]; do
885
+ if [ "$1" = "--head" ]; then branch="$2"; break; fi
886
+ shift
887
+ done
888
+ id="\${branch##*/}"
889
+ touch ${JSON.stringify(barrier)}/"\${id}"
890
+ attempt=0
891
+ while [ "$attempt" -lt 200 ]; do
892
+ set -- ${JSON.stringify(barrier)}/*
893
+ if [ -e "$1" ] && [ "$#" -ge 3 ]; then printf '[]\\n'; exit 0; fi
894
+ attempt=$((attempt + 1))
895
+ sleep 0.01
896
+ done
897
+ echo "provider queries were serialized" >&2
898
+ exit 9
899
+ `,
900
+ )
901
+ await chmod(join(root, "bin", "gh"), 0o755)
902
+
903
+ const result = await runTestEffect(
904
+ SyncService.pipe(
905
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
906
+ ),
907
+ )
908
+ expect(
909
+ result.warnings.filter(
910
+ (warning) => warning.kind === "pr-discovery-unavailable",
911
+ ),
912
+ ).toEqual([])
913
+ })
799
914
  })
@@ -48,6 +48,7 @@ interface RegisteredWorktree {
48
48
  readonly path: string
49
49
  readonly head: string | null
50
50
  readonly branch: string | null
51
+ readonly dirty?: boolean
51
52
  }
52
53
 
53
54
  interface SyncChange {
@@ -106,6 +107,13 @@ interface SyncResult {
106
107
  readonly repositories: RepositorySetupResult
107
108
  }
108
109
 
110
+ export interface SyncProgress {
111
+ readonly stage: "repositories" | "pull-requests" | "executions"
112
+ readonly current: number
113
+ readonly total: number
114
+ readonly target?: string
115
+ }
116
+
109
117
  const parseWorktrees = (output: string): RegisteredWorktree[] => {
110
118
  const worktrees: RegisteredWorktree[] = []
111
119
  let current: RegisteredWorktree | undefined
@@ -152,6 +160,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
152
160
  readonly cwd?: string
153
161
  readonly apply?: boolean
154
162
  readonly now?: Date
163
+ readonly onProgress?: (progress: SyncProgress) => void
155
164
  } = {},
156
165
  ) =>
157
166
  Effect.gen(function* () {
@@ -177,6 +186,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
177
186
  cwd: root,
178
187
  apply: options.apply === true,
179
188
  })
189
+ options.onProgress?.({
190
+ stage: "repositories",
191
+ current: repositorySetup.repositories.length,
192
+ total: repositorySetup.repositories.length,
193
+ })
180
194
 
181
195
  const apply = options.apply === true
182
196
  const now = options.now ?? new Date()
@@ -192,6 +206,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
192
206
  })
193
207
  }
194
208
  const executions: ExecutionSyncState[] = []
209
+ const registeredByRepository = new Map<
210
+ string,
211
+ RegisteredWorktree[] | null
212
+ >()
195
213
  const runExternal = (
196
214
  args: readonly string[],
197
215
  commandOptions?: {
@@ -214,8 +232,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
214
232
  }),
215
233
  ),
216
234
  )
235
+ const taskRecords = yield* tasks.list(root)
217
236
  const records: ExecutionRecord[] = []
218
- for (const task of yield* tasks.list(root)) {
237
+ for (const task of taskRecords) {
219
238
  if ("phases" in task.data) {
220
239
  for (const phase of yield* phases.list(task.id, root)) {
221
240
  records.push({
@@ -237,6 +256,147 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
237
256
  })
238
257
  }
239
258
  }
259
+ const listRegistered = (repositoryPath: string) =>
260
+ Effect.gen(function* () {
261
+ if (registeredByRepository.has(repositoryPath))
262
+ return registeredByRepository.get(repositoryPath)!
263
+ const listed = yield* Effect.either(
264
+ backend.listWorkspaces(repositoryPath),
265
+ )
266
+ if (Either.isLeft(listed)) {
267
+ registeredByRepository.set(repositoryPath, null)
268
+ return null
269
+ }
270
+ const registered: RegisteredWorktree[] = []
271
+ for (const item of listed.right) {
272
+ registered.push({
273
+ head: backend.kind === "jj" ? (item.head ?? null) : item.commit,
274
+ branch: item.branch,
275
+ path: (yield* fs.exists(item.path))
276
+ ? yield* fs.realPath(item.path)
277
+ : resolve(item.path),
278
+ ...(item.dirty === undefined ? {} : { dirty: item.dirty }),
279
+ })
280
+ }
281
+ registeredByRepository.set(repositoryPath, registered)
282
+ return registered
283
+ })
284
+ const queryRecords = records.filter((record) => !record.data.completion)
285
+ let queriedPullRequests = 0
286
+ const remoteName = config.delivery?.remote ?? "origin"
287
+ const repositoryPaths = [
288
+ ...new Set(
289
+ queryRecords.map((record) => join(root, "repos", record.data.repo)),
290
+ ),
291
+ ]
292
+ const remoteUrls = new Map(
293
+ yield* Effect.forEach(
294
+ repositoryPaths,
295
+ (repositoryPath) =>
296
+ backend
297
+ .remoteUrl(repositoryPath, remoteName)
298
+ .pipe(
299
+ Effect.map(
300
+ (remoteUrl) => [repositoryPath, remoteUrl] as const,
301
+ ),
302
+ ),
303
+ { concurrency: 8 },
304
+ ),
305
+ )
306
+ const prQueries = new Map(
307
+ yield* Effect.forEach(
308
+ queryRecords,
309
+ (record) =>
310
+ Effect.gen(function* () {
311
+ const data = record.data
312
+ const repositoryPath = join(root, "repos", data.repo)
313
+ const remoteUrl = remoteUrls.get(repositoryPath) ?? null
314
+ const remoteRepository = (remoteUrl ?? "")
315
+ .trim()
316
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
317
+ .replace(/^[^:]+:/, "")
318
+ .replace(/\.git\/?$/, "")
319
+ .replace(/\/$/, "")
320
+ const existing = data.pr
321
+ ? normalizePullRequestRecord(data.pr)
322
+ : null
323
+ let result
324
+ if (config.delivery && remoteUrl) {
325
+ const resolved = resolveDeliveryCommand(
326
+ config.delivery,
327
+ "query",
328
+ {
329
+ repository: remoteRepository,
330
+ branch: data.branch,
331
+ base: data.base,
332
+ draft: existing ? String(existing.draft) : "",
333
+ url: existing?.url ?? "",
334
+ identifier: existing?.identifier ?? "",
335
+ },
336
+ )
337
+ result = yield* runExternal(resolved.argv, {
338
+ cwd: repositoryPath,
339
+ env: resolved.environment,
340
+ })
341
+ } else if (!config.delivery && existing) {
342
+ result = yield* runExternal([
343
+ "gh",
344
+ "pr",
345
+ "view",
346
+ existing.url,
347
+ "--json",
348
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
349
+ ])
350
+ } else if (!config.delivery) {
351
+ result = yield* runExternal(
352
+ [
353
+ "gh",
354
+ "pr",
355
+ "list",
356
+ "--head",
357
+ data.branch,
358
+ "--state",
359
+ "all",
360
+ "--json",
361
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
362
+ ],
363
+ { cwd: repositoryPath },
364
+ )
365
+ }
366
+ return [
367
+ record.key,
368
+ { remoteUrl, remoteRepository, result },
369
+ ] as const
370
+ }).pipe(
371
+ Effect.tap(() =>
372
+ Effect.sync(() => {
373
+ queriedPullRequests += 1
374
+ options.onProgress?.({
375
+ stage: "pull-requests",
376
+ current: queriedPullRequests,
377
+ total: queryRecords.length,
378
+ target: record.key,
379
+ })
380
+ }),
381
+ ),
382
+ ),
383
+ { concurrency: 8 },
384
+ ),
385
+ )
386
+ const reviewRecords = taskRecords.filter(
387
+ (task) => "review" in task.data,
388
+ )
389
+ const executionTotal = records.length + reviewRecords.length
390
+ let reconciledExecutions = 0
391
+ const reportExecution = (target: string) => {
392
+ reconciledExecutions += 1
393
+ options.onProgress?.({
394
+ stage: "executions",
395
+ current: reconciledExecutions,
396
+ total: executionTotal,
397
+ target,
398
+ })
399
+ }
240
400
 
241
401
  for (const record of records.sort((a, b) =>
242
402
  a.key.localeCompare(b.key),
@@ -271,10 +431,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
271
431
  workspaceConflict = true
272
432
  continue
273
433
  }
274
- const listed = yield* Effect.either(
275
- backend.listWorkspaces(repositoryPath),
276
- )
277
- if (Either.isLeft(listed)) {
434
+ const registered = yield* listRegistered(repositoryPath)
435
+ if (registered === null) {
278
436
  unresolved.push({
279
437
  kind: "worktree-inspection-failed",
280
438
  target: record.key,
@@ -284,26 +442,21 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
284
442
  continue
285
443
  }
286
444
  const exists = yield* fs.isDirectory(checkoutPath)
287
- const registered: RegisteredWorktree[] = []
288
- for (const item of listed.right) {
289
- registered.push({
290
- head: item.commit,
291
- branch:
292
- backend.kind === "jj" && "branch" in checkout
293
- ? checkout.branch
294
- : item.branch,
295
- path: (yield* fs.exists(item.path))
296
- ? yield* fs.realPath(item.path)
297
- : resolve(item.path),
298
- })
299
- }
300
445
  const expectedPath = exists
301
446
  ? yield* fs.realPath(checkoutPath)
302
447
  : (yield* fs.isDirectory(codePath))
303
448
  ? join(yield* fs.realPath(codePath), checkout.repo)
304
449
  : resolve(checkoutPath)
305
450
  let atPath = registered.find((item) => item.path === expectedPath)
306
- if (backend.kind === "jj" && atPath && exists) {
451
+ if (backend.kind === "jj" && atPath && "branch" in checkout) {
452
+ atPath = { ...atPath, branch: checkout.branch }
453
+ }
454
+ if (
455
+ backend.kind === "jj" &&
456
+ atPath &&
457
+ exists &&
458
+ atPath.head === null
459
+ ) {
307
460
  atPath = {
308
461
  ...atPath,
309
462
  head: yield* backend.workspaceHead(checkoutPath),
@@ -415,7 +568,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
415
568
 
416
569
  const dirty =
417
570
  exists && atPath
418
- ? yield* backend.workspaceDirty(checkoutPath)
571
+ ? (atPath.dirty ??
572
+ (yield* backend.workspaceDirty(checkoutPath)))
419
573
  : null
420
574
  if (exists && atPath && dirty === null) {
421
575
  warnings.push({
@@ -487,6 +641,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
487
641
  { silent: true },
488
642
  )
489
643
  for (const checkout of workspace.checkouts) {
644
+ const repositoryPath = join(root, "repos", checkout.repo)
490
645
  const index = checkoutStates.findIndex(
491
646
  (item) => item.repo === checkout.repo,
492
647
  )
@@ -504,6 +659,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
504
659
  : null,
505
660
  dirty: false,
506
661
  }
662
+ const cached = registeredByRepository.get(repositoryPath)
663
+ if (cached) {
664
+ cached.push({
665
+ path: yield* fs.realPath(checkout.path),
666
+ head: checkout.resolvedCommit,
667
+ branch:
668
+ checkout.kind === "writable"
669
+ ? backend.kind === "jj"
670
+ ? checkout.requestedRef
671
+ : `refs/heads/${checkout.requestedRef}`
672
+ : null,
673
+ dirty: false,
674
+ })
675
+ }
507
676
  }
508
677
  }
509
678
  }
@@ -569,6 +738,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
569
738
  checkouts: checkoutStates,
570
739
  pr: { url: null, state: "none" },
571
740
  })
741
+ reportExecution(record.key)
572
742
  continue
573
743
  }
574
744
 
@@ -579,15 +749,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
579
749
  state: "none",
580
750
  }
581
751
  let prConflict = false
582
- const repositoryPath = join(root, "repos", data.repo)
583
- const remoteName = config.delivery?.remote ?? "origin"
584
- const remoteUrl = yield* backend.remoteUrl(repositoryPath, remoteName)
585
- const remoteRepository = (remoteUrl ?? "")
586
- .trim()
587
- .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
588
- .replace(/^[^:]+:/, "")
589
- .replace(/\.git\/?$/, "")
590
- .replace(/\/$/, "")
752
+ const query = prQueries.get(record.key)!
753
+ const { remoteUrl, remoteRepository } = query
591
754
 
592
755
  if (
593
756
  existing &&
@@ -609,18 +772,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
609
772
  message: `Could not inspect delivery remote '${remoteName}'`,
610
773
  })
611
774
  } else if (config.delivery) {
612
- const resolved = resolveDeliveryCommand(config.delivery, "query", {
613
- repository: remoteRepository,
614
- branch: data.branch,
615
- base: data.base,
616
- draft: existing ? String(existing.draft) : "",
617
- url: existing?.url ?? "",
618
- identifier: existing?.identifier ?? "",
619
- })
620
- const queried = yield* runExternal(resolved.argv, {
621
- cwd: repositoryPath,
622
- env: resolved.environment,
623
- })
775
+ const queried = query.result!
624
776
  if (queried.exitCode === 0) {
625
777
  const parsed = yield* Effect.try({
626
778
  try: () => parseOptionalPullRequestRecord(queried.stdout),
@@ -668,14 +820,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
668
820
  })
669
821
  }
670
822
  } else if (existing) {
671
- const viewed = yield* runExternal([
672
- "gh",
673
- "pr",
674
- "view",
675
- existing.url,
676
- "--json",
677
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
678
- ])
823
+ const viewed = query.result!
679
824
  if (viewed.exitCode === 0) {
680
825
  const detail = parseJson<Record<string, unknown>>(
681
826
  viewed.stdout,
@@ -704,20 +849,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
704
849
  })
705
850
  }
706
851
  } else {
707
- const listed = yield* runExternal(
708
- [
709
- "gh",
710
- "pr",
711
- "list",
712
- "--head",
713
- data.branch,
714
- "--state",
715
- "all",
716
- "--json",
717
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
718
- ],
719
- { cwd: repositoryPath },
720
- )
852
+ const listed = query.result!
721
853
  if (listed.exitCode === 0) {
722
854
  const matches = parseJson<Record<string, unknown>[]>(
723
855
  listed.stdout,
@@ -816,11 +948,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
816
948
  checkouts: checkoutStates,
817
949
  pr,
818
950
  })
951
+ reportExecution(record.key)
819
952
  }
820
953
 
821
- for (const task of (yield* tasks.list(root)).filter(
822
- (task) => "review" in task.data,
823
- )) {
954
+ for (const task of reviewRecords) {
824
955
  if (!("review" in task.data)) continue
825
956
  let data = task.data
826
957
  let revision = task.revision
@@ -915,6 +1046,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
915
1046
  sourceAvailable: sourceCommit !== null,
916
1047
  },
917
1048
  })
1049
+ reportExecution(`task:${task.id}`)
918
1050
  }
919
1051
 
920
1052
  return {