@markjaquith/agency 3.2.2 → 3.2.3
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 +20 -58
- package/cli-main.ts +0 -72
- 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 +13 -132
- package/src/cli-parser.ts +10 -101
- package/src/cli.test.ts +12 -111
- 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 +4 -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/workbase/AGENTS.md +9 -15
- package/src/workbase/agent-command.test.ts +0 -3
- package/src/workbase/agent-command.ts +0 -6
- package/src/workbase/document-revision.ts +0 -4
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir, realpath, rename, rm } from "node:fs/promises"
|
|
3
|
+
import { mkdir, realpath, rename, rm, symlink } from "node:fs/promises"
|
|
4
4
|
import { join, resolve } from "node:path"
|
|
5
5
|
import {
|
|
6
6
|
captureErrors,
|
|
@@ -1526,6 +1526,107 @@ pr: null
|
|
|
1526
1526
|
).toBe("keep me\n")
|
|
1527
1527
|
})
|
|
1528
1528
|
|
|
1529
|
+
test("refuses to remove worktrees for active work", async () => {
|
|
1530
|
+
await runTestEffect(
|
|
1531
|
+
TaskService.pipe(
|
|
1532
|
+
Effect.flatMap((service) =>
|
|
1533
|
+
service.create(
|
|
1534
|
+
{
|
|
1535
|
+
id: "active-removal",
|
|
1536
|
+
ticketUrl: null,
|
|
1537
|
+
repo: "agency",
|
|
1538
|
+
branch: "task/active-removal",
|
|
1539
|
+
base: "main",
|
|
1540
|
+
},
|
|
1541
|
+
root,
|
|
1542
|
+
),
|
|
1543
|
+
),
|
|
1544
|
+
),
|
|
1545
|
+
)
|
|
1546
|
+
const workspace = await runTestEffect(
|
|
1547
|
+
WorktreeService.pipe(
|
|
1548
|
+
Effect.flatMap((service) =>
|
|
1549
|
+
service.materialize("active-removal", undefined, root),
|
|
1550
|
+
),
|
|
1551
|
+
),
|
|
1552
|
+
)
|
|
1553
|
+
await runTestEffect(
|
|
1554
|
+
TaskService.pipe(
|
|
1555
|
+
Effect.flatMap((service) =>
|
|
1556
|
+
service.setStatus("active-removal", "working", root),
|
|
1557
|
+
),
|
|
1558
|
+
),
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
await expect(
|
|
1562
|
+
runTestEffect(
|
|
1563
|
+
WorktreeService.pipe(
|
|
1564
|
+
Effect.flatMap((service) =>
|
|
1565
|
+
service.remove("active-removal", undefined, root),
|
|
1566
|
+
),
|
|
1567
|
+
),
|
|
1568
|
+
),
|
|
1569
|
+
).rejects.toThrow("active 'working' ownership")
|
|
1570
|
+
expect(
|
|
1571
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1572
|
+
).toBe(true)
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
test("restores earlier worktrees and preserves the Git failure diagnostic", async () => {
|
|
1576
|
+
await ensureEffectRepository()
|
|
1577
|
+
await runTestEffect(
|
|
1578
|
+
TaskService.pipe(
|
|
1579
|
+
Effect.flatMap((service) =>
|
|
1580
|
+
service.create(
|
|
1581
|
+
{
|
|
1582
|
+
id: "locked-removal",
|
|
1583
|
+
ticketUrl: null,
|
|
1584
|
+
repo: "agency",
|
|
1585
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
1586
|
+
branch: "task/locked-removal",
|
|
1587
|
+
base: "main",
|
|
1588
|
+
},
|
|
1589
|
+
root,
|
|
1590
|
+
),
|
|
1591
|
+
),
|
|
1592
|
+
),
|
|
1593
|
+
)
|
|
1594
|
+
const workspace = await runTestEffect(
|
|
1595
|
+
WorktreeService.pipe(
|
|
1596
|
+
Effect.flatMap((service) =>
|
|
1597
|
+
service.materialize("locked-removal", undefined, root),
|
|
1598
|
+
),
|
|
1599
|
+
),
|
|
1600
|
+
)
|
|
1601
|
+
const referencePath = join(workspace.codePath, "effect")
|
|
1602
|
+
await git([
|
|
1603
|
+
"-C",
|
|
1604
|
+
join(root, "repos/effect"),
|
|
1605
|
+
"worktree",
|
|
1606
|
+
"lock",
|
|
1607
|
+
referencePath,
|
|
1608
|
+
])
|
|
1609
|
+
|
|
1610
|
+
const failure = await runTestEffect(
|
|
1611
|
+
WorktreeService.pipe(
|
|
1612
|
+
Effect.flatMap((service) =>
|
|
1613
|
+
service.remove("locked-removal", undefined, root),
|
|
1614
|
+
),
|
|
1615
|
+
Effect.flip,
|
|
1616
|
+
),
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
expect(failure.message).toContain("locked working tree")
|
|
1620
|
+
expect(failure).toMatchObject({
|
|
1621
|
+
completed: [workspace.writablePath!],
|
|
1622
|
+
rolledBack: [workspace.writablePath!],
|
|
1623
|
+
manualRecovery: [],
|
|
1624
|
+
})
|
|
1625
|
+
for (const checkout of [workspace.writablePath!, referencePath]) {
|
|
1626
|
+
expect(await Bun.file(join(checkout, "README.md")).exists()).toBe(true)
|
|
1627
|
+
}
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1529
1630
|
test("handles a missing checkout without deleting its branch", async () => {
|
|
1530
1631
|
await runTestEffect(
|
|
1531
1632
|
TaskService.pipe(
|
|
@@ -1583,6 +1684,52 @@ pr: null
|
|
|
1583
1684
|
).toBe(0)
|
|
1584
1685
|
})
|
|
1585
1686
|
|
|
1687
|
+
test("does not prune unrelated stale worktree registrations", async () => {
|
|
1688
|
+
for (const id of ["stale-target", "stale-unrelated"]) {
|
|
1689
|
+
await runTestEffect(
|
|
1690
|
+
TaskService.pipe(
|
|
1691
|
+
Effect.flatMap((service) =>
|
|
1692
|
+
service.create(
|
|
1693
|
+
{
|
|
1694
|
+
id,
|
|
1695
|
+
ticketUrl: null,
|
|
1696
|
+
repo: "agency",
|
|
1697
|
+
branch: `task/${id}`,
|
|
1698
|
+
base: "main",
|
|
1699
|
+
},
|
|
1700
|
+
root,
|
|
1701
|
+
),
|
|
1702
|
+
),
|
|
1703
|
+
),
|
|
1704
|
+
)
|
|
1705
|
+
const workspace = await runTestEffect(
|
|
1706
|
+
WorktreeService.pipe(
|
|
1707
|
+
Effect.flatMap((service) => service.materialize(id, undefined, root)),
|
|
1708
|
+
),
|
|
1709
|
+
)
|
|
1710
|
+
await rm(workspace.codePath, { recursive: true })
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
await expect(
|
|
1714
|
+
runTestEffect(
|
|
1715
|
+
WorktreeService.pipe(
|
|
1716
|
+
Effect.flatMap((service) =>
|
|
1717
|
+
service.remove("stale-target", undefined, root),
|
|
1718
|
+
),
|
|
1719
|
+
),
|
|
1720
|
+
),
|
|
1721
|
+
).rejects.toThrow("would also remove unrelated stale registrations")
|
|
1722
|
+
const registered = await gitOutput([
|
|
1723
|
+
"-C",
|
|
1724
|
+
join(root, "repos/agency"),
|
|
1725
|
+
"worktree",
|
|
1726
|
+
"list",
|
|
1727
|
+
"--porcelain",
|
|
1728
|
+
])
|
|
1729
|
+
expect(registered).toContain("tasks/stale-target/code/agency")
|
|
1730
|
+
expect(registered).toContain("tasks/stale-unrelated/code/agency")
|
|
1731
|
+
})
|
|
1732
|
+
|
|
1586
1733
|
test("lists and inspects ownership, registration, commits, and dirtiness", async () => {
|
|
1587
1734
|
const repository = join(root, "repos/agency")
|
|
1588
1735
|
await git(["-C", repository, "branch", "task/inspected", "main"])
|
|
@@ -1712,6 +1859,50 @@ pr: null
|
|
|
1712
1859
|
expect(await Bun.file(unexpected).text()).toBe("keep me\n")
|
|
1713
1860
|
})
|
|
1714
1861
|
|
|
1862
|
+
test("does not follow a symlink named for an expected checkout", async () => {
|
|
1863
|
+
await runTestEffect(
|
|
1864
|
+
TaskService.pipe(
|
|
1865
|
+
Effect.flatMap((service) =>
|
|
1866
|
+
service.create(
|
|
1867
|
+
{
|
|
1868
|
+
id: "symlink-removal",
|
|
1869
|
+
ticketUrl: null,
|
|
1870
|
+
repo: "agency",
|
|
1871
|
+
branch: "task/symlink-removal",
|
|
1872
|
+
base: "main",
|
|
1873
|
+
},
|
|
1874
|
+
root,
|
|
1875
|
+
),
|
|
1876
|
+
),
|
|
1877
|
+
),
|
|
1878
|
+
)
|
|
1879
|
+
const repository = join(root, "repos/agency")
|
|
1880
|
+
await git(["-C", repository, "branch", "task/symlink-removal", "main"])
|
|
1881
|
+
const outside = join(root, "outside-symlink-removal")
|
|
1882
|
+
await git([
|
|
1883
|
+
"-C",
|
|
1884
|
+
repository,
|
|
1885
|
+
"worktree",
|
|
1886
|
+
"add",
|
|
1887
|
+
outside,
|
|
1888
|
+
"task/symlink-removal",
|
|
1889
|
+
])
|
|
1890
|
+
const codePath = join(root, "tasks/symlink-removal/code")
|
|
1891
|
+
await mkdir(codePath, { recursive: true })
|
|
1892
|
+
await symlink(outside, join(codePath, "agency"), "dir")
|
|
1893
|
+
|
|
1894
|
+
await expect(
|
|
1895
|
+
runTestEffect(
|
|
1896
|
+
WorktreeService.pipe(
|
|
1897
|
+
Effect.flatMap((service) =>
|
|
1898
|
+
service.remove("symlink-removal", undefined, root),
|
|
1899
|
+
),
|
|
1900
|
+
),
|
|
1901
|
+
),
|
|
1902
|
+
).rejects.toThrow("is a symbolic link")
|
|
1903
|
+
expect(await Bun.file(join(outside, "README.md")).exists()).toBe(true)
|
|
1904
|
+
})
|
|
1905
|
+
|
|
1715
1906
|
test("refuses removal when a branch has duplicate Agency owners", async () => {
|
|
1716
1907
|
await runTestEffect(
|
|
1717
1908
|
TaskService.pipe(
|
|
@@ -182,6 +182,14 @@ const formatCommand = (args: readonly string[]) =>
|
|
|
182
182
|
)
|
|
183
183
|
.join(" ")
|
|
184
184
|
|
|
185
|
+
const describeError = (error: unknown) =>
|
|
186
|
+
typeof error === "object" &&
|
|
187
|
+
error !== null &&
|
|
188
|
+
"message" in error &&
|
|
189
|
+
typeof error.message === "string"
|
|
190
|
+
? error.message
|
|
191
|
+
: String(error)
|
|
192
|
+
|
|
185
193
|
const runPostCheckoutHook = (options: {
|
|
186
194
|
readonly command: readonly string[] | undefined
|
|
187
195
|
readonly variables: CheckoutCommandVariables
|
|
@@ -1871,6 +1879,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1871
1879
|
}
|
|
1872
1880
|
| { review: { repo: string; commit: string } }
|
|
1873
1881
|
let codePath: string
|
|
1882
|
+
let executionState: {
|
|
1883
|
+
readonly status: string
|
|
1884
|
+
readonly claim?: { readonly state: string }
|
|
1885
|
+
}
|
|
1874
1886
|
if ("phases" in task.data) {
|
|
1875
1887
|
if (!phaseId) {
|
|
1876
1888
|
return yield* new WorktreeError({
|
|
@@ -1880,6 +1892,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1880
1892
|
const phase =
|
|
1881
1893
|
options.phase ?? (yield* phases.show(taskId, phaseId, root))
|
|
1882
1894
|
execution = phase.data
|
|
1895
|
+
executionState = phase.data
|
|
1883
1896
|
codePath = join(dirname(phase.path), "code")
|
|
1884
1897
|
} else {
|
|
1885
1898
|
if (phaseId) {
|
|
@@ -1888,8 +1901,25 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1888
1901
|
})
|
|
1889
1902
|
}
|
|
1890
1903
|
execution = task.data
|
|
1904
|
+
executionState = task.data
|
|
1891
1905
|
codePath = join(dirname(task.path), "code")
|
|
1892
1906
|
}
|
|
1907
|
+
const ownerLabel = phaseId
|
|
1908
|
+
? `Phase '${taskId}/${phaseId}'`
|
|
1909
|
+
: `Task '${taskId}'`
|
|
1910
|
+
if (executionState.claim?.state === "active") {
|
|
1911
|
+
return yield* new WorktreeError({
|
|
1912
|
+
message: `${ownerLabel} has an active claim; release or finish it before removing its worktrees`,
|
|
1913
|
+
})
|
|
1914
|
+
}
|
|
1915
|
+
if (
|
|
1916
|
+
executionState.status === "working" ||
|
|
1917
|
+
executionState.status === "delegated"
|
|
1918
|
+
) {
|
|
1919
|
+
return yield* new WorktreeError({
|
|
1920
|
+
message: `${ownerLabel} has active '${executionState.status}' ownership; reopen it before removing its worktrees`,
|
|
1921
|
+
})
|
|
1922
|
+
}
|
|
1893
1923
|
|
|
1894
1924
|
const codeDirectoryExists = yield* fs.isDirectory(codePath)
|
|
1895
1925
|
const removalPlans: {
|
|
@@ -1931,6 +1961,11 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1931
1961
|
const alias = checkout.repo
|
|
1932
1962
|
const repositoryPath = join(root, "repos", alias)
|
|
1933
1963
|
const checkoutPath = join(codePath, alias)
|
|
1964
|
+
if ((yield* fs.readSymlinkTarget(checkoutPath)) !== null) {
|
|
1965
|
+
return yield* new WorktreeError({
|
|
1966
|
+
message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is a symbolic link`,
|
|
1967
|
+
})
|
|
1968
|
+
}
|
|
1934
1969
|
if (
|
|
1935
1970
|
(yield* fs.exists(checkoutPath)) &&
|
|
1936
1971
|
!(yield* fs.isDirectory(checkoutPath))
|
|
@@ -2024,7 +2059,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2024
2059
|
["git", "-C", checkoutPath, "status", "--porcelain"],
|
|
2025
2060
|
{ captureOutput: true },
|
|
2026
2061
|
)
|
|
2027
|
-
if (status.exitCode !== 0
|
|
2062
|
+
if (status.exitCode !== 0) {
|
|
2063
|
+
return yield* new WorktreeError({
|
|
2064
|
+
message: `Failed to remove worktree for '${alias}': checkout cleanliness could not be verified: ${status.stderr.trim() || `git status exited with code ${status.exitCode}`}`,
|
|
2065
|
+
})
|
|
2066
|
+
}
|
|
2067
|
+
if (status.stdout.trim()) {
|
|
2028
2068
|
return yield* new WorktreeError({
|
|
2029
2069
|
message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
|
|
2030
2070
|
})
|
|
@@ -2039,6 +2079,25 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2039
2079
|
head: registered.head,
|
|
2040
2080
|
branch: registered.branch?.replace(/^refs\/heads\//, ""),
|
|
2041
2081
|
})
|
|
2082
|
+
if (!checkoutExists) {
|
|
2083
|
+
const unrelatedStale: string[] = []
|
|
2084
|
+
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
2085
|
+
const worktreePath = (yield* fs.exists(worktree.path))
|
|
2086
|
+
? yield* fs.realPath(worktree.path)
|
|
2087
|
+
: resolve(worktree.path)
|
|
2088
|
+
if (
|
|
2089
|
+
worktreePath !== registered.path &&
|
|
2090
|
+
!(yield* fs.exists(worktree.path))
|
|
2091
|
+
) {
|
|
2092
|
+
unrelatedStale.push(worktree.path)
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
if (unrelatedStale.length > 0) {
|
|
2096
|
+
return yield* new WorktreeError({
|
|
2097
|
+
message: `Cannot remove stale registration ${registered.path} because pruning would also remove unrelated stale registrations: ${unrelatedStale.join(", ")}`,
|
|
2098
|
+
})
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2042
2101
|
}
|
|
2043
2102
|
for (const plan of removalPlans) {
|
|
2044
2103
|
if (!plan.checkoutExists || !plan.head) continue
|
|
@@ -2056,6 +2115,38 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2056
2115
|
}
|
|
2057
2116
|
|
|
2058
2117
|
const completed: typeof removalPlans = []
|
|
2118
|
+
const planState = (plan: (typeof removalPlans)[number]) =>
|
|
2119
|
+
Effect.gen(function* () {
|
|
2120
|
+
const checkoutExists = yield* fs.isDirectory(plan.checkoutPath)
|
|
2121
|
+
const listed = yield* fs.runCommand(
|
|
2122
|
+
[
|
|
2123
|
+
"git",
|
|
2124
|
+
"-C",
|
|
2125
|
+
plan.repositoryPath,
|
|
2126
|
+
"worktree",
|
|
2127
|
+
"list",
|
|
2128
|
+
"--porcelain",
|
|
2129
|
+
"-z",
|
|
2130
|
+
],
|
|
2131
|
+
{ captureOutput: true },
|
|
2132
|
+
)
|
|
2133
|
+
if (listed.exitCode !== 0) {
|
|
2134
|
+
return yield* new WorktreeError({
|
|
2135
|
+
message: `Failed to inspect worktrees for '${plan.alias}' during recovery: ${listed.stderr.trim() || `git worktree list exited with code ${listed.exitCode}`}`,
|
|
2136
|
+
})
|
|
2137
|
+
}
|
|
2138
|
+
let registered = false
|
|
2139
|
+
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
2140
|
+
const worktreePath = (yield* fs.exists(worktree.path))
|
|
2141
|
+
? yield* fs.realPath(worktree.path)
|
|
2142
|
+
: resolve(worktree.path)
|
|
2143
|
+
if (worktreePath === plan.registeredPath) {
|
|
2144
|
+
registered = true
|
|
2145
|
+
break
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return { checkoutExists, registered }
|
|
2149
|
+
})
|
|
2059
2150
|
const removed = yield* Effect.gen(function* () {
|
|
2060
2151
|
for (const plan of removalPlans) {
|
|
2061
2152
|
const command = plan.checkoutExists
|
|
@@ -2080,8 +2171,18 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2080
2171
|
captureOutput: true,
|
|
2081
2172
|
})
|
|
2082
2173
|
if (result.exitCode !== 0) {
|
|
2174
|
+
const state = yield* planState(plan).pipe(
|
|
2175
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
2176
|
+
)
|
|
2177
|
+
if (
|
|
2178
|
+
!state ||
|
|
2179
|
+
state.checkoutExists !== plan.checkoutExists ||
|
|
2180
|
+
!state.registered
|
|
2181
|
+
) {
|
|
2182
|
+
completed.push(plan)
|
|
2183
|
+
}
|
|
2083
2184
|
return yield* new WorktreeError({
|
|
2084
|
-
message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
|
|
2185
|
+
message: `Failed to remove worktree for '${plan.alias}': ${result.stderr.trim() || `git exited with code ${result.exitCode}`}`,
|
|
2085
2186
|
})
|
|
2086
2187
|
}
|
|
2087
2188
|
completed.push(plan)
|
|
@@ -2099,43 +2200,77 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2099
2200
|
for (const plan of [...completed].reverse()) {
|
|
2100
2201
|
if (!plan.checkoutExists) {
|
|
2101
2202
|
manualRecovery.push(
|
|
2102
|
-
`
|
|
2203
|
+
`Restore stale registration ${plan.registeredPath} if it is still needed`,
|
|
2103
2204
|
)
|
|
2104
2205
|
continue
|
|
2105
2206
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
plan.checkoutPath
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2207
|
+
const recovery = Effect.gen(function* () {
|
|
2208
|
+
const state = yield* planState(plan)
|
|
2209
|
+
if (state.checkoutExists && state.registered) {
|
|
2210
|
+
rolledBack.push(plan.checkoutPath)
|
|
2211
|
+
return
|
|
2212
|
+
}
|
|
2213
|
+
if (state.checkoutExists || state.registered) {
|
|
2214
|
+
manualRecovery.push(
|
|
2215
|
+
`Restore ${plan.checkoutPath}; checkout path ${state.checkoutExists ? "exists" : "is missing"} and registration ${state.registered ? "exists" : "is missing"}`,
|
|
2216
|
+
)
|
|
2217
|
+
return
|
|
2218
|
+
}
|
|
2219
|
+
yield* fs.createDirectory(dirname(plan.checkoutPath))
|
|
2220
|
+
const command = plan.branch
|
|
2221
|
+
? [
|
|
2222
|
+
"git",
|
|
2223
|
+
"-C",
|
|
2224
|
+
plan.repositoryPath,
|
|
2225
|
+
"worktree",
|
|
2226
|
+
"add",
|
|
2227
|
+
plan.checkoutPath,
|
|
2228
|
+
plan.branch,
|
|
2229
|
+
]
|
|
2230
|
+
: [
|
|
2231
|
+
"git",
|
|
2232
|
+
"-C",
|
|
2233
|
+
plan.repositoryPath,
|
|
2234
|
+
"worktree",
|
|
2235
|
+
"add",
|
|
2236
|
+
"--detach",
|
|
2237
|
+
plan.checkoutPath,
|
|
2238
|
+
plan.head!,
|
|
2239
|
+
]
|
|
2240
|
+
const restored = yield* fs.runCommand(command, {
|
|
2241
|
+
captureOutput: true,
|
|
2242
|
+
})
|
|
2243
|
+
if (restored.exitCode === 0) {
|
|
2244
|
+
rolledBack.push(plan.checkoutPath)
|
|
2245
|
+
} else {
|
|
2246
|
+
manualRecovery.push(
|
|
2247
|
+
`Restore ${plan.checkoutPath}: ${restored.stderr.trim() || `git exited with code ${restored.exitCode}`}`,
|
|
2248
|
+
)
|
|
2249
|
+
}
|
|
2250
|
+
}).pipe(
|
|
2251
|
+
Effect.catchAll((recoveryCause) =>
|
|
2252
|
+
Effect.sync(() => {
|
|
2253
|
+
manualRecovery.push(
|
|
2254
|
+
`Restore ${plan.checkoutPath}: ${describeError(recoveryCause)}`,
|
|
2255
|
+
)
|
|
2256
|
+
}),
|
|
2257
|
+
),
|
|
2258
|
+
)
|
|
2259
|
+
yield* recovery
|
|
2133
2260
|
}
|
|
2261
|
+
const causeMessage = describeError(cause)
|
|
2134
2262
|
return yield* new WorktreeError({
|
|
2135
|
-
message:
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2263
|
+
message:
|
|
2264
|
+
completed.length === 0
|
|
2265
|
+
? `${causeMessage}; no worktrees were removed`
|
|
2266
|
+
: manualRecovery.length
|
|
2267
|
+
? `${causeMessage}. Worktree removal requires manual recovery`
|
|
2268
|
+
: `${causeMessage}. Removed worktrees were restored`,
|
|
2269
|
+
completed: completed.map((plan) =>
|
|
2270
|
+
plan.checkoutExists
|
|
2271
|
+
? plan.checkoutPath
|
|
2272
|
+
: plan.registeredPath,
|
|
2273
|
+
),
|
|
2139
2274
|
rolledBack,
|
|
2140
2275
|
manualRecovery,
|
|
2141
2276
|
cause,
|
package/src/test-utils.ts
CHANGED
|
@@ -16,7 +16,6 @@ import { ArchiveService } from "./services/ArchiveService"
|
|
|
16
16
|
import { IntegrationService } from "./services/IntegrationService"
|
|
17
17
|
import { ContextService } from "./services/ContextService"
|
|
18
18
|
import { GraphService } from "./services/GraphService"
|
|
19
|
-
import { ClaimService } from "./services/ClaimService"
|
|
20
19
|
import { SyncService } from "./services/SyncService"
|
|
21
20
|
import { ReadinessService } from "./services/ReadinessService"
|
|
22
21
|
import { GraphMutationService } from "./services/GraphMutationService"
|
|
@@ -48,7 +47,6 @@ const TestLayer = Layer.mergeAll(
|
|
|
48
47
|
IntegrationService.Default,
|
|
49
48
|
ContextService.Default,
|
|
50
49
|
GraphService.Default,
|
|
51
|
-
ClaimService.Default,
|
|
52
50
|
SyncService.Default,
|
|
53
51
|
ReadinessService.Default,
|
|
54
52
|
GraphMutationService.Default,
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -46,9 +46,7 @@ retain `--if-revision` guards when shown, and do not add flags that are not show
|
|
|
46
46
|
`agency push --json`. Create and record a pull request with
|
|
47
47
|
`agency pr create <task> [phase] [--draft] [--title <title>] [--label <label>] --json`;
|
|
48
48
|
do not run a separate push first because `pr create` owns publication.
|
|
49
|
-
12. Complete genuine non-PR work.
|
|
50
|
-
`agency finish <task> [phase] --session-id <id> --revision <revision> --outcome done --no-pull-request --summary <text> [--evidence-url <url>]`.
|
|
51
|
-
Without a claim, run
|
|
49
|
+
12. Complete genuine non-PR work. Run
|
|
52
50
|
`agency task status <task> done --if-revision <revision> --no-pull-request --summary <text> [--evidence-url <url>] --json`
|
|
53
51
|
or
|
|
54
52
|
`agency phase status <task> <phase> done --if-revision <revision> --no-pull-request --summary <text> [--evidence-url <url>] --json`.
|
|
@@ -162,14 +160,14 @@ revision stale, and Agency must not silently rewrite that evidence.
|
|
|
162
160
|
|
|
163
161
|
## Safety
|
|
164
162
|
|
|
165
|
-
- Stop on validation errors, dependency blockers, an unexpected writable
|
|
166
|
-
repository
|
|
163
|
+
- Stop on validation errors, dependency blockers, or an unexpected writable
|
|
164
|
+
repository.
|
|
167
165
|
- Do not manually create, move, or remove worktrees under `code/`.
|
|
168
166
|
- Use `agency archive`, rather than moving work item folders manually.
|
|
169
167
|
- Do not edit bare repositories or repository symlinks under `repos/`.
|
|
170
168
|
- Never invent entity IDs, revisions, PR state, dependency completion, or
|
|
171
169
|
checkout state. Preserve parent backlinks and dependency declarations.
|
|
172
|
-
- Do not bypass dirty-worktree,
|
|
170
|
+
- Do not bypass dirty-worktree, revision, or readiness protections.
|
|
173
171
|
- Do not run `agency work` from an active agent session unless the user
|
|
174
172
|
explicitly asks to launch another agent.
|
|
175
173
|
- Run `agency validate` before worktree or pull-request operations.
|
|
@@ -189,10 +187,8 @@ resolve its reported commits and remediation commands before retrying.
|
|
|
189
187
|
|
|
190
188
|
`agency work` is the human launch flow: it reconciles managed integration,
|
|
191
189
|
selects work, checks readiness, prepares checkouts, marks execution work
|
|
192
|
-
`working
|
|
193
|
-
|
|
194
|
-
an execution unit, launch and monitor their agent separately, and finish or
|
|
195
|
-
release the claim with the current document revision.
|
|
190
|
+
`working`, and starts the agent. Epic and multi-phase task launches remain
|
|
191
|
+
orchestration-only.
|
|
196
192
|
|
|
197
193
|
An Agency-launched agent receives process-local worker identity through both
|
|
198
194
|
the `AGENCY_SESSION_ID` and `AGENCY_TARGET` environment variables and a generated
|
|
@@ -228,13 +224,11 @@ intent, `--no-pull-request`, and a durable outcome summary.
|
|
|
228
224
|
At each closeout trigger (creating or updating a PR, marking it ready, completing
|
|
229
225
|
a refinement loop, or pausing or handing off completed implementation work):
|
|
230
226
|
|
|
231
|
-
-
|
|
232
|
-
successful claim outcome leaves unmerged work `working`. For unclaimed work,
|
|
233
|
-
keep the execution unit `working` through review and merge.
|
|
227
|
+
- Keep the execution unit `working` through review and merge.
|
|
234
228
|
- After merge, run `agency sync` to reconcile the execution unit to
|
|
235
229
|
`done`.
|
|
236
|
-
- For an approved non-PR outcome,
|
|
237
|
-
|
|
230
|
+
- For an approved non-PR outcome, update status with
|
|
231
|
+
`--no-pull-request --summary <text>` and an optional supporting URL.
|
|
238
232
|
- Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
|
|
239
233
|
PR state, current head, diff summary, and verification results after later
|
|
240
234
|
pushes when those details are maintained there.
|
|
@@ -12,9 +12,7 @@ const variables = {
|
|
|
12
12
|
target: "execution-unit:phase/task/build",
|
|
13
13
|
task: "task",
|
|
14
14
|
phase: "build",
|
|
15
|
-
claimant: "orchestrator",
|
|
16
15
|
sessionId: "session-1",
|
|
17
|
-
claimRevision: "revision-1",
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
describe("agent commands", () => {
|
|
@@ -116,7 +114,6 @@ describe("agent commands", () => {
|
|
|
116
114
|
|
|
117
115
|
expect(environment).toMatchObject({
|
|
118
116
|
AGENCY_AGENT: "custom",
|
|
119
|
-
AGENCY_CLAIMANT: "orchestrator",
|
|
120
117
|
AGENCY_SESSION_ID: "session-1",
|
|
121
118
|
AGENCY_WORKBASE: "/workbase",
|
|
122
119
|
AGENCY_TARGET: "execution-unit:phase/task/build",
|
|
@@ -6,9 +6,7 @@ export interface AgentCommandVariables {
|
|
|
6
6
|
readonly target: string
|
|
7
7
|
readonly task: string
|
|
8
8
|
readonly phase: string
|
|
9
|
-
readonly claimant: string
|
|
10
9
|
readonly sessionId: string
|
|
11
|
-
readonly claimRevision: string
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
interface AgentDefinition {
|
|
@@ -25,9 +23,7 @@ const PLACEHOLDERS = new Set<keyof AgentCommandVariables>([
|
|
|
25
23
|
"target",
|
|
26
24
|
"task",
|
|
27
25
|
"phase",
|
|
28
|
-
"claimant",
|
|
29
26
|
"sessionId",
|
|
30
|
-
"claimRevision",
|
|
31
27
|
])
|
|
32
28
|
|
|
33
29
|
const BUILTIN_AGENTS: Readonly<Record<string, AgentDefinition>> = {
|
|
@@ -122,9 +118,7 @@ export const agentEnvironment = (
|
|
|
122
118
|
variables: AgentCommandVariables,
|
|
123
119
|
): Record<string, string> => ({
|
|
124
120
|
AGENCY_AGENT: agent,
|
|
125
|
-
AGENCY_CLAIMANT: variables.claimant,
|
|
126
121
|
AGENCY_SESSION_ID: variables.sessionId,
|
|
127
|
-
AGENCY_CLAIM_REVISION: variables.claimRevision,
|
|
128
122
|
AGENCY_WORKBASE: variables.workbase,
|
|
129
123
|
AGENCY_TARGET: variables.target,
|
|
130
124
|
AGENCY_TASK_ID: variables.task,
|
|
@@ -3,9 +3,6 @@ import { Data } from "effect"
|
|
|
3
3
|
export const documentRevision = (content: string) =>
|
|
4
4
|
new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
5
5
|
|
|
6
|
-
export const isDocumentRevision = (revision: string) =>
|
|
7
|
-
/^[a-f0-9]{64}$/.test(revision)
|
|
8
|
-
|
|
9
6
|
export class RevisionConflictError extends Data.TaggedError(
|
|
10
7
|
"RevisionConflictError",
|
|
11
8
|
)<{
|
|
@@ -14,5 +11,4 @@ export class RevisionConflictError extends Data.TaggedError(
|
|
|
14
11
|
readonly target?: string
|
|
15
12
|
readonly expectedRevision: string
|
|
16
13
|
readonly currentRevision: string
|
|
17
|
-
readonly claim?: unknown
|
|
18
14
|
}> {}
|
|
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
|
|
2
2
|
import { Schema } from "@effect/schema"
|
|
3
3
|
import {
|
|
4
4
|
EntityId,
|
|
5
|
-
ClaimRecord,
|
|
6
5
|
EpicFrontmatter,
|
|
7
6
|
PhaseFrontmatter,
|
|
8
7
|
TaskFrontmatter,
|
|
@@ -401,30 +400,18 @@ describe("work status", () => {
|
|
|
401
400
|
})
|
|
402
401
|
})
|
|
403
402
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
expect(Schema.decodeUnknownSync(ClaimRecord)(record)).toEqual(record)
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
test("rejects malformed timestamps, revisions, and empty identities", () => {
|
|
420
|
-
for (const invalid of [
|
|
421
|
-
{ ...record, claimant: "" },
|
|
422
|
-
{ ...record, startedAt: "today" },
|
|
423
|
-
{ ...record, targetRevision: "abc" },
|
|
424
|
-
]) {
|
|
425
|
-
expect(() => Schema.decodeUnknownSync(ClaimRecord)(invalid)).toThrow()
|
|
426
|
-
}
|
|
427
|
-
})
|
|
403
|
+
test("rejects removed claim frontmatter", () => {
|
|
404
|
+
expect(() =>
|
|
405
|
+
Schema.decodeUnknownSync(TaskFrontmatter, { onExcessProperty: "error" })({
|
|
406
|
+
ticketUrl: null,
|
|
407
|
+
repo: "agency",
|
|
408
|
+
branch: "task/example",
|
|
409
|
+
base: "main",
|
|
410
|
+
pr: null,
|
|
411
|
+
status: "working",
|
|
412
|
+
claim: { state: "active" },
|
|
413
|
+
}),
|
|
414
|
+
).toThrow()
|
|
428
415
|
})
|
|
429
416
|
|
|
430
417
|
describe("workbase registry", () => {
|