@markjaquith/agency 3.2.0 → 3.2.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/README.md +10 -2
- package/cli-main.ts +38 -0
- package/package.json +1 -1
- package/src/commands/context.test.ts +56 -0
- package/src/commands/push.test.ts +4 -2
- package/src/commands/push.ts +2 -0
- package/src/protocol.test.ts +24 -0
- package/src/protocol.ts +56 -4
- package/src/services/ContextService.ts +157 -200
- package/src/services/FileSystemService.ts +2 -0
- package/src/services/PushService.test.ts +110 -4
- package/src/services/PushService.ts +435 -85
- package/src/usage-log.test.ts +11 -1
- package/src/usage-log.ts +22 -4
- package/src/utils/process.test.ts +10 -0
- package/src/utils/process.ts +59 -6
package/README.md
CHANGED
|
@@ -1107,13 +1107,21 @@ without creating a pull request. It requires a valid, registered writable
|
|
|
1107
1107
|
checkout in `working` state, fetches the configured delivery remote, verifies
|
|
1108
1108
|
that the declared base is in the publication history, validates every outgoing
|
|
1109
1109
|
commit's description, author, and conflict state, and refuses non-fast-forward
|
|
1110
|
-
updates.
|
|
1110
|
+
updates. Local commit validation runs before network access when cached remote
|
|
1111
|
+
state is available, and the network fetch is limited to the declared base and
|
|
1112
|
+
delivery branch.
|
|
1111
1113
|
|
|
1112
1114
|
YAML `branch` must exactly match the checked-out local branch, the worktree must
|
|
1113
1115
|
be clean, and `HEAD` is pushed with upstream tracking. Missing commit descriptions
|
|
1114
1116
|
or authors stop publication with exact commits and remediation commands. Push
|
|
1115
1117
|
reports deterministic fetch, inspection, validation, and publication progress on
|
|
1116
|
-
stderr, including while `--json` reserves stdout for one machine result.
|
|
1118
|
+
stderr, including while `--json` reserves stdout for one machine result. Git
|
|
1119
|
+
authentication is non-interactive. Fetch defaults to a 30-second deadline and
|
|
1120
|
+
one retry for transient failures; push defaults to a 120-second deadline. Set
|
|
1121
|
+
`AGENCY_PUSH_FETCH_TIMEOUT_MS` or `AGENCY_PUSH_TIMEOUT_MS` to positive millisecond
|
|
1122
|
+
values to override them. After a failed or timed-out push, Agency compares the
|
|
1123
|
+
exact remote delivery ref with the expected tip before reporting success, a safe
|
|
1124
|
+
retryable failure, or an unknown publication outcome.
|
|
1117
1125
|
|
|
1118
1126
|
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
1119
1127
|
including readiness checks and durable PR recording. It accepts draft, title,
|
package/cli-main.ts
CHANGED
|
@@ -828,6 +828,42 @@ let usageFlagNames = rawArguments
|
|
|
828
828
|
.filter((argument) => argument.startsWith("--"))
|
|
829
829
|
.map((argument) => argument.slice(2).split("=", 1)[0]!)
|
|
830
830
|
|
|
831
|
+
const pushUsageDetails = (error?: unknown) => {
|
|
832
|
+
if (usageCommandPath !== "push") return {}
|
|
833
|
+
const seen = new Set<object>()
|
|
834
|
+
const visit = (
|
|
835
|
+
value: unknown,
|
|
836
|
+
): { stage?: string; category?: string } | null => {
|
|
837
|
+
if (typeof value !== "object" || value === null || seen.has(value))
|
|
838
|
+
return null
|
|
839
|
+
seen.add(value)
|
|
840
|
+
if (
|
|
841
|
+
"stage" in value &&
|
|
842
|
+
typeof value.stage === "string" &&
|
|
843
|
+
"category" in value &&
|
|
844
|
+
typeof value.category === "string"
|
|
845
|
+
) {
|
|
846
|
+
return { stage: value.stage, category: value.category }
|
|
847
|
+
}
|
|
848
|
+
for (const nestedValue of [
|
|
849
|
+
...Object.values(value),
|
|
850
|
+
...Object.getOwnPropertySymbols(value).map(
|
|
851
|
+
(symbol) => (value as Record<symbol, unknown>)[symbol],
|
|
852
|
+
),
|
|
853
|
+
]) {
|
|
854
|
+
const nested = visit(nestedValue)
|
|
855
|
+
if (nested) return nested
|
|
856
|
+
}
|
|
857
|
+
return null
|
|
858
|
+
}
|
|
859
|
+
const details = error ? visit(error) : null
|
|
860
|
+
return {
|
|
861
|
+
vcs: "git" as const,
|
|
862
|
+
terminalStage: details?.stage ?? (error ? "unknown" : "publish"),
|
|
863
|
+
category: details?.category ?? (error ? "unknown" : "success"),
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
831
867
|
try {
|
|
832
868
|
const {
|
|
833
869
|
commandName,
|
|
@@ -921,6 +957,7 @@ try {
|
|
|
921
957
|
durationMs: performance.now() - invocationStartedAt,
|
|
922
958
|
outcome: exitStatus === 0 ? "success" : "failure",
|
|
923
959
|
exitStatus,
|
|
960
|
+
...pushUsageDetails(),
|
|
924
961
|
},
|
|
925
962
|
VERSION,
|
|
926
963
|
)
|
|
@@ -932,6 +969,7 @@ try {
|
|
|
932
969
|
durationMs: performance.now() - invocationStartedAt,
|
|
933
970
|
outcome: "failure",
|
|
934
971
|
exitStatus: 1,
|
|
972
|
+
...pushUsageDetails(error),
|
|
935
973
|
},
|
|
936
974
|
VERSION,
|
|
937
975
|
)
|
package/package.json
CHANGED
|
@@ -282,6 +282,20 @@ status: dropped
|
|
|
282
282
|
})
|
|
283
283
|
|
|
284
284
|
test("reads each workbase document at most once per invocation", async () => {
|
|
285
|
+
const unrelatedArchive = join(root, "archive/tasks/unrelated/TASK.md")
|
|
286
|
+
await write(
|
|
287
|
+
root,
|
|
288
|
+
"archive/tasks/unrelated/TASK.md",
|
|
289
|
+
`---
|
|
290
|
+
ticketUrl: null
|
|
291
|
+
repo: agency
|
|
292
|
+
branch: unrelated
|
|
293
|
+
base: main
|
|
294
|
+
pr: null
|
|
295
|
+
status: done
|
|
296
|
+
---
|
|
297
|
+
`,
|
|
298
|
+
)
|
|
285
299
|
const reads = new Map<string, number>()
|
|
286
300
|
const originalFile = Bun.file
|
|
287
301
|
Bun.file = mock((path: string) => {
|
|
@@ -304,6 +318,48 @@ status: dropped
|
|
|
304
318
|
)
|
|
305
319
|
expect(documents.length).toBeGreaterThan(0)
|
|
306
320
|
expect(documents.every(([, count]) => count === 1)).toBe(true)
|
|
321
|
+
expect(reads.has(unrelatedArchive)).toBe(false)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test("limits repository inspection and overlaps independent Git observations", async () => {
|
|
325
|
+
const commands: string[][] = []
|
|
326
|
+
let activeGitCommands = 0
|
|
327
|
+
let maxActiveGitCommands = 0
|
|
328
|
+
const originalSpawn = Bun.spawn
|
|
329
|
+
;(Bun as { spawn: unknown }).spawn = mock(((
|
|
330
|
+
args: string[],
|
|
331
|
+
options: Parameters<typeof Bun.spawn>[1],
|
|
332
|
+
) => {
|
|
333
|
+
const command = [...args]
|
|
334
|
+
const child = originalSpawn(command, options)
|
|
335
|
+
if (command[0] !== "git") return child
|
|
336
|
+
commands.push(command)
|
|
337
|
+
activeGitCommands += 1
|
|
338
|
+
maxActiveGitCommands = Math.max(maxActiveGitCommands, activeGitCommands)
|
|
339
|
+
const exited = child.exited.finally(() => {
|
|
340
|
+
activeGitCommands -= 1
|
|
341
|
+
})
|
|
342
|
+
return new Proxy(child, {
|
|
343
|
+
get(target, property) {
|
|
344
|
+
return property === "exited"
|
|
345
|
+
? exited
|
|
346
|
+
: Reflect.get(target, property, target)
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}) as unknown as typeof Bun.spawn)
|
|
350
|
+
try {
|
|
351
|
+
await readContext(root, "tasks/agent-contract/phases/context-command")
|
|
352
|
+
} finally {
|
|
353
|
+
;(Bun as { spawn: unknown }).spawn = originalSpawn
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
expect(
|
|
357
|
+
commands.some(
|
|
358
|
+
(command) =>
|
|
359
|
+
command.includes("config") && command.includes("--show-origin"),
|
|
360
|
+
),
|
|
361
|
+
).toBe(false)
|
|
362
|
+
expect(maxActiveGitCommands).toBeGreaterThanOrEqual(3)
|
|
307
363
|
})
|
|
308
364
|
|
|
309
365
|
test("resolves a bare task ID and returns root discovery context", async () => {
|
|
@@ -28,9 +28,10 @@ describe("push command", () => {
|
|
|
28
28
|
publish: (_cwd: string, options: any) => {
|
|
29
29
|
for (const stage of [
|
|
30
30
|
"context",
|
|
31
|
-
"fetch",
|
|
32
31
|
"inspect",
|
|
33
32
|
"validate",
|
|
33
|
+
"fetch",
|
|
34
|
+
"validate",
|
|
34
35
|
"publish",
|
|
35
36
|
] as const)
|
|
36
37
|
options.onProgress(stage)
|
|
@@ -44,9 +45,10 @@ describe("push command", () => {
|
|
|
44
45
|
expect(logs).toEqual([JSON.stringify(result, null, 2)])
|
|
45
46
|
expect(updates).toEqual([
|
|
46
47
|
"start:Inspecting Agency execution context",
|
|
47
|
-
"start:Fetching remote state",
|
|
48
48
|
"start:Selecting the publication tip",
|
|
49
49
|
"start:Validating outgoing changes",
|
|
50
|
+
"start:Fetching remote state",
|
|
51
|
+
"start:Validating outgoing changes",
|
|
50
52
|
"start:Publishing the declared branch",
|
|
51
53
|
"succeed:Published task/example to origin",
|
|
52
54
|
])
|
package/src/commands/push.ts
CHANGED
|
@@ -10,6 +10,7 @@ const stageMessage = {
|
|
|
10
10
|
inspect: "Selecting the publication tip",
|
|
11
11
|
validate: "Validating outgoing changes",
|
|
12
12
|
publish: "Publishing the declared branch",
|
|
13
|
+
reconcile: "Confirming the remote publication outcome",
|
|
13
14
|
} as const
|
|
14
15
|
|
|
15
16
|
export const push = (
|
|
@@ -22,6 +23,7 @@ export const push = (
|
|
|
22
23
|
const showProgress = !options.silent
|
|
23
24
|
const result = yield* publications
|
|
24
25
|
.publish(options.cwd ?? process.cwd(), {
|
|
26
|
+
forwardOutput: options.verbose,
|
|
25
27
|
onProgress: showProgress
|
|
26
28
|
? (stage) => progress.start(stageMessage[stage])
|
|
27
29
|
: undefined,
|
package/src/protocol.test.ts
CHANGED
|
@@ -161,4 +161,28 @@ describe("machine protocol", () => {
|
|
|
161
161
|
},
|
|
162
162
|
})
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
test("uses dynamic protocol metadata without duplicating it in fields", () => {
|
|
166
|
+
expect(
|
|
167
|
+
errorEnvelope({
|
|
168
|
+
_tag: "PushError",
|
|
169
|
+
message: "remote timed out",
|
|
170
|
+
protocolCode: "PUSH_TIMEOUT",
|
|
171
|
+
retryable: true,
|
|
172
|
+
remediation: "Retry after checking connectivity.",
|
|
173
|
+
category: "timeout",
|
|
174
|
+
stage: "fetch",
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({
|
|
177
|
+
version: 1,
|
|
178
|
+
ok: false,
|
|
179
|
+
error: {
|
|
180
|
+
code: "PUSH_TIMEOUT",
|
|
181
|
+
message: "remote timed out",
|
|
182
|
+
fields: { category: "timeout", stage: "fetch" },
|
|
183
|
+
retryable: true,
|
|
184
|
+
remediation: "Retry after checking connectivity.",
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
})
|
|
164
188
|
})
|
package/src/protocol.ts
CHANGED
|
@@ -198,6 +198,36 @@ const errorTag = (error: unknown): string | undefined => {
|
|
|
198
198
|
return undefined
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
const unwrapEffectError = (error: unknown): unknown => {
|
|
202
|
+
const seen = new Set<object>()
|
|
203
|
+
const visit = (value: unknown): unknown => {
|
|
204
|
+
if (typeof value !== "object" || value === null || seen.has(value))
|
|
205
|
+
return null
|
|
206
|
+
seen.add(value)
|
|
207
|
+
if ("protocolCode" in value && typeof value.protocolCode === "string") {
|
|
208
|
+
return value
|
|
209
|
+
}
|
|
210
|
+
for (const nested of [
|
|
211
|
+
...Object.values(value),
|
|
212
|
+
...Object.getOwnPropertySymbols(value).map(
|
|
213
|
+
(symbol) => (value as Record<symbol, unknown>)[symbol],
|
|
214
|
+
),
|
|
215
|
+
]) {
|
|
216
|
+
const found = visit(nested)
|
|
217
|
+
if (found) return found
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
"_tag" in value &&
|
|
221
|
+
typeof value._tag === "string" &&
|
|
222
|
+
value._tag.endsWith("Error")
|
|
223
|
+
) {
|
|
224
|
+
return value
|
|
225
|
+
}
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
return visit(error) ?? error
|
|
229
|
+
}
|
|
230
|
+
|
|
201
231
|
const errorMessage = (error: unknown): string => {
|
|
202
232
|
if (
|
|
203
233
|
typeof error === "object" &&
|
|
@@ -219,6 +249,9 @@ const errorFields = (error: unknown): Record<string, unknown> => {
|
|
|
219
249
|
key !== "name" &&
|
|
220
250
|
key !== "message" &&
|
|
221
251
|
key !== "cause" &&
|
|
252
|
+
key !== "protocolCode" &&
|
|
253
|
+
key !== "retryable" &&
|
|
254
|
+
key !== "remediation" &&
|
|
222
255
|
value !== undefined,
|
|
223
256
|
),
|
|
224
257
|
)
|
|
@@ -231,17 +264,36 @@ export const successEnvelope = (result: unknown): SuccessEnvelope => ({
|
|
|
231
264
|
})
|
|
232
265
|
|
|
233
266
|
export const errorEnvelope = (error: unknown): ErrorEnvelope => {
|
|
234
|
-
const
|
|
267
|
+
const normalized = unwrapEffectError(error)
|
|
268
|
+
const defaults = errorMetadata[errorTag(normalized) ?? ""] ?? {
|
|
235
269
|
code: "COMMAND_FAILED",
|
|
236
270
|
retryable: false,
|
|
237
271
|
}
|
|
272
|
+
const dynamic =
|
|
273
|
+
typeof normalized === "object" && normalized !== null
|
|
274
|
+
? {
|
|
275
|
+
...("protocolCode" in normalized &&
|
|
276
|
+
typeof normalized.protocolCode === "string"
|
|
277
|
+
? { code: normalized.protocolCode }
|
|
278
|
+
: {}),
|
|
279
|
+
...("retryable" in normalized &&
|
|
280
|
+
typeof normalized.retryable === "boolean"
|
|
281
|
+
? { retryable: normalized.retryable }
|
|
282
|
+
: {}),
|
|
283
|
+
...("remediation" in normalized &&
|
|
284
|
+
typeof normalized.remediation === "string"
|
|
285
|
+
? { remediation: normalized.remediation }
|
|
286
|
+
: {}),
|
|
287
|
+
}
|
|
288
|
+
: {}
|
|
238
289
|
return {
|
|
239
290
|
version: PROTOCOL_VERSION,
|
|
240
291
|
ok: false,
|
|
241
292
|
error: {
|
|
242
|
-
...
|
|
243
|
-
|
|
244
|
-
|
|
293
|
+
...defaults,
|
|
294
|
+
...dynamic,
|
|
295
|
+
message: errorMessage(normalized),
|
|
296
|
+
fields: errorFields(normalized),
|
|
245
297
|
},
|
|
246
298
|
}
|
|
247
299
|
}
|