@markjaquith/agency 2.60.0 → 2.61.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/README.md +50 -0
- package/cli-main.ts +2 -0
- package/fixtures/protocol/orchestration-recipes.json +19 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +53 -0
- package/src/cli-parser.test.ts +49 -1
- package/src/cli-parser.ts +30 -1
- package/src/cli.test.ts +10 -1
- package/src/commands/task-phase.test.ts +49 -0
- package/src/commands/task.ts +47 -1
- package/src/graph-schema.test.ts +8 -0
- package/src/graph-schema.ts +7 -0
- package/src/services/ArchiveService.test.ts +18 -1
- package/src/services/GraphMutationService.test.ts +48 -0
- package/src/services/IntegrationService.test.ts +10 -0
- package/src/services/LifecycleTransaction.ts +12 -0
- package/src/services/PhaseService.ts +2 -0
- package/src/services/PullRequestService.ts +11 -2
- package/src/services/SyncService.test.ts +90 -3
- package/src/services/SyncService.ts +27 -21
- package/src/services/TaskPhaseService.test.ts +238 -0
- package/src/services/TaskService.ts +265 -6
- package/src/services/WorkbaseService.ts +30 -0
- package/src/workbase/AGENTS.md +25 -0
- package/src/workbase/delivery-command.test.ts +18 -2
- package/src/workbase/delivery-command.ts +13 -0
- package/src/workbase/frontmatter.test.ts +18 -1
- package/src/workbase/frontmatter.ts +29 -1
- package/src/workbase/opencode-file.ts +1 -1
- package/src/workbase/schemas.test.ts +36 -0
- package/src/workbase/schemas.ts +37 -1
|
@@ -25,6 +25,18 @@ export interface TransactionStep {
|
|
|
25
25
|
readonly manualRecovery?: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export const pathMustNotExistStep = (
|
|
29
|
+
root: string,
|
|
30
|
+
path: string,
|
|
31
|
+
message: string,
|
|
32
|
+
): TransactionStep => ({
|
|
33
|
+
label: `verify ${relative(root, path)} is available`,
|
|
34
|
+
preflight: async () => {
|
|
35
|
+
if (await exists(path)) throw new Error(message)
|
|
36
|
+
},
|
|
37
|
+
apply: async () => {},
|
|
38
|
+
})
|
|
39
|
+
|
|
28
40
|
interface DocumentWrite {
|
|
29
41
|
readonly path: string
|
|
30
42
|
readonly content: string
|
|
@@ -249,6 +249,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
249
249
|
? { description: task.data.description }
|
|
250
250
|
: {}),
|
|
251
251
|
...(task.data.epic ? { epic: task.data.epic } : {}),
|
|
252
|
+
...(task.data.purpose ? { purpose: task.data.purpose } : {}),
|
|
253
|
+
...(task.data.handoff ? { handoff: task.data.handoff } : {}),
|
|
252
254
|
phases: [
|
|
253
255
|
{ id: firstPhaseId! },
|
|
254
256
|
{
|
|
@@ -239,7 +239,15 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
239
239
|
})
|
|
240
240
|
if (!url)
|
|
241
241
|
throw new Error("GitHub CLI did not return a pull request URL")
|
|
242
|
-
|
|
242
|
+
const normalized = normalizePullRequestRecord(url)
|
|
243
|
+
return {
|
|
244
|
+
...normalized,
|
|
245
|
+
headRepository: repository,
|
|
246
|
+
headBranch: execution.branch,
|
|
247
|
+
baseRepository: normalized.repository,
|
|
248
|
+
baseBranch: execution.base,
|
|
249
|
+
draft,
|
|
250
|
+
}
|
|
243
251
|
},
|
|
244
252
|
catch: (cause) =>
|
|
245
253
|
new PullRequestError({
|
|
@@ -249,7 +257,8 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
249
257
|
if (
|
|
250
258
|
config.delivery &&
|
|
251
259
|
(record.provider !== config.delivery.provider ||
|
|
252
|
-
record.repository.toLowerCase() !==
|
|
260
|
+
(record.headRepository ?? record.repository).toLowerCase() !==
|
|
261
|
+
repository.toLowerCase())
|
|
253
262
|
) {
|
|
254
263
|
return yield* new PullRequestError({
|
|
255
264
|
message:
|
|
@@ -66,12 +66,12 @@ case "$*" in
|
|
|
66
66
|
esac
|
|
67
67
|
if [ "$2" = "view" ]; then
|
|
68
68
|
cat <<'JSON'
|
|
69
|
-
{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
|
|
69
|
+
{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
|
|
70
70
|
JSON
|
|
71
71
|
exit 0
|
|
72
72
|
fi
|
|
73
73
|
cat <<'JSON'
|
|
74
|
-
[{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
|
|
74
|
+
[{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
|
|
75
75
|
JSON
|
|
76
76
|
`,
|
|
77
77
|
)
|
|
@@ -307,6 +307,10 @@ pr: null
|
|
|
307
307
|
state: "merged",
|
|
308
308
|
draft: false,
|
|
309
309
|
merged: true,
|
|
310
|
+
headRepository: "example/agency",
|
|
311
|
+
headBranch: "feat/example",
|
|
312
|
+
baseRepository: "example/agency",
|
|
313
|
+
baseBranch: "main",
|
|
310
314
|
mergeable: true,
|
|
311
315
|
},
|
|
312
316
|
claim: { state: "released", sessionId: "session-1" },
|
|
@@ -357,7 +361,7 @@ pr: null
|
|
|
357
361
|
join(root, "bin", "gh"),
|
|
358
362
|
`#!/bin/sh
|
|
359
363
|
cat <<'JSON'
|
|
360
|
-
{"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
|
|
364
|
+
{"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
|
|
361
365
|
JSON
|
|
362
366
|
`,
|
|
363
367
|
)
|
|
@@ -747,6 +751,89 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
747
751
|
expect(task.data).toMatchObject({ status: "open" })
|
|
748
752
|
})
|
|
749
753
|
|
|
754
|
+
test("reconciles a merged upstream PR whose head is the writable fork", async () => {
|
|
755
|
+
await runTestEffect(
|
|
756
|
+
TaskService.pipe(
|
|
757
|
+
Effect.flatMap((service) =>
|
|
758
|
+
service.create(
|
|
759
|
+
{
|
|
760
|
+
id: "forked",
|
|
761
|
+
ticketUrl: null,
|
|
762
|
+
repo: "agency",
|
|
763
|
+
branch: "task/polish-readme",
|
|
764
|
+
base: "main",
|
|
765
|
+
},
|
|
766
|
+
root,
|
|
767
|
+
),
|
|
768
|
+
),
|
|
769
|
+
),
|
|
770
|
+
)
|
|
771
|
+
await runTestEffect(
|
|
772
|
+
WorktreeService.pipe(
|
|
773
|
+
Effect.flatMap((service) =>
|
|
774
|
+
service.materialize("forked", undefined, root),
|
|
775
|
+
),
|
|
776
|
+
),
|
|
777
|
+
)
|
|
778
|
+
await git(
|
|
779
|
+
["remote", "set-url", "origin", "git@github.com:markjaquith/Pasted.git"],
|
|
780
|
+
join(root, "repos/agency"),
|
|
781
|
+
)
|
|
782
|
+
await runTestEffect(
|
|
783
|
+
PullRequestService.pipe(
|
|
784
|
+
Effect.flatMap((service) =>
|
|
785
|
+
service.setUrl(
|
|
786
|
+
"forked",
|
|
787
|
+
undefined,
|
|
788
|
+
"https://github.com/getpasted/pasted/pull/17",
|
|
789
|
+
root,
|
|
790
|
+
),
|
|
791
|
+
),
|
|
792
|
+
),
|
|
793
|
+
)
|
|
794
|
+
await Bun.write(
|
|
795
|
+
join(root, "bin", "gh"),
|
|
796
|
+
`#!/bin/sh
|
|
797
|
+
cat <<'JSON'
|
|
798
|
+
{"number":17,"state":"MERGED","title":"Polish README","isDraft":false,"headRefName":"task/polish-readme","baseRefName":"main","headRepository":{"nameWithOwner":"markjaquith/Pasted"},"baseRepository":{"nameWithOwner":"getpasted/pasted"},"url":"https://github.com/getpasted/pasted/pull/17","mergedAt":"2026-08-11T00:00:00Z","mergeCommit":{"oid":"241d45da1115ef685c91a5e6882d118c16801550"},"mergeable":"UNKNOWN"}
|
|
799
|
+
JSON
|
|
800
|
+
`,
|
|
801
|
+
)
|
|
802
|
+
await chmod(join(root, "bin", "gh"), 0o755)
|
|
803
|
+
|
|
804
|
+
const applied = await runTestEffect(
|
|
805
|
+
SyncService.pipe(
|
|
806
|
+
Effect.flatMap((service) =>
|
|
807
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
808
|
+
),
|
|
809
|
+
),
|
|
810
|
+
)
|
|
811
|
+
expect(
|
|
812
|
+
applied.unresolved.filter((notice) => notice.target === "task:forked"),
|
|
813
|
+
).toEqual([])
|
|
814
|
+
expect(applied.changes.map((change) => change.kind)).toEqual([
|
|
815
|
+
"record-pr",
|
|
816
|
+
"mark-done",
|
|
817
|
+
])
|
|
818
|
+
const task = await runTestEffect(
|
|
819
|
+
TaskService.pipe(
|
|
820
|
+
Effect.flatMap((service) => service.show("forked", root)),
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
expect(task.data).toMatchObject({
|
|
824
|
+
status: "done",
|
|
825
|
+
pr: {
|
|
826
|
+
repository: "getpasted/pasted",
|
|
827
|
+
headRepository: "markjaquith/Pasted",
|
|
828
|
+
headBranch: "task/polish-readme",
|
|
829
|
+
baseRepository: "getpasted/pasted",
|
|
830
|
+
baseBranch: "main",
|
|
831
|
+
state: "merged",
|
|
832
|
+
merged: true,
|
|
833
|
+
},
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
750
837
|
test("leaves non-PR completion unchanged when a matching PR is discoverable", async () => {
|
|
751
838
|
await runTestEffect(
|
|
752
839
|
TaskService.pipe(
|
|
@@ -349,7 +349,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
349
349
|
"view",
|
|
350
350
|
existing.url,
|
|
351
351
|
"--json",
|
|
352
|
-
"number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
|
|
352
|
+
"number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
|
|
353
353
|
],
|
|
354
354
|
{
|
|
355
355
|
cwd: repositoryPath,
|
|
@@ -372,7 +372,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
372
372
|
"--state",
|
|
373
373
|
"all",
|
|
374
374
|
"--json",
|
|
375
|
-
"number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
|
|
375
|
+
"number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
|
|
376
376
|
],
|
|
377
377
|
{
|
|
378
378
|
cwd: repositoryPath,
|
|
@@ -773,19 +773,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
773
773
|
const query = prQueries.get(record.key)!
|
|
774
774
|
const { remoteUrl, remoteRepository } = query
|
|
775
775
|
|
|
776
|
-
if (
|
|
777
|
-
existing &&
|
|
778
|
-
remoteRepository.toLowerCase() !== existing.repository.toLowerCase()
|
|
779
|
-
) {
|
|
780
|
-
prConflict = true
|
|
781
|
-
unresolved.push({
|
|
782
|
-
kind: "pr-repository-conflict",
|
|
783
|
-
target: record.key,
|
|
784
|
-
message: `Recorded PR repository does not match writable repository remote '${remoteName}'`,
|
|
785
|
-
action: "Correct the configured remote or recorded PR",
|
|
786
|
-
})
|
|
787
|
-
}
|
|
788
|
-
|
|
789
776
|
if (config.delivery && !remoteUrl) {
|
|
790
777
|
warnings.push({
|
|
791
778
|
kind: "delivery-remote-unavailable",
|
|
@@ -812,8 +799,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
812
799
|
} else if (
|
|
813
800
|
parsed.right &&
|
|
814
801
|
(parsed.right.provider !== config.delivery.provider ||
|
|
815
|
-
|
|
816
|
-
|
|
802
|
+
(
|
|
803
|
+
parsed.right.headRepository ?? parsed.right.repository
|
|
804
|
+
).toLowerCase() !== remoteRepository.toLowerCase())
|
|
817
805
|
) {
|
|
818
806
|
if (parsed.right) {
|
|
819
807
|
prConflict = true
|
|
@@ -850,14 +838,18 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
850
838
|
current = recordFromGitHubJson(detail)
|
|
851
839
|
pr = { ...detail, ...current }
|
|
852
840
|
if (
|
|
853
|
-
|
|
854
|
-
|
|
841
|
+
current.headRepository?.toLowerCase() !==
|
|
842
|
+
remoteRepository.toLowerCase() ||
|
|
843
|
+
current.headBranch !== data.branch ||
|
|
844
|
+
current.baseRepository?.toLowerCase() !==
|
|
845
|
+
current.repository.toLowerCase() ||
|
|
846
|
+
current.baseBranch !== data.base
|
|
855
847
|
) {
|
|
856
848
|
prConflict = true
|
|
857
849
|
unresolved.push({
|
|
858
|
-
kind: "pr-
|
|
850
|
+
kind: "pr-repository-conflict",
|
|
859
851
|
target: record.key,
|
|
860
|
-
message: `Recorded PR
|
|
852
|
+
message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
|
|
861
853
|
action: "Correct the declaration or recorded PR URL",
|
|
862
854
|
})
|
|
863
855
|
}
|
|
@@ -883,6 +875,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
883
875
|
if (matches.length === 1) {
|
|
884
876
|
current = recordFromGitHubJson(matches[0]!)
|
|
885
877
|
pr = { ...matches[0], ...current }
|
|
878
|
+
if (
|
|
879
|
+
current.headRepository?.toLowerCase() !==
|
|
880
|
+
remoteRepository.toLowerCase() ||
|
|
881
|
+
current.baseRepository?.toLowerCase() !==
|
|
882
|
+
current.repository.toLowerCase()
|
|
883
|
+
) {
|
|
884
|
+
prConflict = true
|
|
885
|
+
unresolved.push({
|
|
886
|
+
kind: "pr-repository-conflict",
|
|
887
|
+
target: record.key,
|
|
888
|
+
message: `Discovered PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
|
|
889
|
+
action: "Record the authoritative PR URL manually",
|
|
890
|
+
})
|
|
891
|
+
}
|
|
886
892
|
} else if (matches.length > 1) {
|
|
887
893
|
unresolved.push({
|
|
888
894
|
kind: "multiple-prs",
|
|
@@ -116,6 +116,234 @@ describe("task and phase services", () => {
|
|
|
116
116
|
expect(epic.data.tasks).toEqual([])
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
+
test("creates revision-bound task and phase investigation handoffs", async () => {
|
|
120
|
+
const source = await runTestEffect(
|
|
121
|
+
TaskService.pipe(
|
|
122
|
+
Effect.flatMap((service) =>
|
|
123
|
+
service.create(
|
|
124
|
+
{
|
|
125
|
+
id: "investigate",
|
|
126
|
+
ticketUrl: null,
|
|
127
|
+
multiPhase: true,
|
|
128
|
+
purpose: "investigation",
|
|
129
|
+
},
|
|
130
|
+
root,
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
const phase = await runTestEffect(
|
|
136
|
+
PhaseService.pipe(
|
|
137
|
+
Effect.flatMap((service) =>
|
|
138
|
+
service.create(
|
|
139
|
+
{
|
|
140
|
+
taskId: "investigate",
|
|
141
|
+
id: "evidence",
|
|
142
|
+
repo: "agency",
|
|
143
|
+
branch: "task/investigate-evidence",
|
|
144
|
+
base: "main",
|
|
145
|
+
},
|
|
146
|
+
root,
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
const currentSource = await runTestEffect(
|
|
152
|
+
TaskService.pipe(
|
|
153
|
+
Effect.flatMap((service) => service.show("investigate", root)),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
const sourceContent = await Bun.file(source.path).text()
|
|
157
|
+
const phaseContent = await Bun.file(phase.path).text()
|
|
158
|
+
|
|
159
|
+
const fromTask = await runTestEffect(
|
|
160
|
+
TaskService.pipe(
|
|
161
|
+
Effect.flatMap((service) =>
|
|
162
|
+
service.handoff(
|
|
163
|
+
{
|
|
164
|
+
sourceTaskId: "investigate",
|
|
165
|
+
id: "implement-task",
|
|
166
|
+
ticketUrl: null,
|
|
167
|
+
repo: "agency",
|
|
168
|
+
branch: "task/implement-task",
|
|
169
|
+
base: "main",
|
|
170
|
+
},
|
|
171
|
+
root,
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
const fromPhase = await runTestEffect(
|
|
177
|
+
TaskService.pipe(
|
|
178
|
+
Effect.flatMap((service) =>
|
|
179
|
+
service.handoff(
|
|
180
|
+
{
|
|
181
|
+
sourceTaskId: "investigate",
|
|
182
|
+
sourcePhaseId: "evidence",
|
|
183
|
+
id: "implement-phase",
|
|
184
|
+
ticketUrl: null,
|
|
185
|
+
repo: "effect",
|
|
186
|
+
branch: "task/implement-phase",
|
|
187
|
+
base: "main",
|
|
188
|
+
},
|
|
189
|
+
root,
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
expect(fromTask.source).toEqual({
|
|
196
|
+
selector: "task/investigate",
|
|
197
|
+
documentPath: source.path,
|
|
198
|
+
revision: currentSource.revision,
|
|
199
|
+
})
|
|
200
|
+
expect(fromPhase.source).toEqual({
|
|
201
|
+
selector: "phase/investigate/evidence",
|
|
202
|
+
documentPath: phase.path,
|
|
203
|
+
revision: phase.revision,
|
|
204
|
+
})
|
|
205
|
+
expect(fromPhase.validation.valid).toBe(true)
|
|
206
|
+
expect(fromPhase.worktreePrepare.command).toEqual([
|
|
207
|
+
"agency",
|
|
208
|
+
"worktree",
|
|
209
|
+
"prepare",
|
|
210
|
+
"implement-phase",
|
|
211
|
+
])
|
|
212
|
+
expect(await Bun.file(source.path).text()).toBe(sourceContent)
|
|
213
|
+
expect(await Bun.file(phase.path).text()).toBe(phaseContent)
|
|
214
|
+
expect(
|
|
215
|
+
await Bun.file(join(root, "tasks/implement-phase/code")).exists(),
|
|
216
|
+
).toBe(false)
|
|
217
|
+
|
|
218
|
+
const created = await runTestEffect(
|
|
219
|
+
TaskService.pipe(
|
|
220
|
+
Effect.flatMap((service) => service.show("implement-phase", root)),
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
expect(created.data).toMatchObject({
|
|
224
|
+
purpose: "implementation",
|
|
225
|
+
handoff: {
|
|
226
|
+
source: {
|
|
227
|
+
kind: "phase",
|
|
228
|
+
taskId: "investigate",
|
|
229
|
+
phaseId: "evidence",
|
|
230
|
+
},
|
|
231
|
+
sourceRevision: phase.revision,
|
|
232
|
+
},
|
|
233
|
+
status: "open",
|
|
234
|
+
})
|
|
235
|
+
await Bun.write(
|
|
236
|
+
phase.path,
|
|
237
|
+
phaseContent.replace(
|
|
238
|
+
"Describe the phase outcome.",
|
|
239
|
+
"Recorded evidence remains stale-safe.",
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
const staleSource = await runTestEffect(
|
|
243
|
+
TaskService.pipe(
|
|
244
|
+
Effect.flatMap((service) => service.show("implement-phase", root)),
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
expect(staleSource.data.handoff?.sourceRevision).toBe(phase.revision)
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
runTestEffect(
|
|
251
|
+
TaskService.pipe(
|
|
252
|
+
Effect.flatMap((service) =>
|
|
253
|
+
service.handoff(
|
|
254
|
+
{
|
|
255
|
+
sourceTaskId: "investigate",
|
|
256
|
+
id: "implement-task",
|
|
257
|
+
ticketUrl: null,
|
|
258
|
+
repo: "agency",
|
|
259
|
+
branch: "task/duplicate",
|
|
260
|
+
base: "main",
|
|
261
|
+
},
|
|
262
|
+
root,
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
).rejects.toThrow("already exists")
|
|
268
|
+
|
|
269
|
+
await mkdir(join(root, "archive/tasks/reserved"), { recursive: true })
|
|
270
|
+
await expect(
|
|
271
|
+
runTestEffect(
|
|
272
|
+
TaskService.pipe(
|
|
273
|
+
Effect.flatMap((service) =>
|
|
274
|
+
service.handoff(
|
|
275
|
+
{
|
|
276
|
+
sourceTaskId: "investigate",
|
|
277
|
+
id: "reserved",
|
|
278
|
+
ticketUrl: null,
|
|
279
|
+
repo: "agency",
|
|
280
|
+
branch: "task/reserved",
|
|
281
|
+
base: "main",
|
|
282
|
+
},
|
|
283
|
+
root,
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
).rejects.toThrow("explicit creation requires a different ID")
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test("rolls back handoff creation when a source revision is stale", async () => {
|
|
292
|
+
const source = await runTestEffect(
|
|
293
|
+
TaskService.pipe(
|
|
294
|
+
Effect.flatMap((service) =>
|
|
295
|
+
service.create(
|
|
296
|
+
{
|
|
297
|
+
id: "stale-investigation",
|
|
298
|
+
ticketUrl: null,
|
|
299
|
+
repo: "agency",
|
|
300
|
+
branch: "task/stale-investigation",
|
|
301
|
+
base: "main",
|
|
302
|
+
purpose: "investigation",
|
|
303
|
+
},
|
|
304
|
+
root,
|
|
305
|
+
),
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
const staleRevision = source.revision
|
|
310
|
+
await Bun.write(
|
|
311
|
+
source.path,
|
|
312
|
+
`${source.content}\nChanged after inspection.\n`,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
await expect(
|
|
316
|
+
runTestEffect(
|
|
317
|
+
TaskService.pipe(
|
|
318
|
+
Effect.flatMap((service) =>
|
|
319
|
+
service.create(
|
|
320
|
+
{
|
|
321
|
+
id: "stale-implementation",
|
|
322
|
+
ticketUrl: null,
|
|
323
|
+
repo: "effect",
|
|
324
|
+
branch: "task/stale-implementation",
|
|
325
|
+
base: "main",
|
|
326
|
+
purpose: "implementation",
|
|
327
|
+
handoff: {
|
|
328
|
+
source: {
|
|
329
|
+
kind: "task",
|
|
330
|
+
taskId: "stale-investigation",
|
|
331
|
+
},
|
|
332
|
+
sourceRevision: staleRevision,
|
|
333
|
+
},
|
|
334
|
+
preconditions: [{ path: source.path, revision: staleRevision }],
|
|
335
|
+
},
|
|
336
|
+
root,
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
).rejects.toThrow("Revision conflict")
|
|
342
|
+
expect(
|
|
343
|
+
await Bun.file(join(root, "tasks/stale-implementation/TASK.md")).exists(),
|
|
344
|
+
).toBe(false)
|
|
345
|
+
})
|
|
346
|
+
|
|
119
347
|
test("creates and sequences phases on a multi-phase task", async () => {
|
|
120
348
|
await runTestEffect(
|
|
121
349
|
TaskService.pipe(
|
|
@@ -192,6 +420,11 @@ describe("task and phase services", () => {
|
|
|
192
420
|
repos: [{ repo: "effect", ref: "main" }],
|
|
193
421
|
branch: "task/single",
|
|
194
422
|
base: "main",
|
|
423
|
+
purpose: "implementation",
|
|
424
|
+
handoff: {
|
|
425
|
+
source: { kind: "task", taskId: "investigation" },
|
|
426
|
+
sourceRevision: "a".repeat(64),
|
|
427
|
+
},
|
|
195
428
|
},
|
|
196
429
|
root,
|
|
197
430
|
),
|
|
@@ -256,6 +489,11 @@ describe("task and phase services", () => {
|
|
|
256
489
|
expect(task.data).toEqual({
|
|
257
490
|
ticketUrl: "https://example.com/task",
|
|
258
491
|
description: "Deliver the complete task.",
|
|
492
|
+
purpose: "implementation",
|
|
493
|
+
handoff: {
|
|
494
|
+
source: { kind: "task", taskId: "investigation" },
|
|
495
|
+
sourceRevision: "a".repeat(64),
|
|
496
|
+
},
|
|
259
497
|
phases: [
|
|
260
498
|
{ id: "implementation" },
|
|
261
499
|
{ id: "extra", dependsOn: ["implementation"] },
|