@markjaquith/agency 3.2.2 → 3.2.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/README.md +30 -62
- package/cli-main.ts +20 -83
- package/fixtures/protocol/orchestration-recipes.json +9 -34
- package/package.json +1 -4
- package/schemas/agency-graph-v1.schema.json +2 -31
- package/src/cli-parser.test.ts +34 -132
- package/src/cli-parser.ts +45 -102
- package/src/cli.test.ts +60 -117
- package/src/commands/act.ts +2 -6
- package/src/commands/sync.ts +3 -3
- package/src/commands/validate.ts +3 -1
- package/src/commands/work.test.ts +70 -8
- package/src/commands/work.ts +15 -8
- package/src/graph-schema.ts +0 -2
- package/src/protocol.test.ts +0 -25
- package/src/protocol.ts +0 -11
- package/src/readiness.test.ts +2 -2
- package/src/services/ArchiveBulkService.test.ts +0 -88
- package/src/services/ArchiveService.ts +0 -32
- package/src/services/GraphMutationService.ts +0 -26
- package/src/services/GraphService.test.ts +1 -1
- package/src/services/IntegrationService.test.ts +7 -4
- package/src/services/LifecycleTransaction.ts +5 -1
- package/src/services/PhaseService.ts +1 -8
- package/src/services/ReadinessService.test.ts +0 -34
- package/src/services/ReadinessService.ts +1 -20
- package/src/services/ReviewService.test.ts +0 -64
- package/src/services/ReviewService.ts +0 -5
- package/src/services/SyncService.test.ts +75 -88
- package/src/services/SyncService.ts +52 -123
- package/src/services/TaskPhaseService.test.ts +13 -1
- package/src/services/TaskService.ts +1 -7
- package/src/services/WorkbaseService.ts +0 -3
- package/src/services/WorktreeService.test.ts +192 -1
- package/src/services/WorktreeService.ts +169 -34
- package/src/test-utils.ts +0 -2
- package/src/usage-log.test.ts +89 -5
- package/src/usage-log.ts +139 -31
- package/src/workbase/AGENTS.md +9 -15
- package/src/workbase/agent-command.test.ts +1 -3
- package/src/workbase/agent-command.ts +1 -6
- package/src/workbase/document-revision.ts +0 -4
- package/src/workbase/opencode-plugin-file.ts +1 -0
- package/src/workbase/schemas.test.ts +12 -25
- package/src/workbase/schemas.ts +0 -16
- package/src/commands/claim.ts +0 -122
- package/src/services/ClaimService.test.ts +0 -415
- package/src/services/ClaimService.ts +0 -608
package/src/commands/act.ts
CHANGED
|
@@ -106,9 +106,6 @@ const entityChoices = (
|
|
|
106
106
|
value: entityKey(node),
|
|
107
107
|
}))
|
|
108
108
|
|
|
109
|
-
const activeClaim = (node: EntityNode) =>
|
|
110
|
-
"claim" in node.data && node.data.claim?.state === "active"
|
|
111
|
-
|
|
112
109
|
const executionNode = (
|
|
113
110
|
node: EntityNode,
|
|
114
111
|
executions: ReadonlyMap<
|
|
@@ -131,7 +128,6 @@ const canWork = (
|
|
|
131
128
|
const execution = executionNode(node, executions)
|
|
132
129
|
return Boolean(
|
|
133
130
|
execution &&
|
|
134
|
-
!activeClaim(node) &&
|
|
135
131
|
(execution.readiness.ready ||
|
|
136
132
|
(execution.status === "working" &&
|
|
137
133
|
execution.readiness.blockers.every(
|
|
@@ -168,10 +164,10 @@ const actionChoices = (
|
|
|
168
164
|
if (canCreatePr(node, executions)) {
|
|
169
165
|
choices.push({ key: "pr", label: "Create pull request", value: "pr" })
|
|
170
166
|
}
|
|
171
|
-
if (execution && isTerminalStatus(node.status)
|
|
167
|
+
if (execution && isTerminalStatus(node.status)) {
|
|
172
168
|
choices.push({ key: "reopen", label: "Reopen", value: "reopen" })
|
|
173
169
|
}
|
|
174
|
-
if (execution && !isTerminalStatus(node.status)
|
|
170
|
+
if (execution && !isTerminalStatus(node.status)) {
|
|
175
171
|
choices.push({ key: "drop", label: "Drop", value: "drop" })
|
|
176
172
|
}
|
|
177
173
|
if (node.readiness.terminal) {
|
package/src/commands/sync.ts
CHANGED
|
@@ -125,7 +125,7 @@ export const help = `
|
|
|
125
125
|
Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
|
|
126
126
|
|
|
127
127
|
Compare portable repository declarations and execution state with local Git
|
|
128
|
-
repositories, worktrees, branches, references,
|
|
128
|
+
repositories, worktrees, branches, references, and pull requests.
|
|
129
129
|
Safe reconciliation transitions are applied by default.
|
|
130
130
|
When a task or phase is provided, only that target and its repositories are
|
|
131
131
|
queried or reconciled. Task scope includes all phases of a multi-phase task.
|
|
@@ -135,7 +135,7 @@ Options:
|
|
|
135
135
|
--json Output one versioned machine result
|
|
136
136
|
|
|
137
137
|
Sync may materialize declared repositories and unambiguous missing checkouts,
|
|
138
|
-
adopt legacy repositories with portable origins,
|
|
139
|
-
|
|
138
|
+
adopt legacy repositories with portable origins, record a uniquely matched PR,
|
|
139
|
+
and mark merged work done. Dirty, stale, or
|
|
140
140
|
conflicting checkouts are always left unresolved.
|
|
141
141
|
`
|
package/src/commands/validate.ts
CHANGED
|
@@ -13,7 +13,9 @@ interface ValidateOptions extends BaseCommandOptions {
|
|
|
13
13
|
readonly json?: boolean
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
class ValidationFailedError extends Data.TaggedError(
|
|
16
|
+
export class ValidationFailedError extends Data.TaggedError(
|
|
17
|
+
"ValidationFailedError",
|
|
18
|
+
)<{
|
|
17
19
|
readonly message: string
|
|
18
20
|
readonly root: string
|
|
19
21
|
readonly issues: readonly {
|
|
@@ -84,6 +84,8 @@ interface HarnessOptions {
|
|
|
84
84
|
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
85
85
|
>
|
|
86
86
|
readonly taskRepo?: string
|
|
87
|
+
readonly existingPaths?: readonly string[]
|
|
88
|
+
readonly validationIssues?: readonly { path: string; message: string }[]
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -142,15 +144,17 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
142
144
|
},
|
|
143
145
|
}),
|
|
144
146
|
repositoryAliases: () => Effect.succeed(["agency"]),
|
|
145
|
-
validate: () =>
|
|
146
|
-
|
|
147
|
+
validate: () => {
|
|
148
|
+
const issues = options.validationIssues ?? []
|
|
149
|
+
return Effect.succeed({
|
|
147
150
|
root: "/workbase",
|
|
148
|
-
issues
|
|
151
|
+
issues,
|
|
149
152
|
epicCount: 0,
|
|
150
153
|
taskCount: 1,
|
|
151
154
|
phaseCount: 0,
|
|
152
|
-
valid:
|
|
153
|
-
})
|
|
155
|
+
valid: issues.length === 0,
|
|
156
|
+
})
|
|
157
|
+
},
|
|
154
158
|
}
|
|
155
159
|
const epics = {
|
|
156
160
|
show: (id: string) =>
|
|
@@ -281,7 +285,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
281
285
|
readFile: (path: string) =>
|
|
282
286
|
Effect.succeed(path.endsWith("agency.json") ? '{"version":2}\n' : ""),
|
|
283
287
|
readDirectory: () => Effect.succeed([]),
|
|
284
|
-
exists: () =>
|
|
288
|
+
exists: (path: string) =>
|
|
289
|
+
Effect.succeed(options.existingPaths?.includes(path) ?? false),
|
|
285
290
|
realPath: (path: string) => Effect.succeed(path),
|
|
286
291
|
runCommand: (args: readonly string[]) => {
|
|
287
292
|
const cli = args[1]!
|
|
@@ -569,6 +574,65 @@ describe("work command", () => {
|
|
|
569
574
|
).rejects.toThrow("Recalled repository conflicts")
|
|
570
575
|
})
|
|
571
576
|
|
|
577
|
+
test("resolves task and phase document paths", async () => {
|
|
578
|
+
const taskDocument = "/workbase/tasks/example/TASK.md"
|
|
579
|
+
const taskHarness = createHarness({
|
|
580
|
+
existingDirectories: [],
|
|
581
|
+
existingPaths: [taskDocument],
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
await taskHarness.runPrepare({
|
|
585
|
+
directory: taskDocument,
|
|
586
|
+
dryRun: true,
|
|
587
|
+
silent: true,
|
|
588
|
+
})
|
|
589
|
+
expect(taskHarness.guards[0]?.target).toBe("execution-unit:task/example")
|
|
590
|
+
expect(taskHarness.shownTasks).toEqual(["example", "example"])
|
|
591
|
+
|
|
592
|
+
const phaseDocument =
|
|
593
|
+
"/workbase/tasks/example/phases/implementation/PHASE.md"
|
|
594
|
+
const phaseHarness = createHarness({
|
|
595
|
+
existingDirectories: [],
|
|
596
|
+
existingPaths: [phaseDocument],
|
|
597
|
+
multiPhaseTasks: ["example"],
|
|
598
|
+
workspace: multiPhaseWorkspace,
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
await phaseHarness.runPrepare({
|
|
602
|
+
directory: phaseDocument,
|
|
603
|
+
dryRun: true,
|
|
604
|
+
silent: true,
|
|
605
|
+
})
|
|
606
|
+
expect(phaseHarness.guards[0]?.target).toBe(
|
|
607
|
+
"execution-unit:phase/example/implementation",
|
|
608
|
+
)
|
|
609
|
+
expect(phaseHarness.materializeOptions).toHaveLength(1)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
test("returns structured validation issues before readiness or materialization", async () => {
|
|
613
|
+
const harness = createHarness({
|
|
614
|
+
existingDirectories: [],
|
|
615
|
+
validationIssues: [
|
|
616
|
+
{
|
|
617
|
+
path: "tasks/example/TASK.md",
|
|
618
|
+
message: "Repository alias 'missing' is not configured",
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
await expect(
|
|
624
|
+
harness.runPrepare({
|
|
625
|
+
cwd: "/workbase",
|
|
626
|
+
directory: "example",
|
|
627
|
+
dryRun: true,
|
|
628
|
+
}),
|
|
629
|
+
).rejects.toThrow(
|
|
630
|
+
"Workbase validation failed with 1 issue:\n- tasks/example/TASK.md: Repository alias 'missing' is not configured",
|
|
631
|
+
)
|
|
632
|
+
expect(harness.guards).toEqual([])
|
|
633
|
+
expect(harness.events).toEqual([])
|
|
634
|
+
})
|
|
635
|
+
|
|
572
636
|
test("force prepares a phase blocked by an active dependency without launching or changing status", async () => {
|
|
573
637
|
const blocked = createHarness({
|
|
574
638
|
workspace: multiPhaseWorkspace,
|
|
@@ -1031,12 +1095,10 @@ describe("work command", () => {
|
|
|
1031
1095
|
})
|
|
1032
1096
|
expect(harness.launchEnvironments[0]).toMatchObject({
|
|
1033
1097
|
AGENCY_AGENT: "custom",
|
|
1034
|
-
AGENCY_CLAIMANT: process.env.USER ?? "agency",
|
|
1035
1098
|
AGENCY_WORKBASE: "/workbase",
|
|
1036
1099
|
AGENCY_TARGET: "execution-unit:task/example",
|
|
1037
1100
|
AGENCY_TASK_ID: "example",
|
|
1038
1101
|
AGENCY_PHASE_ID: "",
|
|
1039
|
-
AGENCY_CLAIM_REVISION: "",
|
|
1040
1102
|
CUSTOM_TARGET: "execution-unit:task/example",
|
|
1041
1103
|
})
|
|
1042
1104
|
})
|
package/src/commands/work.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
normalizeRecalledContext,
|
|
37
37
|
readValidationEvidence,
|
|
38
38
|
} from "../workbase/execution-contract"
|
|
39
|
+
import { ValidationFailedError } from "./validate"
|
|
39
40
|
|
|
40
41
|
export interface WorkOptions extends BaseCommandOptions {
|
|
41
42
|
readonly directory?: string
|
|
@@ -288,7 +289,6 @@ export const work = (
|
|
|
288
289
|
const defaultAgents = ["opencode2", "opencode", "pi", "claude"] as const
|
|
289
290
|
let defaultAgentIndex = 0
|
|
290
291
|
let agent: string = selectedAgent ?? defaultAgents[defaultAgentIndex]!
|
|
291
|
-
const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
|
|
292
292
|
const sessionId =
|
|
293
293
|
process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
|
|
294
294
|
const resume =
|
|
@@ -300,9 +300,7 @@ export const work = (
|
|
|
300
300
|
target: targetNodeId(target),
|
|
301
301
|
task: target.kind === "epic" ? "" : target.taskId,
|
|
302
302
|
phase: target.kind === "phase" ? target.phaseId : "",
|
|
303
|
-
claimant,
|
|
304
303
|
sessionId,
|
|
305
|
-
claimRevision: "",
|
|
306
304
|
}
|
|
307
305
|
let resolved = resolveAgentCommand(
|
|
308
306
|
agent,
|
|
@@ -439,7 +437,8 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
439
437
|
const cwd = options.cwd ?? process.cwd()
|
|
440
438
|
const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
441
439
|
const isDirectory = yield* fs.isDirectory(targetPath)
|
|
442
|
-
const
|
|
440
|
+
const targetExists = isDirectory || (yield* fs.exists(targetPath))
|
|
441
|
+
const root = yield* workbase.discover(targetExists ? targetPath : cwd)
|
|
443
442
|
|
|
444
443
|
let taskId = options.taskId
|
|
445
444
|
let phaseId = options.phaseId
|
|
@@ -447,7 +446,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
447
446
|
const task = yield* tasks.show(taskId, root)
|
|
448
447
|
taskId = task.id
|
|
449
448
|
if (phaseId) phaseId = (yield* phases.show(task.id, phaseId, root)).id
|
|
450
|
-
} else if (options.directory && !
|
|
449
|
+
} else if (options.directory && !targetExists) {
|
|
451
450
|
const task = yield* tasks.show(options.directory, root)
|
|
452
451
|
taskId = task.id
|
|
453
452
|
} else {
|
|
@@ -526,9 +525,17 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
526
525
|
})
|
|
527
526
|
let validation: unknown = { valid: true, source: "evidence" }
|
|
528
527
|
if (assessment.disposition.status === "refreshed") {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
528
|
+
const report = yield* workbase.validate(root)
|
|
529
|
+
validation = report
|
|
530
|
+
if (!report.valid && !options.force) {
|
|
531
|
+
const details = report.issues
|
|
532
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
533
|
+
.join("\n")
|
|
534
|
+
return yield* new ValidationFailedError({
|
|
535
|
+
message: `Workbase validation failed with ${report.issues.length} issue${report.issues.length === 1 ? "" : "s"}:\n${details}`,
|
|
536
|
+
root: report.root,
|
|
537
|
+
issues: report.issues,
|
|
538
|
+
})
|
|
532
539
|
}
|
|
533
540
|
}
|
|
534
541
|
yield* readiness.guardWorkTarget(target, root, options.force)
|
package/src/graph-schema.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
EpicFrontmatter,
|
|
4
4
|
PhaseFrontmatter,
|
|
5
5
|
PullRequestRecord,
|
|
6
|
-
ClaimRecord,
|
|
7
6
|
ReviewRecord,
|
|
8
7
|
TaskHandoff,
|
|
9
8
|
TaskFrontmatter,
|
|
@@ -96,7 +95,6 @@ export const GraphExecutionData = Schema.Union(
|
|
|
96
95
|
handoff: Schema.optional(TaskHandoff),
|
|
97
96
|
review: ReviewRecord,
|
|
98
97
|
status: WorkStatus,
|
|
99
|
-
claim: Schema.optional(ClaimRecord),
|
|
100
98
|
}),
|
|
101
99
|
)
|
|
102
100
|
|
package/src/protocol.test.ts
CHANGED
|
@@ -116,31 +116,6 @@ describe("machine protocol", () => {
|
|
|
116
116
|
"issues",
|
|
117
117
|
"root",
|
|
118
118
|
])
|
|
119
|
-
expect(
|
|
120
|
-
errorEnvelope({
|
|
121
|
-
_tag: "ClaimConflictError",
|
|
122
|
-
message: "already claimed",
|
|
123
|
-
target: "task 'example'",
|
|
124
|
-
currentRevision: "a".repeat(64),
|
|
125
|
-
claim: {
|
|
126
|
-
claimant: "orchestrator",
|
|
127
|
-
agent: "agent",
|
|
128
|
-
sessionId: "job-1",
|
|
129
|
-
startedAt: "2026-07-17T12:00:00.000Z",
|
|
130
|
-
targetRevision: "0".repeat(64),
|
|
131
|
-
state: "active",
|
|
132
|
-
},
|
|
133
|
-
}),
|
|
134
|
-
).toMatchObject({
|
|
135
|
-
error: {
|
|
136
|
-
code: "CLAIM_CONFLICT",
|
|
137
|
-
retryable: true,
|
|
138
|
-
fields: {
|
|
139
|
-
target: "task 'example'",
|
|
140
|
-
claim: { agent: "agent", sessionId: "job-1" },
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
})
|
|
144
119
|
expect(
|
|
145
120
|
errorEnvelope({
|
|
146
121
|
_tag: "RevisionConflictError",
|
package/src/protocol.ts
CHANGED
|
@@ -81,22 +81,11 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
81
81
|
EpicError: { code: "EPIC_ERROR", retryable: false },
|
|
82
82
|
TaskError: { code: "TASK_ERROR", retryable: false },
|
|
83
83
|
PhaseError: { code: "PHASE_ERROR", retryable: false },
|
|
84
|
-
ClaimError: { code: "CLAIM_ERROR", retryable: false },
|
|
85
|
-
ClaimConflictError: {
|
|
86
|
-
code: "CLAIM_CONFLICT",
|
|
87
|
-
retryable: true,
|
|
88
|
-
remediation: "Inspect the current ownership details before retrying.",
|
|
89
|
-
},
|
|
90
84
|
RevisionConflictError: {
|
|
91
85
|
code: "REVISION_CONFLICT",
|
|
92
86
|
retryable: true,
|
|
93
87
|
remediation: "Read the current document revision and retry intentionally.",
|
|
94
88
|
},
|
|
95
|
-
ClaimOwnershipError: {
|
|
96
|
-
code: "CLAIM_OWNERSHIP",
|
|
97
|
-
retryable: false,
|
|
98
|
-
remediation: "Use the session that owns the claim.",
|
|
99
|
-
},
|
|
100
89
|
ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
|
|
101
90
|
WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
|
|
102
91
|
PushError: { code: "PUSH_ERROR", retryable: false },
|
package/src/readiness.test.ts
CHANGED
|
@@ -27,10 +27,10 @@ describe("readiness model", () => {
|
|
|
27
27
|
blockedBy: ["task:one"],
|
|
28
28
|
terminal: true,
|
|
29
29
|
})
|
|
30
|
-
expect(readinessState("working", [{ id: "
|
|
30
|
+
expect(readinessState("working", [{ id: "dependency:self" }])).toEqual({
|
|
31
31
|
ready: false,
|
|
32
32
|
blocked: true,
|
|
33
|
-
blockedBy: ["
|
|
33
|
+
blockedBy: ["dependency:self"],
|
|
34
34
|
terminal: false,
|
|
35
35
|
})
|
|
36
36
|
})
|
|
@@ -335,94 +335,6 @@ describe("ArchiveService bulk task archive", () => {
|
|
|
335
335
|
).toBe(true)
|
|
336
336
|
})
|
|
337
337
|
|
|
338
|
-
test("skips task- and phase-level active claims", async () => {
|
|
339
|
-
await createTask("claimed")
|
|
340
|
-
await dropTask("claimed")
|
|
341
|
-
const taskPath = join(root, "tasks/claimed/TASK.md")
|
|
342
|
-
await Bun.write(
|
|
343
|
-
taskPath,
|
|
344
|
-
(await Bun.file(taskPath).text()).replace(
|
|
345
|
-
"status: dropped\n",
|
|
346
|
-
`status: dropped
|
|
347
|
-
claim:
|
|
348
|
-
claimant: orchestrator
|
|
349
|
-
agent: opencode
|
|
350
|
-
sessionId: task-session
|
|
351
|
-
startedAt: 2026-08-07T00:00:00.000Z
|
|
352
|
-
targetRevision: ${"a".repeat(64)}
|
|
353
|
-
state: active
|
|
354
|
-
`,
|
|
355
|
-
),
|
|
356
|
-
)
|
|
357
|
-
await runTestEffect(
|
|
358
|
-
TaskService.pipe(
|
|
359
|
-
Effect.flatMap((service) =>
|
|
360
|
-
service.create(
|
|
361
|
-
{ id: "multi-claimed", ticketUrl: null, multiPhase: true },
|
|
362
|
-
root,
|
|
363
|
-
),
|
|
364
|
-
),
|
|
365
|
-
),
|
|
366
|
-
)
|
|
367
|
-
await runTestEffect(
|
|
368
|
-
PhaseService.pipe(
|
|
369
|
-
Effect.flatMap((service) =>
|
|
370
|
-
service.create(
|
|
371
|
-
{
|
|
372
|
-
taskId: "multi-claimed",
|
|
373
|
-
id: "phase",
|
|
374
|
-
repo: "agency",
|
|
375
|
-
branch: "task/multi-claimed",
|
|
376
|
-
base: "main",
|
|
377
|
-
},
|
|
378
|
-
root,
|
|
379
|
-
),
|
|
380
|
-
),
|
|
381
|
-
),
|
|
382
|
-
)
|
|
383
|
-
await runTestEffect(
|
|
384
|
-
PhaseService.pipe(
|
|
385
|
-
Effect.flatMap((service) =>
|
|
386
|
-
service.setStatus("multi-claimed", "phase", "dropped", root),
|
|
387
|
-
),
|
|
388
|
-
),
|
|
389
|
-
)
|
|
390
|
-
const phasePath = join(root, "tasks/multi-claimed/phases/phase/PHASE.md")
|
|
391
|
-
await Bun.write(
|
|
392
|
-
phasePath,
|
|
393
|
-
(await Bun.file(phasePath).text()).replace(
|
|
394
|
-
"status: dropped\n",
|
|
395
|
-
`status: dropped
|
|
396
|
-
claim:
|
|
397
|
-
claimant: orchestrator
|
|
398
|
-
agent: opencode
|
|
399
|
-
sessionId: phase-session
|
|
400
|
-
startedAt: 2026-08-07T00:00:00.000Z
|
|
401
|
-
targetRevision: ${"b".repeat(64)}
|
|
402
|
-
state: active
|
|
403
|
-
`,
|
|
404
|
-
),
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
const result = await archiveTasks(true)
|
|
408
|
-
|
|
409
|
-
expect(result.tasks).toMatchObject([
|
|
410
|
-
{
|
|
411
|
-
id: "claimed",
|
|
412
|
-
disposition: "skipped",
|
|
413
|
-
reason: { code: "active-claim", details: ["task:claimed"] },
|
|
414
|
-
},
|
|
415
|
-
{
|
|
416
|
-
id: "multi-claimed",
|
|
417
|
-
disposition: "skipped",
|
|
418
|
-
reason: {
|
|
419
|
-
code: "active-claim",
|
|
420
|
-
details: ["phase:multi-claimed/phase"],
|
|
421
|
-
},
|
|
422
|
-
},
|
|
423
|
-
])
|
|
424
|
-
})
|
|
425
|
-
|
|
426
338
|
test("rolls back the entire cohort when application fails", async () => {
|
|
427
339
|
await runTestEffect(
|
|
428
340
|
EpicService.pipe(
|
|
@@ -111,7 +111,6 @@ interface LifecycleOptions {
|
|
|
111
111
|
|
|
112
112
|
type TaskArchiveSkipCode =
|
|
113
113
|
| "non-terminal"
|
|
114
|
-
| "active-claim"
|
|
115
114
|
| "dirty-worktree"
|
|
116
115
|
| "checkout-preflight-failed"
|
|
117
116
|
| "retained-dependent"
|
|
@@ -169,7 +168,6 @@ interface TaskArchiveContext {
|
|
|
169
168
|
readonly executionUnits: readonly { taskId: string; phaseId?: string }[]
|
|
170
169
|
readonly terminal: boolean
|
|
171
170
|
readonly terminalDetails: readonly string[]
|
|
172
|
-
readonly activeClaims: readonly string[]
|
|
173
171
|
}
|
|
174
172
|
|
|
175
173
|
const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
|
|
@@ -182,8 +180,6 @@ const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
|
|
|
182
180
|
executionUnits: [{ taskId: task.id }],
|
|
183
181
|
terminal: isTerminalStatus(task.data.status),
|
|
184
182
|
terminalDetails: [`status=${task.data.status}`],
|
|
185
|
-
activeClaims:
|
|
186
|
-
task.data.claim?.state === "active" ? [`task:${task.id}`] : [],
|
|
187
183
|
} satisfies TaskArchiveContext
|
|
188
184
|
}
|
|
189
185
|
|
|
@@ -207,9 +203,6 @@ const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
|
|
|
207
203
|
: phaseRecords.map(
|
|
208
204
|
(phase) => `phase:${phase.id}:status=${phase.data.status}`,
|
|
209
205
|
),
|
|
210
|
-
activeClaims: phaseRecords
|
|
211
|
-
.filter((phase) => phase.data.claim?.state === "active")
|
|
212
|
-
.map((phase) => `phase:${task.id}/${phase.id}`),
|
|
213
206
|
} satisfies TaskArchiveContext
|
|
214
207
|
})
|
|
215
208
|
|
|
@@ -217,9 +210,6 @@ const archiveEligibilityError = (context: TaskArchiveContext) => {
|
|
|
217
210
|
if (!context.terminal) {
|
|
218
211
|
return `Task '${context.task.id}' is not terminal (${context.terminalDetails.join(", ")}); only done or dropped tasks can be archived`
|
|
219
212
|
}
|
|
220
|
-
if (context.activeClaims.length > 0) {
|
|
221
|
-
return `Task '${context.task.id}' has active claims (${context.activeClaims.join(", ")}); release or finish them before archiving`
|
|
222
|
-
}
|
|
223
213
|
return undefined
|
|
224
214
|
}
|
|
225
215
|
|
|
@@ -735,11 +725,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
735
725
|
const executionUnits: { taskId: string; phaseId?: string }[] = []
|
|
736
726
|
const phaseRecords: PhaseRecord[] = []
|
|
737
727
|
for (const task of taskRecords) {
|
|
738
|
-
if ("claim" in task.data && task.data.claim?.state === "active") {
|
|
739
|
-
return yield* new ArchiveError({
|
|
740
|
-
message: `Task '${task.id}' has an active claim; release or finish it before archiving`,
|
|
741
|
-
})
|
|
742
|
-
}
|
|
743
728
|
if ("phases" in task.data) {
|
|
744
729
|
for (const phase of task.data.phases) {
|
|
745
730
|
const record = yield* (yield* PhaseService).show(
|
|
@@ -747,11 +732,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
747
732
|
phase.id,
|
|
748
733
|
root,
|
|
749
734
|
)
|
|
750
|
-
if (record.data.claim?.state === "active") {
|
|
751
|
-
return yield* new ArchiveError({
|
|
752
|
-
message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
|
|
753
|
-
})
|
|
754
|
-
}
|
|
755
735
|
phaseRecords.push(record)
|
|
756
736
|
executionUnits.push({ taskId: task.id, phaseId: phase.id })
|
|
757
737
|
}
|
|
@@ -910,13 +890,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
910
890
|
})
|
|
911
891
|
continue
|
|
912
892
|
}
|
|
913
|
-
if (context.activeClaims.length > 0) {
|
|
914
|
-
skipped.set(task.id, {
|
|
915
|
-
code: "active-claim",
|
|
916
|
-
details: context.activeClaims,
|
|
917
|
-
})
|
|
918
|
-
continue
|
|
919
|
-
}
|
|
920
893
|
const destination = archivedTaskDirectory(root, task.id)
|
|
921
894
|
if (yield* fs.exists(destination)) {
|
|
922
895
|
skipped.set(task.id, {
|
|
@@ -1398,11 +1371,6 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
1398
1371
|
const declaration = task.data.phases.find(
|
|
1399
1372
|
(candidate) => candidate.id === id,
|
|
1400
1373
|
)!
|
|
1401
|
-
if (phase.data.claim?.state === "active") {
|
|
1402
|
-
return yield* new ArchiveError({
|
|
1403
|
-
message: `Phase '${id}' has an active claim; release or finish it before archiving`,
|
|
1404
|
-
})
|
|
1405
|
-
}
|
|
1406
1374
|
const dependent = task.data.phases.find((candidate) =>
|
|
1407
1375
|
candidate.dependsOn?.includes(id),
|
|
1408
1376
|
)
|
|
@@ -376,15 +376,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
376
376
|
message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
|
|
377
377
|
})
|
|
378
378
|
}
|
|
379
|
-
if (
|
|
380
|
-
executionChange &&
|
|
381
|
-
"claim" in record.data &&
|
|
382
|
-
record.data.claim?.state === "active"
|
|
383
|
-
) {
|
|
384
|
-
return yield* new GraphMutationError({
|
|
385
|
-
message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
386
|
-
})
|
|
387
|
-
}
|
|
388
379
|
if (
|
|
389
380
|
updates.pr &&
|
|
390
381
|
"completion" in record.data &&
|
|
@@ -526,11 +517,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
526
517
|
message: `Phase '${id}' has materialized code; remove its worktree with Agency before changing execution metadata`,
|
|
527
518
|
})
|
|
528
519
|
}
|
|
529
|
-
if (executionChange && record.data.claim?.state === "active") {
|
|
530
|
-
return yield* new GraphMutationError({
|
|
531
|
-
message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
532
|
-
})
|
|
533
|
-
}
|
|
534
520
|
if (updates.pr && record.data.completion) {
|
|
535
521
|
return yield* new GraphMutationError({
|
|
536
522
|
message:
|
|
@@ -842,10 +828,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
842
828
|
return yield* new GraphMutationError({
|
|
843
829
|
message: `Task '${newId}' already exists`,
|
|
844
830
|
})
|
|
845
|
-
if ("claim" in task.data && task.data.claim?.state === "active")
|
|
846
|
-
return yield* new GraphMutationError({
|
|
847
|
-
message: `Task '${id}' has an active claim; release or finish it before renaming`,
|
|
848
|
-
})
|
|
849
831
|
if (yield* fs.isDirectory(join(from, "code")))
|
|
850
832
|
return yield* new GraphMutationError({
|
|
851
833
|
message: `Task '${id}' has a materialized worktree; remove it with Agency before renaming`,
|
|
@@ -859,10 +841,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
859
841
|
root,
|
|
860
842
|
)
|
|
861
843
|
movedPhases.push(record)
|
|
862
|
-
if (record.data.claim?.state === "active")
|
|
863
|
-
return yield* new GraphMutationError({
|
|
864
|
-
message: `Phase '${phase.id}' has an active claim; release or finish it before renaming task '${id}'`,
|
|
865
|
-
})
|
|
866
844
|
if (yield* fs.isDirectory(join(dirname(record.path), "code")))
|
|
867
845
|
return yield* new GraphMutationError({
|
|
868
846
|
message: `Phase '${phase.id}' has a materialized worktree; remove it with Agency before renaming task '${id}'`,
|
|
@@ -952,10 +930,6 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
952
930
|
})
|
|
953
931
|
}
|
|
954
932
|
const phase = yield* phases.show(taskId, id, root)
|
|
955
|
-
if (phase.data.claim?.state === "active")
|
|
956
|
-
return yield* new GraphMutationError({
|
|
957
|
-
message: `Phase '${id}' has an active claim; release or finish it before renaming`,
|
|
958
|
-
})
|
|
959
933
|
const from = dirname(phase.path)
|
|
960
934
|
const to = join(dirname(from), newId)
|
|
961
935
|
if (yield* fs.exists(to))
|
|
@@ -230,7 +230,7 @@ describe("GraphService", () => {
|
|
|
230
230
|
expect(calls).toBe(0)
|
|
231
231
|
})
|
|
232
232
|
|
|
233
|
-
test("never reports
|
|
233
|
+
test("never reports active or terminal execution units as ready", async () => {
|
|
234
234
|
const root = await createWorkbase()
|
|
235
235
|
roots.push(root)
|
|
236
236
|
const path = "tasks/ship/phases/implement/PHASE.md"
|
|
@@ -177,7 +177,7 @@ describe("IntegrationService", () => {
|
|
|
177
177
|
"agency task status <task> dropped --if-revision <revision> --json",
|
|
178
178
|
"Continue already materialized work",
|
|
179
179
|
"agency pr create <task> [phase]",
|
|
180
|
-
"agency
|
|
180
|
+
"agency task status <task> done --if-revision <revision> --no-pull-request",
|
|
181
181
|
"agency task create <slug> --multi-phase",
|
|
182
182
|
"agency task handoff <investigation-task> <new-task>",
|
|
183
183
|
"agency review refresh <task> --if-revision <revision> --json",
|
|
@@ -260,6 +260,9 @@ describe("IntegrationService", () => {
|
|
|
260
260
|
"!result.authority?.writable?.checkoutPath",
|
|
261
261
|
)
|
|
262
262
|
expect(managedWorkbaseOpencodePlugin).toContain('status !== "working"')
|
|
263
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
264
|
+
'output.env.AGENCY_INVOCATION_SOURCE = "agent"',
|
|
265
|
+
)
|
|
263
266
|
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
264
267
|
"output.env.AGENCY_SESSION_ID = sessionID",
|
|
265
268
|
)
|
|
@@ -626,7 +629,7 @@ describe("IntegrationService", () => {
|
|
|
626
629
|
)
|
|
627
630
|
expect(body).toContain("Never invent entity IDs")
|
|
628
631
|
expect(body).toContain("Preserve parent backlinks")
|
|
629
|
-
expect(body).toContain("dirty-worktree,
|
|
632
|
+
expect(body).toContain("dirty-worktree, revision")
|
|
630
633
|
expect(body).toContain("`agency work` is the human launch flow")
|
|
631
634
|
expect(body).toContain("Agency worker launch target: <target>.")
|
|
632
635
|
expect(body).toContain("environment variables and a generated")
|
|
@@ -636,7 +639,7 @@ describe("IntegrationService", () => {
|
|
|
636
639
|
)
|
|
637
640
|
expect(body).toMatch(/If\s+the prompt\s+and context disagree/)
|
|
638
641
|
expect(body).toContain("marks execution work")
|
|
639
|
-
expect(body).toContain("
|
|
642
|
+
expect(body).toContain("marks execution work")
|
|
640
643
|
expect(body).toContain("formatting, type checks, build, dead-code checks")
|
|
641
644
|
expect(body).toContain("Review and commit the diff")
|
|
642
645
|
expect(body).toContain("Use `agency push`")
|
|
@@ -650,7 +653,7 @@ describe("IntegrationService", () => {
|
|
|
650
653
|
expect(body).toContain("marking it ready")
|
|
651
654
|
expect(body).toMatch(/completing\s+a refinement loop/)
|
|
652
655
|
expect(body).toContain("pausing or handing off")
|
|
653
|
-
expect(body).toContain("
|
|
656
|
+
expect(body).toContain("update status")
|
|
654
657
|
expect(body).toContain("`agency sync`")
|
|
655
658
|
expect(body).toContain("`--no-pull-request --summary <text>`")
|
|
656
659
|
expect(body).toContain("`TASK.md` or `PHASE.md`")
|
|
@@ -262,7 +262,11 @@ export const runLifecycleTransaction = ({
|
|
|
262
262
|
})
|
|
263
263
|
}
|
|
264
264
|
} catch (cause) {
|
|
265
|
-
if (
|
|
265
|
+
if (
|
|
266
|
+
cause instanceof LifecycleTransactionError ||
|
|
267
|
+
cause instanceof RevisionConflictError
|
|
268
|
+
)
|
|
269
|
+
throw cause
|
|
266
270
|
const rollbackErrors: unknown[] = []
|
|
267
271
|
for (const step of [...completed].reverse()) {
|
|
268
272
|
if (!step.rollback) continue
|
|
@@ -230,7 +230,6 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
230
230
|
base: taskData.base,
|
|
231
231
|
pr: taskData.pr,
|
|
232
232
|
status: taskData.status,
|
|
233
|
-
...(taskData.claim ? { claim: taskData.claim } : {}),
|
|
234
233
|
...(taskData.completion
|
|
235
234
|
? { completion: taskData.completion }
|
|
236
235
|
: {}),
|
|
@@ -515,16 +514,10 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
515
514
|
const validStatus = yield* decodeStatus(status)
|
|
516
515
|
if (validStatus === "delegated") {
|
|
517
516
|
return yield* new PhaseError({
|
|
518
|
-
message:
|
|
519
|
-
"Delegation requires explicit ownership; use 'agency claim'",
|
|
517
|
+
message: "Delegated status cannot be set directly",
|
|
520
518
|
})
|
|
521
519
|
}
|
|
522
520
|
const record = yield* service.show(taskId, id, startPath)
|
|
523
|
-
if (record.data.claim?.state === "active") {
|
|
524
|
-
return yield* new PhaseError({
|
|
525
|
-
message: `Phase '${id}' has an active claim; use agency release or agency finish`,
|
|
526
|
-
})
|
|
527
|
-
}
|
|
528
521
|
if (nonPrCompletion && validStatus !== "done") {
|
|
529
522
|
return yield* new PhaseError({
|
|
530
523
|
message: "Non-PR completion is valid only with a done status",
|