@markjaquith/agency 2.71.2 → 2.71.4

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.71.2",
3
+ "version": "2.71.4",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -66,7 +66,9 @@
66
66
  "postinstall": "bun scripts/install-pi-extension.ts install",
67
67
  "preuninstall": "bun scripts/install-pi-extension.ts uninstall",
68
68
  "benchmark:workbase": "bun scripts/benchmark-workbase.ts",
69
+ "benchmark:context": "bun scripts/benchmark-context.ts",
69
70
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
71
+ "benchmark:task": "bun scripts/benchmark-task.ts",
70
72
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
71
73
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
72
74
  "format": "oxfmt",
@@ -1,5 +1,5 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
- import { mkdir, rename, rm } from "node:fs/promises"
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
2
+ import { mkdir, readFile as nodeReadFile, rename, rm } from "node:fs/promises"
3
3
  import { dirname, join } from "node:path"
4
4
  import {
5
5
  captureLogs,
@@ -343,6 +343,31 @@ status: dropped
343
343
  })
344
344
  })
345
345
 
346
+ test("reads each workbase document at most once per invocation", async () => {
347
+ const reads = new Map<string, number>()
348
+ const originalFile = Bun.file
349
+ Bun.file = mock((path: string) => {
350
+ const file = originalFile(path)
351
+ return Object.assign(file, {
352
+ text: async () => {
353
+ reads.set(path, (reads.get(path) ?? 0) + 1)
354
+ return nodeReadFile(path, "utf8")
355
+ },
356
+ })
357
+ }) as unknown as typeof Bun.file
358
+ try {
359
+ await readContext(root, "tasks/agent-contract/phases/context-command")
360
+ } finally {
361
+ Bun.file = originalFile
362
+ }
363
+
364
+ const documents = [...reads].filter(([path]) =>
365
+ /(?:EPIC|TASK|PHASE)\.md$/.test(path),
366
+ )
367
+ expect(documents.length).toBeGreaterThan(0)
368
+ expect(documents.every(([, count]) => count === 1)).toBe(true)
369
+ })
370
+
346
371
  test("resolves a bare task ID and returns root discovery context", async () => {
347
372
  const task = await readContext(root, "agent-contract")
348
373
  expect(task.target).toMatchObject({
@@ -360,8 +360,7 @@ export const task = (
360
360
  return
361
361
  }
362
362
  case "list": {
363
- const records = yield* tasks.list(cwd)
364
- const { taskRows } = yield* getWorkViews({
363
+ const { taskRows, tasks: taskNodes } = yield* getWorkViews({
365
364
  cwd,
366
365
  statuses: options.statuses,
367
366
  repositories: options.repositories,
@@ -369,14 +368,22 @@ export const task = (
369
368
  blocked: options.blocked,
370
369
  pr: options.pr,
371
370
  })
372
- const ordered = taskRows.flatMap((row) => {
373
- const record = records.find((item) => item.id === row.key)
374
- return record ? [record] : []
375
- })
376
371
  if (options.json) {
377
372
  log(
378
373
  JSON.stringify(
379
- ordered.map(({ content: _, ...record }) => record),
374
+ taskRows.flatMap((row) => {
375
+ const node = taskNodes.get(row.key)
376
+ if (!node?.workspace) return []
377
+ const { sha256, ...data } = node.data
378
+ return [
379
+ {
380
+ id: row.key,
381
+ path: node.workspace.documentPath,
382
+ revision: sha256,
383
+ data,
384
+ },
385
+ ]
386
+ }),
380
387
  null,
381
388
  2,
382
389
  ),
@@ -1,5 +1,5 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
- import { Data, Effect, Either } from "effect"
2
+ import { Data, Deferred, Effect, Either, Exit } from "effect"
3
3
  import { join, relative, resolve, sep } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
@@ -131,10 +131,76 @@ export class ContextService extends Effect.Service<ContextService>()(
131
131
  readonly full?: boolean
132
132
  }) =>
133
133
  Effect.gen(function* () {
134
- const fs = yield* FileSystemService
134
+ const sourceFs = yield* FileSystemService
135
135
  const workbase = yield* WorkbaseService
136
136
  const repositoryService = yield* RepositoryService
137
137
  const versionControl = yield* VersionControlService
138
+ const memoize = <A, E, R>(
139
+ cache: Map<string, Deferred.Deferred<A, E>>,
140
+ key: string,
141
+ effect: Effect.Effect<A, E, R>,
142
+ ) => {
143
+ const cached = cache.get(key)
144
+ if (cached) return Deferred.await(cached)
145
+ return Effect.gen(function* () {
146
+ const deferred = yield* Deferred.make<A, E>()
147
+ cache.set(key, deferred)
148
+ const exit = yield* Effect.exit(effect)
149
+ yield* Deferred.done(deferred, exit)
150
+ return yield* Exit.match(exit, {
151
+ onFailure: Effect.failCause,
152
+ onSuccess: Effect.succeed,
153
+ })
154
+ })
155
+ }
156
+ const existsCache = new Map<
157
+ string,
158
+ Deferred.Deferred<
159
+ Effect.Effect.Success<ReturnType<typeof sourceFs.exists>>,
160
+ Effect.Effect.Error<ReturnType<typeof sourceFs.exists>>
161
+ >
162
+ >()
163
+ const directoryCache = new Map<
164
+ string,
165
+ Deferred.Deferred<
166
+ Effect.Effect.Success<ReturnType<typeof sourceFs.isDirectory>>,
167
+ Effect.Effect.Error<ReturnType<typeof sourceFs.isDirectory>>
168
+ >
169
+ >()
170
+ const readCache = new Map<
171
+ string,
172
+ Deferred.Deferred<
173
+ Effect.Effect.Success<ReturnType<typeof sourceFs.readFile>>,
174
+ Effect.Effect.Error<ReturnType<typeof sourceFs.readFile>>
175
+ >
176
+ >()
177
+ const listingCache = new Map<
178
+ string,
179
+ Deferred.Deferred<
180
+ Effect.Effect.Success<ReturnType<typeof sourceFs.readDirectory>>,
181
+ Effect.Effect.Error<ReturnType<typeof sourceFs.readDirectory>>
182
+ >
183
+ >()
184
+ const realPathCache = new Map<
185
+ string,
186
+ Deferred.Deferred<
187
+ Effect.Effect.Success<ReturnType<typeof sourceFs.realPath>>,
188
+ Effect.Effect.Error<ReturnType<typeof sourceFs.realPath>>
189
+ >
190
+ >()
191
+ const fs = {
192
+ ...sourceFs,
193
+ exists: (path: string) =>
194
+ memoize(existsCache, path, sourceFs.exists(path)),
195
+ isDirectory: (path: string) =>
196
+ memoize(directoryCache, path, sourceFs.isDirectory(path)),
197
+ readFile: (path: string) =>
198
+ memoize(readCache, path, sourceFs.readFile(path)),
199
+ readDirectory: (path: string) =>
200
+ memoize(listingCache, path, sourceFs.readDirectory(path)),
201
+ realPath: (path: string) =>
202
+ memoize(realPathCache, path, sourceFs.realPath(path)),
203
+ } satisfies FileSystemService
138
204
  const cwd = resolve(options.cwd ?? process.cwd())
139
205
  const suppliedTarget = options.target ?? "."
140
206
  const candidate = resolve(cwd, suppliedTarget)
@@ -142,7 +208,66 @@ export class ContextService extends Effect.Service<ContextService>()(
142
208
  const { root, config } = yield* workbase.loadConfig(
143
209
  candidateExists ? candidate : cwd,
144
210
  )
145
- const backend = yield* versionControl.forWorkbase(root)
211
+ const sourceBackend = yield* versionControl.forWorkbase(root)
212
+ const revisionCache = new Map<
213
+ string,
214
+ Deferred.Deferred<
215
+ Effect.Effect.Success<
216
+ ReturnType<typeof sourceBackend.resolveRevision>
217
+ >,
218
+ Effect.Effect.Error<
219
+ ReturnType<typeof sourceBackend.resolveRevision>
220
+ >
221
+ >
222
+ >()
223
+ const workspaceCache = new Map<
224
+ string,
225
+ Deferred.Deferred<
226
+ Effect.Effect.Success<
227
+ ReturnType<typeof sourceBackend.listWorkspaces>
228
+ >,
229
+ Effect.Effect.Error<
230
+ ReturnType<typeof sourceBackend.listWorkspaces>
231
+ >
232
+ >
233
+ >()
234
+ const backend = {
235
+ ...sourceBackend,
236
+ resolveRevision: (repositoryPath: string, revision: string) =>
237
+ memoize(
238
+ revisionCache,
239
+ `${repositoryPath}\0${revision}`,
240
+ sourceBackend.resolveRevision(repositoryPath, revision),
241
+ ),
242
+ listWorkspaces: (repositoryPath: string) =>
243
+ memoize(
244
+ workspaceCache,
245
+ repositoryPath,
246
+ sourceBackend.listWorkspaces(repositoryPath),
247
+ ),
248
+ }
249
+ const contextWorkbase = {
250
+ ...workbase,
251
+ discover: (startPath?: string) =>
252
+ workbase
253
+ .discover(startPath)
254
+ .pipe(Effect.provideService(FileSystemService, fs)),
255
+ loadConfig: (startPath?: string) =>
256
+ workbase
257
+ .loadConfig(startPath)
258
+ .pipe(Effect.provideService(FileSystemService, fs)),
259
+ repositoryAliases: (startPath?: string) =>
260
+ workbase
261
+ .repositoryAliases(startPath)
262
+ .pipe(Effect.provideService(FileSystemService, fs)),
263
+ }
264
+ const provideContextServices = <A, E, R>(
265
+ effect: Effect.Effect<A, E, R>,
266
+ ) =>
267
+ effect.pipe(
268
+ Effect.provideService(FileSystemService, fs),
269
+ Effect.provideService(WorkbaseService, contextWorkbase),
270
+ )
146
271
 
147
272
  if (relative(root, candidate) === "") {
148
273
  const compact = !options.full
@@ -216,7 +341,9 @@ export class ContextService extends Effect.Service<ContextService>()(
216
341
  }
217
342
  }
218
343
 
219
- const validation = yield* workbase.validate(root)
344
+ const validation = yield* provideContextServices(
345
+ workbase.validate(root),
346
+ )
220
347
  return {
221
348
  projection: compact ? "compact" : "complete",
222
349
  workbase: {
@@ -593,7 +720,9 @@ export class ContextService extends Effect.Service<ContextService>()(
593
720
  })
594
721
  }
595
722
 
596
- const validation = yield* workbase.validate(root)
723
+ const validation = yield* provideContextServices(
724
+ workbase.validate(root),
725
+ )
597
726
  const relevantPaths = new Set<string>(
598
727
  [epic?.path, task?.path, phase?.path]
599
728
  .filter((path): path is string => Boolean(path))
@@ -888,10 +1017,9 @@ export class ContextService extends Effect.Service<ContextService>()(
888
1017
  const codePath = join(entityDirectory, "code")
889
1018
  const inspectionWarnings: string[] = []
890
1019
  const repositories = new Map(
891
- (yield* repositoryService.list(root)).map((repository) => [
892
- repository.alias,
893
- repository,
894
- ]),
1020
+ (yield* provideContextServices(repositoryService.list(root))).map(
1021
+ (repository) => [repository.alias, repository],
1022
+ ),
895
1023
  )
896
1024
 
897
1025
  const inspectCheckout = (
package/src/work-view.ts CHANGED
@@ -262,5 +262,5 @@ export const getWorkViews = (options: WorkViewOptions = {}) =>
262
262
  }
263
263
  const executionRows = filterRows(makeRows(executionEntities))
264
264
 
265
- return { epicRows, taskRows, phaseRows, executionRows }
265
+ return { epicRows, taskRows, phaseRows, executionRows, tasks }
266
266
  })