@markjaquith/agency 2.34.1 → 2.35.0
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 +1 -1
- package/src/cli.test.ts +68 -0
- package/src/commands/work.test.ts +140 -4
- package/src/commands/work.ts +46 -1
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -862,6 +862,19 @@ status: open
|
|
|
862
862
|
await runGit(["-C", source, "remote", "add", "origin", daemon.remote])
|
|
863
863
|
|
|
864
864
|
parseJson(await runCli(["init", root, "--json"], parent))
|
|
865
|
+
const agencyConfigPath = join(root, "agency.json")
|
|
866
|
+
const agencyConfig = JSON.parse(await Bun.file(agencyConfigPath).text())
|
|
867
|
+
await Bun.write(
|
|
868
|
+
agencyConfigPath,
|
|
869
|
+
`${JSON.stringify(
|
|
870
|
+
{
|
|
871
|
+
...agencyConfig,
|
|
872
|
+
runners: { noop: { command: ["true"] } },
|
|
873
|
+
},
|
|
874
|
+
null,
|
|
875
|
+
2,
|
|
876
|
+
)}\n`,
|
|
877
|
+
)
|
|
865
878
|
parseJson(
|
|
866
879
|
await runCli(["repo", "link", "agency", source, "--json"], root),
|
|
867
880
|
)
|
|
@@ -1066,6 +1079,61 @@ status: open
|
|
|
1066
1079
|
}
|
|
1067
1080
|
}
|
|
1068
1081
|
}
|
|
1082
|
+
|
|
1083
|
+
parseJson(
|
|
1084
|
+
await runCli(["task", "status", "example", "done", "--json"], root),
|
|
1085
|
+
)
|
|
1086
|
+
const blocked = await runCli(
|
|
1087
|
+
["work", "--task", "example", "--runner", "noop"],
|
|
1088
|
+
root,
|
|
1089
|
+
)
|
|
1090
|
+
expect(blocked.exitCode).toBe(1)
|
|
1091
|
+
expect(blocked.stderr).toContain("Task status is done")
|
|
1092
|
+
expect(
|
|
1093
|
+
parseJson(await runCli(["task", "show", "example", "--json"], root))
|
|
1094
|
+
.data.status,
|
|
1095
|
+
).toBe("done")
|
|
1096
|
+
|
|
1097
|
+
const resumedTask = await runCli(
|
|
1098
|
+
["work", "--task", "example", "--runner", "noop", "--force"],
|
|
1099
|
+
root,
|
|
1100
|
+
)
|
|
1101
|
+
expect(resumedTask).toMatchObject({ exitCode: 0, stderr: "" })
|
|
1102
|
+
expect(resumedTask.stdout).toContain(
|
|
1103
|
+
"Reopened task/example from done as working",
|
|
1104
|
+
)
|
|
1105
|
+
expect(
|
|
1106
|
+
parseJson(await runCli(["task", "show", "example", "--json"], root))
|
|
1107
|
+
.data.status,
|
|
1108
|
+
).toBe("working")
|
|
1109
|
+
|
|
1110
|
+
parseJson(
|
|
1111
|
+
await runCli(
|
|
1112
|
+
["phase", "status", "pipeline", "build", "dropped", "--json"],
|
|
1113
|
+
root,
|
|
1114
|
+
),
|
|
1115
|
+
)
|
|
1116
|
+
const resumedPhase = await runCli(
|
|
1117
|
+
[
|
|
1118
|
+
"work",
|
|
1119
|
+
"--task",
|
|
1120
|
+
"pipeline",
|
|
1121
|
+
"--phase",
|
|
1122
|
+
"build",
|
|
1123
|
+
"--runner",
|
|
1124
|
+
"noop",
|
|
1125
|
+
"--force",
|
|
1126
|
+
],
|
|
1127
|
+
root,
|
|
1128
|
+
)
|
|
1129
|
+
expect(resumedPhase).toMatchObject({ exitCode: 0, stderr: "" })
|
|
1130
|
+
expect(resumedPhase.stdout).toContain(
|
|
1131
|
+
"Reopened phase/pipeline/build from dropped as working",
|
|
1132
|
+
)
|
|
1133
|
+
const graph = parseJson(await runCli(["graph", "--json"], root))
|
|
1134
|
+
expect(
|
|
1135
|
+
graph.nodes.find((node: any) => node.id === "task:pipeline").status,
|
|
1136
|
+
).toBe("working")
|
|
1069
1137
|
},
|
|
1070
1138
|
30_000,
|
|
1071
1139
|
)
|
|
@@ -69,10 +69,17 @@ interface HarnessOptions {
|
|
|
69
69
|
readonly registeredWorkbases?: readonly string[]
|
|
70
70
|
readonly existingDirectories?: readonly string[]
|
|
71
71
|
readonly guardError?: Error
|
|
72
|
+
readonly launchError?: Error
|
|
72
73
|
readonly workTargetIds?: readonly string[]
|
|
73
74
|
readonly opencodeIntegrationState?: "managed" | "customized"
|
|
74
75
|
readonly taskStatus?: "open" | "working" | "delegated" | "done" | "dropped"
|
|
75
76
|
readonly phaseStatus?: "open" | "working" | "delegated" | "done" | "dropped"
|
|
77
|
+
readonly taskStatuses?: Readonly<
|
|
78
|
+
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
79
|
+
>
|
|
80
|
+
readonly phaseStatuses?: Readonly<
|
|
81
|
+
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
82
|
+
>
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -92,6 +99,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
92
99
|
const materializeOptions: Array<
|
|
93
100
|
Parameters<WorktreeService["materialize"]>[3]
|
|
94
101
|
> = []
|
|
102
|
+
const taskStatuses = { ...options.taskStatuses }
|
|
103
|
+
const phaseStatuses = { ...options.phaseStatuses }
|
|
95
104
|
const worktrees = {
|
|
96
105
|
materialize: (
|
|
97
106
|
_taskId: string,
|
|
@@ -147,13 +156,19 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
147
156
|
repo: "agency",
|
|
148
157
|
branch: `task/${id}`,
|
|
149
158
|
base: "main",
|
|
150
|
-
status: options.taskStatus ?? "open",
|
|
159
|
+
status: taskStatuses[id] ?? options.taskStatus ?? "open",
|
|
151
160
|
},
|
|
152
161
|
})
|
|
153
162
|
},
|
|
154
163
|
list: () => Effect.succeed(options.taskRecords ?? []),
|
|
155
164
|
setStatus: (id: string, status: string) => {
|
|
156
165
|
statusUpdates.push(`task:${id}:${status}`)
|
|
166
|
+
taskStatuses[id] = status as
|
|
167
|
+
| "open"
|
|
168
|
+
| "working"
|
|
169
|
+
| "delegated"
|
|
170
|
+
| "done"
|
|
171
|
+
| "dropped"
|
|
157
172
|
return Effect.void
|
|
158
173
|
},
|
|
159
174
|
}
|
|
@@ -167,12 +182,19 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
182
|
repo: "agency",
|
|
168
183
|
branch: `task/${id}`,
|
|
169
184
|
base: "main",
|
|
170
|
-
status:
|
|
185
|
+
status:
|
|
186
|
+
phaseStatuses[`${taskId}/${id}`] ?? options.phaseStatus ?? "open",
|
|
171
187
|
},
|
|
172
188
|
}),
|
|
173
189
|
list: () => Effect.succeed(options.phaseRecords ?? []),
|
|
174
190
|
setStatus: (taskId: string, id: string, status: string) => {
|
|
175
191
|
statusUpdates.push(`phase:${taskId}:${id}:${status}`)
|
|
192
|
+
phaseStatuses[`${taskId}/${id}`] = status as
|
|
193
|
+
| "open"
|
|
194
|
+
| "working"
|
|
195
|
+
| "delegated"
|
|
196
|
+
| "done"
|
|
197
|
+
| "dropped"
|
|
176
198
|
return Effect.void
|
|
177
199
|
},
|
|
178
200
|
}
|
|
@@ -241,6 +263,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
241
263
|
events.push(`launch:${cli}`)
|
|
242
264
|
launches.push({ cli, args, cwd })
|
|
243
265
|
launchEnvironments.push(environment)
|
|
266
|
+
if (options.launchError) throw options.launchError
|
|
244
267
|
}
|
|
245
268
|
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
246
269
|
const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
|
|
@@ -284,6 +307,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
284
307
|
launchEnvironments,
|
|
285
308
|
materializeOptions,
|
|
286
309
|
statusUpdates,
|
|
310
|
+
taskStatuses,
|
|
311
|
+
phaseStatuses,
|
|
287
312
|
shownTasks,
|
|
288
313
|
progressUpdates,
|
|
289
314
|
guards,
|
|
@@ -328,6 +353,72 @@ describe("work command", () => {
|
|
|
328
353
|
})
|
|
329
354
|
})
|
|
330
355
|
|
|
356
|
+
test("reopens forced terminal tasks through open before launching as working", async () => {
|
|
357
|
+
for (const previousStatus of ["done", "dropped"] as const) {
|
|
358
|
+
const harness = createHarness({
|
|
359
|
+
taskStatuses: { example: previousStatus },
|
|
360
|
+
})
|
|
361
|
+
const output = await captureLogs(() =>
|
|
362
|
+
harness.run({ taskId: "example", opencode: true, force: true }),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
expect(harness.statusUpdates).toEqual([
|
|
366
|
+
"task:example:open",
|
|
367
|
+
"task:example:working",
|
|
368
|
+
])
|
|
369
|
+
expect(harness.taskStatuses.example).toBe("working")
|
|
370
|
+
expect(output).toEqual([
|
|
371
|
+
`Reopened task/example from ${previousStatus} as working`,
|
|
372
|
+
])
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test("reopens forced terminal phases through open before launching as working", async () => {
|
|
377
|
+
for (const previousStatus of ["done", "dropped"] as const) {
|
|
378
|
+
const harness = createHarness({
|
|
379
|
+
workspace: multiPhaseWorkspace,
|
|
380
|
+
multiPhaseTasks: ["example"],
|
|
381
|
+
phaseStatuses: { "example/implementation": previousStatus },
|
|
382
|
+
})
|
|
383
|
+
const output = await captureLogs(() =>
|
|
384
|
+
harness.run({
|
|
385
|
+
taskId: "example",
|
|
386
|
+
phaseId: "implementation",
|
|
387
|
+
opencode: true,
|
|
388
|
+
force: true,
|
|
389
|
+
}),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
expect(harness.statusUpdates).toEqual([
|
|
393
|
+
"phase:example:implementation:open",
|
|
394
|
+
"phase:example:implementation:working",
|
|
395
|
+
])
|
|
396
|
+
expect(harness.phaseStatuses["example/implementation"]).toBe("working")
|
|
397
|
+
expect(output).toEqual([
|
|
398
|
+
`Reopened phase/example/implementation from ${previousStatus} as working`,
|
|
399
|
+
])
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test("reports a forced reopen as one machine result", async () => {
|
|
404
|
+
const harness = createHarness({ taskStatuses: { example: "done" } })
|
|
405
|
+
const output = await captureLogs(() =>
|
|
406
|
+
harness.run({
|
|
407
|
+
taskId: "example",
|
|
408
|
+
opencode: true,
|
|
409
|
+
force: true,
|
|
410
|
+
json: true,
|
|
411
|
+
}),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
expect(JSON.parse(output.join("\n"))).toEqual({
|
|
415
|
+
target: "task/example",
|
|
416
|
+
reopened: true,
|
|
417
|
+
previousStatus: "done",
|
|
418
|
+
status: "working",
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
331
422
|
test("offers only launchable targets to the interactive chooser", async () => {
|
|
332
423
|
const harness = createHarness({
|
|
333
424
|
taskRecords: [
|
|
@@ -425,7 +516,7 @@ describe("work command", () => {
|
|
|
425
516
|
opencode: true,
|
|
426
517
|
})
|
|
427
518
|
|
|
428
|
-
expect(harness.shownTasks).toEqual(["delivery"])
|
|
519
|
+
expect(harness.shownTasks).toEqual(["delivery", "delivery"])
|
|
429
520
|
expect(harness.launches[0]?.cwd).toBe(taskDirectory)
|
|
430
521
|
})
|
|
431
522
|
|
|
@@ -438,7 +529,7 @@ describe("work command", () => {
|
|
|
438
529
|
opencode: true,
|
|
439
530
|
})
|
|
440
531
|
|
|
441
|
-
expect(harness.shownTasks).toEqual(["delivery"])
|
|
532
|
+
expect(harness.shownTasks).toEqual(["delivery", "delivery"])
|
|
442
533
|
expect(harness.launches[0]?.cwd).toBe(taskDirectory)
|
|
443
534
|
})
|
|
444
535
|
|
|
@@ -829,6 +920,22 @@ describe("work command", () => {
|
|
|
829
920
|
expect(printed.environment.API_TOKEN).toBeUndefined()
|
|
830
921
|
})
|
|
831
922
|
|
|
923
|
+
test("does not reopen a forced terminal target in print-only mode", async () => {
|
|
924
|
+
const harness = createHarness({ taskStatuses: { example: "done" } })
|
|
925
|
+
|
|
926
|
+
await captureLogs(() =>
|
|
927
|
+
harness.run({
|
|
928
|
+
taskId: "example",
|
|
929
|
+
opencode: true,
|
|
930
|
+
force: true,
|
|
931
|
+
printCommand: true,
|
|
932
|
+
}),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
expect(harness.statusUpdates).toEqual([])
|
|
936
|
+
expect(harness.taskStatuses.example).toBe("done")
|
|
937
|
+
})
|
|
938
|
+
|
|
832
939
|
test("prints the runtime OpenCode integration config", async () => {
|
|
833
940
|
const harness = createHarness()
|
|
834
941
|
const output = await captureLogs(() =>
|
|
@@ -933,6 +1040,35 @@ describe("work command", () => {
|
|
|
933
1040
|
])
|
|
934
1041
|
})
|
|
935
1042
|
|
|
1043
|
+
test("does not reopen a terminal target when preparation fails", async () => {
|
|
1044
|
+
const harness = createHarness({
|
|
1045
|
+
taskStatuses: { example: "dropped" },
|
|
1046
|
+
materializeError: new Error("materialization failed"),
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
await expect(
|
|
1050
|
+
harness.run({ taskId: "example", force: true }),
|
|
1051
|
+
).rejects.toThrow("materialization failed")
|
|
1052
|
+
expect(harness.statusUpdates).toEqual([])
|
|
1053
|
+
expect(harness.taskStatuses.example).toBe("dropped")
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
test("retains working when launch fails after a forced reopen", async () => {
|
|
1057
|
+
const harness = createHarness({
|
|
1058
|
+
taskStatuses: { example: "done" },
|
|
1059
|
+
launchError: new Error("launch failed"),
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
await expect(
|
|
1063
|
+
harness.run({ taskId: "example", force: true, silent: true }),
|
|
1064
|
+
).rejects.toThrow("launch failed")
|
|
1065
|
+
expect(harness.statusUpdates).toEqual([
|
|
1066
|
+
"task:example:open",
|
|
1067
|
+
"task:example:working",
|
|
1068
|
+
])
|
|
1069
|
+
expect(harness.taskStatuses.example).toBe("working")
|
|
1070
|
+
})
|
|
1071
|
+
|
|
936
1072
|
test("respects silent and verbose logging options", async () => {
|
|
937
1073
|
const verboseHarness = createHarness()
|
|
938
1074
|
const verboseLogs = await captureErrors(() =>
|
package/src/commands/work.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { IntegrationService } from "../services/IntegrationService"
|
|
|
12
12
|
import { createLoggers } from "../utils/effect"
|
|
13
13
|
import { execvp } from "../utils/exec"
|
|
14
14
|
import { createProgress, type Progress } from "../utils/progress"
|
|
15
|
+
import { isTerminalStatus } from "../readiness"
|
|
15
16
|
import {
|
|
16
17
|
buildWorkTargetChoices,
|
|
17
18
|
pickWorkTarget,
|
|
@@ -379,9 +380,53 @@ export const work = (
|
|
|
379
380
|
return
|
|
380
381
|
}
|
|
381
382
|
if (target.kind === "phase") {
|
|
383
|
+
const previousStatus = (yield* phases.show(
|
|
384
|
+
target.taskId,
|
|
385
|
+
target.phaseId,
|
|
386
|
+
root,
|
|
387
|
+
)).data.status
|
|
388
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
389
|
+
yield* phases.setStatus(target.taskId, target.phaseId, "open", root)
|
|
390
|
+
}
|
|
382
391
|
yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
|
|
392
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
393
|
+
const result = {
|
|
394
|
+
target: `phase/${target.taskId}/${target.phaseId}`,
|
|
395
|
+
reopened: true,
|
|
396
|
+
previousStatus,
|
|
397
|
+
status: "working",
|
|
398
|
+
}
|
|
399
|
+
log(
|
|
400
|
+
options.json
|
|
401
|
+
? JSON.stringify(result)
|
|
402
|
+
: `Reopened ${result.target} from ${previousStatus} as working`,
|
|
403
|
+
)
|
|
404
|
+
}
|
|
383
405
|
} else if (target.kind === "task" && !target.multiPhase) {
|
|
406
|
+
const task = yield* tasks.show(target.taskId, root)
|
|
407
|
+
if ("phases" in task.data) {
|
|
408
|
+
return yield* Effect.fail(
|
|
409
|
+
new Error(`Task '${target.taskId}' is not an execution unit`),
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
const previousStatus = task.data.status
|
|
413
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
414
|
+
yield* tasks.setStatus(target.taskId, "open", root)
|
|
415
|
+
}
|
|
384
416
|
yield* tasks.setStatus(target.taskId, "working", root)
|
|
417
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
418
|
+
const result = {
|
|
419
|
+
target: `task/${target.taskId}`,
|
|
420
|
+
reopened: true,
|
|
421
|
+
previousStatus,
|
|
422
|
+
status: "working",
|
|
423
|
+
}
|
|
424
|
+
log(
|
|
425
|
+
options.json
|
|
426
|
+
? JSON.stringify(result)
|
|
427
|
+
: `Reopened ${result.target} from ${previousStatus} as working`,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
385
430
|
}
|
|
386
431
|
for (const [key, value] of Object.entries(environment)) {
|
|
387
432
|
process.env[key] = value
|
|
@@ -484,7 +529,7 @@ Options:
|
|
|
484
529
|
--print-command Print cwd, argv, and non-secret environment without launch
|
|
485
530
|
--opencode Require the OpenCode preset
|
|
486
531
|
--claude Require the Claude Code preset
|
|
487
|
-
--force Override readiness
|
|
532
|
+
--force Override readiness; reopen terminal execution units
|
|
488
533
|
--no-input Never open an interactive selector
|
|
489
534
|
|
|
490
535
|
Without interactive input, provide an explicit workbase or cwd and an entity
|