@dirwin517/bot-farm 0.1.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/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- package/test/workspaces.mjs.tmp +0 -0
package/test/craft.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Cheap and on track: model routing by difficulty, trimmed handoffs, team
|
|
2
|
+
// notes, loop detection; and the fun part: XP, levels, perks, harvests, PR
|
|
3
|
+
// packets and portable replays.
|
|
4
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
import { mkdtemp, writeFile, readFile, stat } from "node:fs/promises"
|
|
7
|
+
import { tmpdir } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
10
|
+
import { startServer } from "../src/server.mjs"
|
|
11
|
+
import { difficulty, trimText, stuckReason, routeFor } from "../src/craft.mjs"
|
|
12
|
+
import { ModelLedger } from "../src/quota.mjs"
|
|
13
|
+
import { levelOf } from "../src/farmhands.mjs"
|
|
14
|
+
|
|
15
|
+
const run = promisify(execFile)
|
|
16
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
18
|
+
let failed = 0
|
|
19
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
20
|
+
const repo = await mkdtemp(join(tmpdir(), "craft-"))
|
|
21
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
22
|
+
await writeFile(join(repo, "a"), "x")
|
|
23
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
24
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
25
|
+
const home = await mkdtemp(join(tmpdir(), "crafth-"))
|
|
26
|
+
process.env.BOT_FARM_WORKTREE_ROOT = join(home, "wt")
|
|
27
|
+
await sleep(600)
|
|
28
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home, "m.json"), registryPath: join(home, "r.json"), mcpBase: "http://127.0.0.1:4843" })
|
|
29
|
+
await sup.start()
|
|
30
|
+
startServer({ supervisor: sup, port: 4843 })
|
|
31
|
+
const api = async (path, opts = {}) => {
|
|
32
|
+
const res = await fetch(`http://127.0.0.1:4843${path}`, { method: opts.method ?? "GET", headers: opts.body ? { "content-type": "application/json" } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
33
|
+
return { status: res.status, body: await res.json().catch(() => null) }
|
|
34
|
+
}
|
|
35
|
+
const prompts = async (id) => (await (await fetch("http://127.0.0.1:4096/__prompts")).json()).filter((p) => p.id === id)
|
|
36
|
+
const T = (id) => sup.tasks.get(id)
|
|
37
|
+
const idleAll = () => { for (const s of sup.store.list()) s.setStatus("idle") }
|
|
38
|
+
|
|
39
|
+
console.log("difficulty, without a model")
|
|
40
|
+
ok(difficulty({ title: "Fix typo in README" }).level === "easy", "a typo in the docs is easy")
|
|
41
|
+
ok(difficulty({ title: "Refactor the auth flow", detail: "migrate sessions to the new security model" }).level === "hard", "a security refactor is hard")
|
|
42
|
+
ok(difficulty({ title: "Return 409 when a FRID exists on DELETE /cards/{id}", detail: "The service should check the FRID repository before deleting and map the conflict in the controller advice, returning a JSON error body." }).level === "normal", "an ordinary change is normal")
|
|
43
|
+
ok(difficulty({ explicit: "hard", title: "typo" }).level === "hard", "a difficulty set on the piece wins")
|
|
44
|
+
|
|
45
|
+
console.log("\ntrimming handoffs, without a model")
|
|
46
|
+
const long = "Did the thing. ".repeat(40) + "\n```java\n" + "int x = 1;\n".repeat(30) + "```\n" + "Decided to keep FridDao.existsActive. ".repeat(80)
|
|
47
|
+
const brief = trimText(long, 800)
|
|
48
|
+
ok(brief.length < 1000 && brief.includes("[code block, 31 lines") && brief.includes("botfarm_handoff"), `a long handoff becomes a brief (${long.length} → ${brief.length} chars), code blocks become pointers`)
|
|
49
|
+
|
|
50
|
+
console.log("\nloops, from the tool log")
|
|
51
|
+
const call = (name, summary, status = "completed") => ({ name, summary, status, at: Date.now() })
|
|
52
|
+
ok(stuckReason([call("grep", "FridDao"), call("read", "a.java"), call("grep", "FridDao"), call("grep", "FridDao")])?.why.includes("repeated grep"), "the same grep three times is a loop")
|
|
53
|
+
ok(stuckReason([1, 2, 3, 4, 5].map((i) => call("bash", "mvn test " + i, "error")))?.why.includes("last 5"), "five failures in a row is a loop")
|
|
54
|
+
ok(stuckReason(Array.from({ length: 20 }, (_, i) => call("read", "f" + i)), { tokens: 300_000 })?.why.includes("without changing a file"), "300k tokens and no edits is a loop")
|
|
55
|
+
ok(!stuckReason([call("read", "a"), call("edit", "a"), call("read", "b")], { tokens: 10_000 }), "normal work is not")
|
|
56
|
+
{
|
|
57
|
+
const { inputSig, summarizeToolInput } = await import("../src/store.mjs")
|
|
58
|
+
const mcp = (input) => ({ name: "local-dev_repo_grep", summary: summarizeToolInput(input), sig: inputSig(input), status: "completed", at: Date.now() })
|
|
59
|
+
ok(!stuckReason([mcp({ regex: "FridDao", glob: "*.java" }), mcp({ regex: "FridDao", glob: "*.xml" }), mcp({ regex: "CardService" })]), "the same tool three times with different arguments is not a loop")
|
|
60
|
+
ok(stuckReason([mcp({ regex: "FridDao", glob: "*.java" }), mcp({ glob: "*.java", regex: "FridDao" }), mcp({ regex: "FridDao", glob: "*.java" })])?.why.includes("repeated local-dev_repo_grep"), "with exactly the same arguments (in any order) it is")
|
|
61
|
+
const mvn = { name: "bash", summary: "mvn -q test", sig: inputSig({ command: "mvn -q test" }), status: "error", at: 1 }
|
|
62
|
+
ok(!stuckReason([mvn, call("edit", "A.java"), mvn, call("edit", "A.java"), mvn]), "running the same build after each edit is not a loop")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log("\nwhat a card changed, however it changed it")
|
|
66
|
+
{
|
|
67
|
+
const g = await import("../src/git.mjs")
|
|
68
|
+
const d = await mkdtemp(join(tmpdir(), "snap-"))
|
|
69
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: d })
|
|
70
|
+
await writeFile(join(d, "Svc.java"), "class Svc {\n int a = 1;\n}\n")
|
|
71
|
+
await writeFile(join(d, "Keep.java"), "class Keep {}\n")
|
|
72
|
+
await run("git", ["add", "."], { cwd: d })
|
|
73
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: d })
|
|
74
|
+
// Someone else's uncommitted work, before the card starts.
|
|
75
|
+
await writeFile(join(d, "Svc.java"), "class Svc {\n int a = 1;\n int b = 2;\n}\n")
|
|
76
|
+
const snap = await g.snapshot(d)
|
|
77
|
+
// The card: an edit on top of that, a brand-new file written by "some MCP tool", nothing else.
|
|
78
|
+
await writeFile(join(d, "Svc.java"), "class Svc {\n int a = 1;\n int b = 2;\n int c = 3;\n}\n")
|
|
79
|
+
await writeFile(join(d, "Generated.java"), "class Generated {}\n")
|
|
80
|
+
const ch = await g.changesSince(d, snap)
|
|
81
|
+
const svc = ch.find((c) => c.file === "Svc.java")
|
|
82
|
+
ok(svc && svc.added === 1 && svc.diff.includes("+ int c = 3;") && !svc.diff.includes("+ int b = 2;"), "a file edited during the card shows only this card's change, not earlier uncommitted work")
|
|
83
|
+
ok(ch.some((c) => c.file === "Generated.java" && c.created && c.diff.includes("+class Generated")), "a file written any other way (an MCP tool, a script) shows up too")
|
|
84
|
+
ok(!ch.some((c) => c.file === "Keep.java"), "untouched files do not")
|
|
85
|
+
ok(await g.changedSince(d, snap) && !(await g.changedSince(d, await g.snapshot(d))), "and 'did anything change' is answered from disk")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log("\nrouting a card by how hard it looks")
|
|
89
|
+
const ws = await sup.workspaces.add({ path: repo, name: "craft" })
|
|
90
|
+
ok((await readFile(join(repo, "botfarm", "routing.botfarm.yml"), "utf8")).includes("enabled: false"), "a routing file is written, switched off")
|
|
91
|
+
await writeFile(join(repo, "botfarm", "routing.botfarm.yml"), "enabled: true\ntiers:\n easy:\n model: amazon-bedrock/anthropic.claude-haiku-4-5\n variant: low\n normal: {}\n hard:\n model: amazon-bedrock/anthropic.claude-opus-5\n variant: high\n")
|
|
92
|
+
sup.pipelines.definitions.set("fan", {
|
|
93
|
+
id: "fan", title: "Fan",
|
|
94
|
+
stages: [
|
|
95
|
+
{ id: "plan", persona: "product", receives: [], prompt: "{{story}}" },
|
|
96
|
+
{ id: "build", persona: "dev", receives: ["plan"], split: "tasks", parallel: 2, limits: { minutes: 30 }, prompt: "Build it." },
|
|
97
|
+
{ id: "check", persona: "reviewer", receives: ["build"], prompt: "{{#handoffs.build}}{{summary}}{{/handoffs.build}}" },
|
|
98
|
+
],
|
|
99
|
+
})
|
|
100
|
+
const lib = await sup.pipelines.lib(ws.id)
|
|
101
|
+
for (const [k, v] of sup.pipelines.definitions) lib.definitions.set(k, v)
|
|
102
|
+
for (const [k, v] of sup.pipelines.personas) if (!lib.personas.has(k)) lib.personas.set(k, v)
|
|
103
|
+
const r = await sup.pipelines.start({ pipeline: "fan", workspace: ws.id, story: "US7: two small things", title: "Craft", limits: { usd: 50 } })
|
|
104
|
+
await sleep(300)
|
|
105
|
+
idleAll()
|
|
106
|
+
await sup.pipelines.complete(T(r.taskIds.plan), { summary: long, acceptance_criteria: ["docs fixed", "service secured"], tasks: [{ title: "Fix a typo in the API docs", detail: "One word in docs/api.md" }, { title: "Redesign the auth token refresh", detail: "Migrate token refresh to the new security model; watch for race conditions", difficulty: "hard" }] })
|
|
107
|
+
await sleep(400)
|
|
108
|
+
const [easy, hard] = T(r.taskIds.build).children.map(T)
|
|
109
|
+
ok(easy.difficulty?.level === "easy" && hard.difficulty?.level === "hard", "each piece is sized: easy and hard")
|
|
110
|
+
const easyP = (await prompts(easy.assignee)).at(-1)
|
|
111
|
+
const hardP = (await prompts(hard.assignee)).at(-1)
|
|
112
|
+
ok(easyP.model?.modelID === "anthropic.claude-haiku-4-5" && easyP.variant === "low", "the easy piece runs on haiku, low")
|
|
113
|
+
ok(hardP.model?.modelID === "anthropic.claude-opus-5" && hardP.variant === "high", "the hard one on opus, high")
|
|
114
|
+
const seen = sup.pipelines.contextFor(easy).handoffs.plan
|
|
115
|
+
ok(seen.summary.includes("[code block, 31 lines") && seen.summary.length < 2200 && T(r.taskIds.plan).handoff.summary.length > 3000, `and reads the product bot's handoff trimmed (${T(r.taskIds.plan).handoff.summary.length} → ${seen.summary.length} chars; the card keeps it all)`)
|
|
116
|
+
await sup.setBotSettings(easy.assignee, { model: "amazon-bedrock/anthropic.claude-sonnet-5" })
|
|
117
|
+
await sup.send(easy.assignee, "go on")
|
|
118
|
+
ok((await prompts(easy.assignee)).at(-1).model?.modelID === "anthropic.claude-sonnet-5", "a bot's own Model… choice still wins")
|
|
119
|
+
await sup.setBotSettings(easy.assignee, { model: null, variant: null })
|
|
120
|
+
|
|
121
|
+
console.log("\nrouting from the app")
|
|
122
|
+
let rr = await api(`/api/workspaces/${ws.id}/routing`)
|
|
123
|
+
ok(rr.body.enabled === true && rr.body.tiers.hard.model.endsWith("opus-5"), "the routing page reads the file")
|
|
124
|
+
rr = await api(`/api/workspaces/${ws.id}/routing`, { method: "PUT", body: { enabled: true, tiers: { easy: { model: "amazon-bedrock/anthropic.claude-sonnet-5", variant: "low" }, normal: { variant: "default" }, hard: { model: "amazon-bedrock/anthropic.claude-opus-5", variant: "max" } } } })
|
|
125
|
+
const rtext = await readFile(join(repo, "botfarm", "routing.botfarm.yml"), "utf8")
|
|
126
|
+
ok(rr.status === 200 && rtext.startsWith("# BotFarm model routing") && rtext.includes("variant: max") && !rtext.includes("default"), "and saves it, keeping the explanation at the top")
|
|
127
|
+
ok((await api(`/api/workspaces/${ws.id}/models`)).body.models.some((m) => m.id.endsWith("opus-5")), "with the models opencode offers to pick from")
|
|
128
|
+
let pr2 = await api(`/api/workspaces/${ws.id}/pipelines/pinned`, { method: "PUT", body: { doc: { id: "pinned", stages: [{ id: "review", persona: "reviewer", receives: [], difficulty: "normal", prompt: "{{story}}" }] } } })
|
|
129
|
+
ok(pr2.body.pipelines.find((p) => p.id === "pinned").stages[0].difficulty === "normal" && !pr2.body.problems.some((p) => p.id === "pinned"), "a stage can pin its difficulty")
|
|
130
|
+
pr2 = await api(`/api/workspaces/${ws.id}/pipelines/pinned`, { method: "PUT", body: { doc: { id: "pinned", stages: [{ id: "review", persona: "reviewer", receives: [], difficulty: "huge", prompt: "x" }] } } })
|
|
131
|
+
ok(pr2.body.problems.some((p) => p.id === "pinned" && p.problems.some((x) => /difficulty/.test(x))), "and a wrong one is reported")
|
|
132
|
+
await api(`/api/workspaces/${ws.id}/pipelines/pinned`, { method: "DELETE" })
|
|
133
|
+
const agentSave = await api(`/api/workspaces/${ws.id}/agents/reviewer`, { method: "PUT", body: { doc: { ...(await sup.workspaces.summary(ws.id)).agents.find((a) => a.id === "reviewer"), tiers: { easy: { variant: "medium" }, normal: {}, hard: {} } } } })
|
|
134
|
+
ok(agentSave.body.agents.find((a) => a.id === "reviewer").tiers.easy.variant === "medium", "an agent can route its own way")
|
|
135
|
+
|
|
136
|
+
console.log("\nquotas: so much on opus, then sonnet")
|
|
137
|
+
await api(`/api/workspaces/${ws.id}/routing`, { method: "PUT", body: { enabled: true, tiers: { easy: { model: "amazon-bedrock/anthropic.claude-haiku-4-5", variant: "low" }, normal: {}, hard: { model: "amazon-bedrock/anthropic.claude-opus-5", variant: "high", quota: { usd: 5, per: "day" }, fallback: { model: "amazon-bedrock/anthropic.claude-sonnet-5", variant: "high" } } } } })
|
|
138
|
+
ok((await readFile(join(repo, "botfarm", "routing.botfarm.yml"), "utf8")).includes("fallback:"), "a tier saves a quota and a fallback")
|
|
139
|
+
let view = (await api(`/api/workspaces/${ws.id}/routing`)).body
|
|
140
|
+
ok(view.usage.hard && view.usage.hard.spent.usd === 0 && !view.usage.hard.over, "the routing page shows how much of it is used")
|
|
141
|
+
const devRow = view.agents?.find((a) => a.id === "dev")
|
|
142
|
+
ok(devRow && devRow.levels.hard.source === "workspace" && devRow.levels.hard.quota?.scope === "everyone" && devRow.levels.easy.model.includes("haiku"), "and what each agent gets per size, with where it comes from")
|
|
143
|
+
const hardDev = sup.store.get(hard.assignee)
|
|
144
|
+
hardDev.route = { model: "amazon-bedrock/anthropic.claude-opus-5", variant: "high", quota: { usd: 5, per: "day" }, fallback: { model: "amazon-bedrock/anthropic.claude-sonnet-5", variant: "high" }, taskId: hard.id, level: "hard", wsId: ws.id, projectId: r.projectId, switchMode: "now" }
|
|
145
|
+
hardDev.setStatus("busy")
|
|
146
|
+
sup.ledger.add(ws.id, "amazon-bedrock/us.anthropic.claude-opus-5", r.projectId, { usd: 5.2, tokens: 300_000 })
|
|
147
|
+
view = (await api(`/api/workspaces/${ws.id}/routing`)).body
|
|
148
|
+
ok(view.usage.hard.over?.kind === "usd", "spend on opus counts against it (whatever the region prefix)")
|
|
149
|
+
const before = (await prompts(hardDev.id)).length
|
|
150
|
+
await sup.pipelines.checkQuotas()
|
|
151
|
+
ok(hardDev.route.model.endsWith("sonnet-5") && hardDev.route.fellBack?.why.includes("quota"), "a bot on opus moves to sonnet when the quota runs out")
|
|
152
|
+
const after = await prompts(hardDev.id)
|
|
153
|
+
ok(after.length === before + 1 && after.at(-1).model?.modelID === "anthropic.claude-sonnet-5" && after.at(-1).parts[0].text.includes("now running on"), "at once: its turn is picked up again on sonnet")
|
|
154
|
+
ok(T(hard.id).difficulty.route.fellBack, "the card says it fell back")
|
|
155
|
+
const tier = sup.pipelines.applyQuota({ model: "amazon-bedrock/anthropic.claude-opus-5", variant: "high", quota: { usd: 5, per: "day" }, fallback: { model: "amazon-bedrock/anthropic.claude-sonnet-5" } }, { wsId: ws.id, projectId: r.projectId })
|
|
156
|
+
ok(tier.model.endsWith("sonnet-5") && tier.fellBack, "new hard cards go straight to the fallback")
|
|
157
|
+
ok(!sup.pipelines.applyQuota({ model: "amazon-bedrock/anthropic.claude-opus-5", quota: { usd: 5, per: "workstream" } }, { wsId: ws.id, projectId: "another" }).fellBack, "a per-workstream quota starts fresh in another workstream")
|
|
158
|
+
hardDev.route = null
|
|
159
|
+
await sup.workspaces.saveRouting(ws.id, { enabled: true, tiers: { easy: { model: "amazon-bedrock/anthropic.claude-haiku-4-5", variant: "low" }, normal: {}, hard: { model: "amazon-bedrock/anthropic.claude-opus-5", variant: "high" } } })
|
|
160
|
+
|
|
161
|
+
console.log("\nteam notes and files already read")
|
|
162
|
+
const devA = sup.store.get(easy.assignee), devB = sup.store.get(hard.assignee)
|
|
163
|
+
const token = (s) => sup.mcpUrlFor(s).split("/").pop()
|
|
164
|
+
const rpc = async (s, name, args) => { sup.mcpCalls.set(s.id, Date.now()); return (await (await fetch(`http://127.0.0.1:4843/mcp/${token(s)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) })).json()).result }
|
|
165
|
+
let out = await rpc(devA, "notes_write", { topic: "Auth token flow", text: "TokenService.refresh() → TokenRepo; locks in RefreshLock", files: ["src/auth/TokenService.java"] })
|
|
166
|
+
ok(!out.isError && out.structuredContent.saved === "Auth token flow", "a bot saves a note")
|
|
167
|
+
out = await rpc(devB, "notes_read", {})
|
|
168
|
+
ok(out.structuredContent.notes.some((n) => n.topic === "Auth token flow"), "another lists it")
|
|
169
|
+
out = await rpc(devB, "notes_read", { topic: "auth token" })
|
|
170
|
+
ok(out.structuredContent.text.includes("RefreshLock"), "and reads it by topic")
|
|
171
|
+
out = await rpc(devB, "handoff", { stage: "plan" })
|
|
172
|
+
ok(out.structuredContent.summary.length === long.trim().length, "the full handoff is one call away")
|
|
173
|
+
devA.toolLog.push({ name: "read", summary: "src/auth/TokenService.java", status: "completed", at: Date.now() })
|
|
174
|
+
const ctxText = sup.pipelines.sharedContext(hard)
|
|
175
|
+
ok(ctxText.includes('"Auth token flow"') && ctxText.includes("src/auth/TokenService.java (@" + devA.handle + ")"), "the next card lists the team's notes and the files they read")
|
|
176
|
+
|
|
177
|
+
console.log("\nstuck bots are stopped and asked about")
|
|
178
|
+
devB.setStatus("busy")
|
|
179
|
+
const t0 = Date.now()
|
|
180
|
+
devB.toolLog.push(...[1, 2, 3].map(() => ({ name: "grep", summary: "refreshToken", status: "completed", at: t0 + 10 })))
|
|
181
|
+
await sup.pipelines.checkBudgets(t0 + 1000)
|
|
182
|
+
let h = T(hard.id)
|
|
183
|
+
ok(h.status === "review" && h.budget?.kind === "loop" && h.budget.why.includes("repeated grep"), "a bot repeating itself is paused, with why")
|
|
184
|
+
let res = await api(`/api/tasks/${hard.id}/budget`, { method: "POST", body: { action: "continue" } })
|
|
185
|
+
h = T(hard.id)
|
|
186
|
+
ok(res.status === 200 && h.status === "active" && h.loopQuietUntil > Date.now() && !h.budget, "Continue lets it go on and keeps the check quiet for a while")
|
|
187
|
+
ok((await prompts(devB.id)).at(-1).parts[0].text.includes("looked stuck"), "and tells the bot what it looked like")
|
|
188
|
+
|
|
189
|
+
console.log("\nXP, levels and perks")
|
|
190
|
+
idleAll()
|
|
191
|
+
const wtDir = sup.store.get(easy.assignee).directory
|
|
192
|
+
const { mkdir: mkd2 } = await import("node:fs/promises")
|
|
193
|
+
await mkd2(join(wtDir, "docs"), { recursive: true })
|
|
194
|
+
await writeFile(join(wtDir, "docs", "api.md"), "# API\n\nDELETE /cards/{id} returns 409 when a FRID is active.\n")
|
|
195
|
+
await writeFile(join(wtDir, "a"), "x\nchanged by the dev bot\n")
|
|
196
|
+
// A 1x1 PNG among the artifacts: a screenshot the bot saved.
|
|
197
|
+
await writeFile(join(wtDir, "shot.png"), Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"))
|
|
198
|
+
await writeFile(join(wtDir, "Generated.java"), "class Generated {}\n") // as if an MCP tool wrote it
|
|
199
|
+
for (const k of [easy, hard]) await sup.pipelines.complete(T(k.id), { summary: `Did ${k.title}.`, artifacts: k === easy ? ["docs/api.md", "a", "shot.png"] : ["src/auth/TokenServiceTest.java"] })
|
|
200
|
+
await sleep(300)
|
|
201
|
+
let dev = sup.farmhands.get(ws.id, "dev")
|
|
202
|
+
ok(dev.xp > 0 && T(easy.id).xp?.gained >= 20, `a clean card earns XP (${T(easy.id).xp.reasons.join(", ")})`)
|
|
203
|
+
ok(T(hard.id).xp.gained < T(easy.id).xp.gained, "a card that got stuck earns less")
|
|
204
|
+
sup.farmhands.award(ws.id, "dev", 400, { reasons: [["test", 400]] })
|
|
205
|
+
dev = sup.farmhands.get(ws.id, "dev")
|
|
206
|
+
ok(dev.level >= 5 && dev.hat, `enough XP levels a bot type up (level ${dev.level}, ${dev.title} ${dev.hat})`)
|
|
207
|
+
const lim = sup.pipelines.effectiveLimits({ limits: { minutes: 30 }, runId: r.id, persona: "dev" })
|
|
208
|
+
ok(lim.minutes > 30, `and its perks raise card limits (${lim.minutes} min)`)
|
|
209
|
+
|
|
210
|
+
console.log("\ncard completion in the chat")
|
|
211
|
+
const er = T(easy.id).report
|
|
212
|
+
ok(er && er.handle === sup.store.get(easy.assignee).handle && er.minutes >= 0 && typeof er.tokens === "number" && typeof er.usd === "number", "a finished card has a report: who, how long, tokens, cost")
|
|
213
|
+
ok(er.diffs.some((d) => d.file === "a" && d.added >= 1 && d.diff.includes("+changed by the dev bot")) && er.diffs.some((d) => d.file === "docs/api.md" && d.diff.includes("409")), "with the diff of every file it handed over, as it was then")
|
|
214
|
+
ok(er.images.some((i) => i.path === "shot.png"), "and image files among its artifacts as screenshots")
|
|
215
|
+
ok(er.diffs.some((d) => d.file === "Generated.java" && d.unlisted && d.created), "files changed on disk but not in its handoff are included and marked")
|
|
216
|
+
const roomNow = sup.rooms.rooms.get(sup.pipelines.run(r.id).roomId)
|
|
217
|
+
const ev = roomNow.messages.find((m) => m.meta?.kind === "card" && m.meta.taskId === easy.id)
|
|
218
|
+
ok(ev && ev.meta.files.some((f) => f.file === "a" && f.added >= 1) && ev.meta.images === 1 && ev.meta.tokens === er.tokens && ev.quiet, "the chat gets a card event carrying it (and it wakes nobody)")
|
|
219
|
+
const joinedEv = roomNow.messages.find((m) => m.meta?.kind === "card" && m.meta.taskId === r.taskIds.build)
|
|
220
|
+
ok(joinedEv && joinedEv.meta.pieces === 2 && joinedEv.meta.bots.length === 2, "a split stage gets one for all its pieces")
|
|
221
|
+
res = await api(`/api/tasks/${easy.id}/report`)
|
|
222
|
+
ok(res.status === 200 && res.body.diffs.length >= 2 && res.body.summary.includes("Did"), "the diffs load when you open them")
|
|
223
|
+
const img = await fetch(`http://127.0.0.1:4843/api/tasks/${easy.id}/image/0`)
|
|
224
|
+
ok(img.ok && img.headers.get("content-type") === "image/png" && (await img.arrayBuffer()).byteLength > 20, "and the screenshot is served")
|
|
225
|
+
ok((await fetch(`http://127.0.0.1:4843/api/tasks/${easy.id}/image/5`)).status === 404, "(only the ones it has)")
|
|
226
|
+
|
|
227
|
+
console.log("\npause and resume a workstream")
|
|
228
|
+
const devC = sup.store.get(r.sessions.reviewer)
|
|
229
|
+
res = await api(`/api/projects/${r.projectId}/pause`, { method: "POST", body: {} })
|
|
230
|
+
ok(res.status === 200 && res.body.paused?.kind === "manual" && sup.pipelines.run(r.id).paused, "Pause stops the workstream")
|
|
231
|
+
ok(sup.projects.snapshot().find((p) => p.id === r.projectId) && sup.pipelines.budgetView(sup.projects.get(r.projectId)).paused.kind === "manual", "and the header knows it was you")
|
|
232
|
+
ok(sup.pipelines.run(r.id).paused.interrupted.includes(r.taskIds.check) && devC.status === "idle", "the bot working a card is stopped mid-card")
|
|
233
|
+
const nPrompts = (await prompts(devC.id)).length
|
|
234
|
+
res = await api(`/api/projects/${r.projectId}/resume`, { method: "POST", body: {} })
|
|
235
|
+
await sleep(200)
|
|
236
|
+
ok(res.status === 200 && !sup.pipelines.run(r.id).paused, "Resume lets it go on")
|
|
237
|
+
ok((await prompts(devC.id)).length === nPrompts + 1 && (await prompts(devC.id)).at(-1).parts[0].text.includes("paused by the operator and has been resumed"), "and the stopped bot picks its card up again")
|
|
238
|
+
|
|
239
|
+
console.log("\nharvest, PR packet, replay")
|
|
240
|
+
idleAll()
|
|
241
|
+
await sup.pipelines.complete(T(r.taskIds.check), { summary: "All good; the token refresh is covered by TokenServiceTest.", acceptance_criteria: ["docs fixed", "service secured"] })
|
|
242
|
+
await sleep(600)
|
|
243
|
+
const project = sup.projects.get(r.projectId)
|
|
244
|
+
ok(project.harvest && project.harvest.cards >= 4 && project.harvest.xp > 0, `the finished workstream is harvested (${project.harvest.cards} cards, +${project.harvest.xp} XP)`)
|
|
245
|
+
ok(project.harvest.budget === 50 && project.harvest.saved > 0, "with what it saved against its budget")
|
|
246
|
+
const totals = (await api(`/api/workspaces/${ws.id}/harvest`)).body
|
|
247
|
+
ok(totals.week.stories === 1 && totals.levels.some((l) => l.persona === "dev"), "the harvest log counts it this week, with levels")
|
|
248
|
+
const packetPath = join(repo, ".botfarm", "workstreams", `${r.projectId}.pr.md`)
|
|
249
|
+
ok(await stat(packetPath).then(() => true, () => false), "a PR packet is written when it finishes")
|
|
250
|
+
const packet = (await api(`/api/projects/${r.projectId}/pr`)).body
|
|
251
|
+
ok(packet.markdown.includes("## Acceptance criteria") && packet.markdown.includes("service secured") && packet.markdown.includes("TokenServiceTest.java") && packet.markdown.includes("## Cost"), "with criteria, the tests behind them and the cost")
|
|
252
|
+
const rep = await (await fetch(`http://127.0.0.1:4843/api/projects/${r.projectId}/replay?download=1`)).json()
|
|
253
|
+
ok(rep.format === "botfarm-replay" && rep.events.some((e) => e.k === "task") && rep.events.some((e) => e.k === "msg") && rep.events.some((e) => e.k === "bot") && rep.events.some((e) => e.k === "harvest"), `a replay exports as one file (${rep.events.length} events)`)
|
|
254
|
+
res = await api(`/api/replays`, { method: "POST", body: rep })
|
|
255
|
+
ok(res.status === 201, "it imports")
|
|
256
|
+
const list = (await api(`/api/replays`)).body
|
|
257
|
+
ok(list.some((x) => x.id === res.body.id && x.name === "Craft"), "and shows up with the others")
|
|
258
|
+
res = await api(`/api/replays`, { method: "POST", body: { hello: 1 } })
|
|
259
|
+
ok(res.status === 400, "anything else is refused")
|
|
260
|
+
|
|
261
|
+
console.log("\nper-agent routing")
|
|
262
|
+
{
|
|
263
|
+
const ws = { enabled: true, tiers: { easy: { model: "a/haiku" }, normal: { model: "a/sonnet" }, hard: { model: "a/opus", quota: { usd: 5 }, fallback: { model: "a/sonnet" } } } }
|
|
264
|
+
const own = { hard: { model: "a/opus", variant: "high", quota: { usd: 1 }, fallback: { model: "a/sonnet" } } }
|
|
265
|
+
ok(routeFor("hard", { agentTiers: own, routing: ws }).source === "agent" && routeFor("hard", { agentTiers: own, routing: ws }).variant === "high", "an agent's own rule wins for its size")
|
|
266
|
+
ok(routeFor("easy", { agentTiers: own, routing: ws })?.model === "a/haiku" && routeFor("easy", { agentTiers: own, routing: ws }).source === "workspace", "sizes it leaves out use the workspace's")
|
|
267
|
+
ok(routeFor("easy", { agentTiers: own, routing: { ...ws, enabled: false } }) === null && routeFor("hard", { agentTiers: own, routing: { ...ws, enabled: false } })?.model === "a/opus", "with workspace routing off, only the agent's own rules apply")
|
|
268
|
+
const rows = new Map()
|
|
269
|
+
const led = new ModelLedger({ get: (_, id) => rows.get(id), put: (_, r) => rows.set(r.id, r), all: () => [...rows.values()] })
|
|
270
|
+
led.add("w", "a/opus", null, { usd: 2 }, Date.now(), "dev")
|
|
271
|
+
led.add("w", "a/opus", null, { usd: 3 }, Date.now(), "qa")
|
|
272
|
+
const Y = await import("../src/yaml.mjs")
|
|
273
|
+
const flow = Y.parse("quota: { usd: 3, per: day }\nfallback: { model: a/sonnet }\nnone: {}\nprompt: {{story}}")
|
|
274
|
+
ok(flow.quota.usd === 3 && flow.fallback.model === "a/sonnet" && typeof flow.none === "object" && flow.prompt === "{{story}}", "one-line YAML maps work in hand-edited files (templates stay text)")
|
|
275
|
+
ok(led.spent("w", "a/opus", "day").usd === 5 && led.spent("w", "a/opus@dev", "day").usd === 2, "spend counts for everyone and, separately, per agent")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
sup.stop()
|
|
279
|
+
mock.kill()
|
|
280
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
281
|
+
process.exit(failed ? 1 : 0)
|
package/test/extend.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// One file per agent and pipeline in botfarm/, the split of the old single
|
|
2
|
+
// files, workspace tools from botfarm/mcps/ (served to bots and run from the
|
|
3
|
+
// test bench), and switching a running bot's model and reasoning level.
|
|
4
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
import { mkdtemp, writeFile, readFile, readdir, mkdir, stat } from "node:fs/promises"
|
|
7
|
+
import { tmpdir } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
10
|
+
import { startServer } from "../src/server.mjs"
|
|
11
|
+
|
|
12
|
+
const run = promisify(execFile)
|
|
13
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
15
|
+
let failed = 0
|
|
16
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
17
|
+
const repo = await mkdtemp(join(tmpdir(), "ext-"))
|
|
18
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
19
|
+
await writeFile(join(repo, "a"), "x")
|
|
20
|
+
// An old-style workspace: the two single files, one prompt still naming botfarm_ tools.
|
|
21
|
+
await writeFile(join(repo, "botfarm-agents.yml"), `agents:
|
|
22
|
+
- id: dev
|
|
23
|
+
title: Dev Bot
|
|
24
|
+
role: dev
|
|
25
|
+
prompt: |
|
|
26
|
+
Build it, then call botfarm_task_complete.
|
|
27
|
+
- id: qa
|
|
28
|
+
title: QA Bot
|
|
29
|
+
role: qa
|
|
30
|
+
prompt: Test it.
|
|
31
|
+
`)
|
|
32
|
+
await writeFile(join(repo, "botfarm-pipeline.yml"), `pipelines:
|
|
33
|
+
- id: quick
|
|
34
|
+
title: Quick
|
|
35
|
+
stages:
|
|
36
|
+
- id: build
|
|
37
|
+
persona: dev
|
|
38
|
+
receives: []
|
|
39
|
+
prompt: "{{story}}"
|
|
40
|
+
- id: verify
|
|
41
|
+
persona: qa
|
|
42
|
+
receives: [build]
|
|
43
|
+
prompt: "{{story}}"
|
|
44
|
+
`)
|
|
45
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
46
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
47
|
+
const home = await mkdtemp(join(tmpdir(), "exth-"))
|
|
48
|
+
process.env.BOT_FARM_WORKTREE_ROOT = join(home, "wt")
|
|
49
|
+
await sleep(600)
|
|
50
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home, "m.json"), registryPath: join(home, "r.json"), mcpBase: "http://127.0.0.1:4841" })
|
|
51
|
+
await sup.start()
|
|
52
|
+
startServer({ supervisor: sup, port: 4841 })
|
|
53
|
+
const api = async (path, opts = {}) => {
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:4841${path}`, { method: opts.method ?? "GET", headers: opts.body ? { "content-type": "application/json" } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
55
|
+
return { status: res.status, body: await res.json().catch(() => null) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log("one file per definition, named after its id")
|
|
59
|
+
const ws = await sup.workspaces.add({ path: repo, name: "ext" })
|
|
60
|
+
const files = await readdir(join(repo, "botfarm"))
|
|
61
|
+
ok(files.includes("dev.agent.botfarm.yml") && files.includes("qa.agent.botfarm.yml") && files.includes("quick.pipeline.botfarm.yml"), `the old files are split into botfarm/ (${files.filter((f) => f.endsWith(".yml")).join(", ")})`)
|
|
62
|
+
ok(await stat(join(repo, "botfarm-agents.yml.bak")).then(() => true, () => false) && !(await stat(join(repo, "botfarm-agents.yml")).then(() => true, () => false)), "and kept as .bak")
|
|
63
|
+
ok((await readFile(join(repo, "botfarm", "dev.agent.botfarm.yml"), "utf8")).includes("botfarm_task_complete"), "prompts are moved to the new tool names")
|
|
64
|
+
ok(files.includes("mcps") && (await readdir(join(repo, "botfarm", "mcps"))).includes("worktree_status.js"), "a tools folder with an example tool")
|
|
65
|
+
let lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
66
|
+
ok(lib.agents.map((a) => a.id).sort().join() === "dev,qa" && lib.pipelines[0].id === "quick" && lib.pipelines[0].file === "botfarm/quick.pipeline.botfarm.yml", "the library reads them, each with its file")
|
|
67
|
+
await writeFile(join(repo, "botfarm", "reviewer.agent.botfarm.yml"), "title: Reviewer\nrole: reviewer\nprompt: Review it.\n")
|
|
68
|
+
lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
69
|
+
ok(lib.agents.some((a) => a.id === "reviewer" && a.title === "Reviewer"), "a file dropped in by hand is picked up, its id from the name")
|
|
70
|
+
let r = await api(`/api/workspaces/${ws.id}/pipelines/solo`, { method: "PUT", body: { doc: { id: "solo", title: "Solo", stages: [{ id: "only", persona: "dev", receives: [], prompt: "{{story}}" }] } } })
|
|
71
|
+
ok(r.status === 200 && (await readdir(join(repo, "botfarm"))).includes("solo.pipeline.botfarm.yml"), "saving from the app writes its own file")
|
|
72
|
+
r = await api(`/api/workspaces/${ws.id}/file/pipelines?entry=solo`)
|
|
73
|
+
ok(r.body.text.includes("title: Solo") && !r.body.text.includes("quick"), "the YAML tab shows just that file")
|
|
74
|
+
r = await api(`/api/workspaces/${ws.id}/file/pipelines?entry=solo`, { method: "PUT", body: { text: "title: Solo two\nstages:\n - id: only\n persona: dev\n receives: []\n" } })
|
|
75
|
+
ok(r.status === 200 && r.body.pipelines.find((p) => p.id === "solo").title === "Solo two", "and writes just that file")
|
|
76
|
+
r = await api(`/api/workspaces/${ws.id}/pipelines/solo`, { method: "DELETE" })
|
|
77
|
+
ok(!(await readdir(join(repo, "botfarm"))).includes("solo.pipeline.botfarm.yml"), "deleting removes the file")
|
|
78
|
+
r = await api(`/api/workspaces/${ws.id}/agents/bad%20id`, { method: "PUT", body: { doc: { id: "bad id", prompt: "x" } } })
|
|
79
|
+
ok(r.status >= 400, "an id that cannot be a file name is refused")
|
|
80
|
+
|
|
81
|
+
console.log("\nworkspace tools from botfarm/mcps/")
|
|
82
|
+
await writeFile(join(repo, "botfarm", "mcps", "shout.js"), `export const name = "shout"
|
|
83
|
+
export const description = "Upper-case some text."
|
|
84
|
+
export const inputSchema = { type: "object", properties: { text: { type: "string" }, times: { type: "number" } }, required: ["text"] }
|
|
85
|
+
export async function execute({ text, times = 1 }, ctx) { ctx.log("shouting", times); return { text: text.toUpperCase().repeat(times), where: ctx.repoRoot, bot: ctx.bot?.handle ?? null } }
|
|
86
|
+
`)
|
|
87
|
+
await writeFile(join(repo, "botfarm", "mcps", "broken.js"), `export const name = "roster"\nexport async function execute() {}\n`)
|
|
88
|
+
let tools = (await api(`/api/workspaces/${ws.id}/tools`)).body
|
|
89
|
+
ok(tools.tools.some((t) => t.name === "shout") && tools.tools.some((t) => t.name === "worktree_status"), "every .js file there is a tool")
|
|
90
|
+
ok(tools.errors.some((e) => e.file === "broken.js" && /built-in/.test(e.error)), "a file that takes a built-in name is reported, not loaded")
|
|
91
|
+
r = await api(`/api/workspaces/${ws.id}/tools/shout/run`, { method: "POST", body: { args: { text: "hi", times: 2 } } })
|
|
92
|
+
ok(r.body.ok && r.body.result.text === "HIHI" && r.body.result.where === repo && r.body.logs[0] === "shouting 2", "the test bench runs it in the workspace, with its log")
|
|
93
|
+
r = await api(`/api/workspaces/${ws.id}/tools/worktree_status/run`, { method: "POST", body: { args: {} } })
|
|
94
|
+
ok(r.body.ok && r.body.result.branch === "main", "the example tool runs git in the checkout")
|
|
95
|
+
await writeFile(join(repo, "botfarm", "mcps", "boom.js"), `export const name = "boom"\nexport async function execute() { throw new Error("kaboom") }\n`)
|
|
96
|
+
await sleep(600)
|
|
97
|
+
r = await api(`/api/workspaces/${ws.id}/tools/boom/run`, { method: "POST", body: {} })
|
|
98
|
+
ok(!r.body.ok && r.body.error === "kaboom", "a failing tool comes back as an error, not a crash (and new files load without a restart)")
|
|
99
|
+
r = await api(`/api/workspaces/${ws.id}/tools`, { method: "POST", body: { name: "fresh_tool" } })
|
|
100
|
+
ok(r.status === 201 && r.body.file === "fresh_tool.js" && r.body.tools.some((t) => t.name === "fresh_tool"), "New tool writes a working template")
|
|
101
|
+
r = await api(`/api/workspaces/${ws.id}/tools/fresh_tool.js/source`)
|
|
102
|
+
ok(r.body.text.includes('export const name = "fresh_tool"'), "its source can be read")
|
|
103
|
+
await api(`/api/workspaces/${ws.id}/tools/fresh_tool.js/source`, { method: "PUT", body: { text: r.body.text.replace("Say what this does", "Lists a folder") } })
|
|
104
|
+
tools = (await api(`/api/workspaces/${ws.id}/tools`)).body
|
|
105
|
+
ok(tools.tools.find((t) => t.name === "fresh_tool").description.startsWith("Lists a folder"), "and saving it reloads it")
|
|
106
|
+
|
|
107
|
+
console.log("\nbots see them as botfarm_* next to the built-ins")
|
|
108
|
+
const start = await sup.pipelines.start({ pipeline: "quick", workspace: ws.id, story: "US1: shout", title: "Shout" })
|
|
109
|
+
await sleep(300)
|
|
110
|
+
const dev = sup.store.get(start.sessions.dev)
|
|
111
|
+
const cfg = JSON.parse(await readFile(join(dev.directory, ".opencode", "opencode.json"), "utf8"))
|
|
112
|
+
ok(cfg.mcp.botfarm?.url?.includes("/mcp/") && !cfg.mcp.botfarm, "the worktree's MCP server is called botfarm")
|
|
113
|
+
const token = cfg.mcp.botfarm.url.split("/").pop()
|
|
114
|
+
const rpc = async (method, params) => (await (await fetch(`http://127.0.0.1:4841/mcp/${token}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) })).json()).result
|
|
115
|
+
const init = await rpc("initialize", {})
|
|
116
|
+
ok(init.serverInfo.name === "botfarm", "and introduces itself as botfarm")
|
|
117
|
+
const listed = (await rpc("tools/list", {})).tools.map((t) => t.name)
|
|
118
|
+
ok(listed.includes("task_complete") && listed.includes("shout") && listed.includes("worktree_status"), "tools/list has the built-ins and the workspace's")
|
|
119
|
+
sup.mcpCalls.set(dev.id, Date.now())
|
|
120
|
+
const called = await rpc("tools/call", { name: "shout", arguments: { text: "ok" } })
|
|
121
|
+
ok(called.structuredContent?.text === "OK" && called.structuredContent.where === dev.directory, "a bot's call runs in its own worktree")
|
|
122
|
+
const devPrompt = (await (await fetch("http://127.0.0.1:4096/__prompts")).json()).find((p) => p.id === dev.id)
|
|
123
|
+
ok(JSON.stringify(devPrompt).includes("botfarm_task_complete") && !JSON.stringify(devPrompt).includes("botfarm_"), "prompts name the botfarm_ tools")
|
|
124
|
+
|
|
125
|
+
console.log("\nupgrade or downgrade a running bot")
|
|
126
|
+
r = await api(`/api/sessions/${dev.id}/models`)
|
|
127
|
+
ok(r.body.models.some((m) => m.id === "amazon-bedrock/anthropic.claude-opus-5" && m.variants.includes("max")), "the models opencode offers are listed, with their reasoning levels")
|
|
128
|
+
r = await api(`/api/sessions/${dev.id}/settings`, { method: "POST", body: { model: "amazon-bedrock/anthropic.claude-opus-5", variant: "high" } })
|
|
129
|
+
ok(r.status === 200 && r.body.override.model.endsWith("opus-5") && r.body.configuredModel.endsWith("opus-5"), "switching to opus is recorded on the bot")
|
|
130
|
+
await sup.send(dev.id, "carry on")
|
|
131
|
+
let last = (await (await fetch("http://127.0.0.1:4096/__prompts")).json()).filter((p) => p.id === dev.id).at(-1)
|
|
132
|
+
ok(last.model?.modelID === "anthropic.claude-opus-5" && last.variant === "high", "its next turn runs on opus, high")
|
|
133
|
+
await sup.syncWorkstreamConfig(start.projectId)
|
|
134
|
+
ok(sup.store.get(dev.id).override?.model?.endsWith("opus-5"), "a config refresh keeps it")
|
|
135
|
+
const manifest = await readFile(join(repo, ".botfarm", "workstreams", `${start.projectId}.yml`), "utf8")
|
|
136
|
+
ok(manifest.includes("opus-5"), "and the workstream manifest records it")
|
|
137
|
+
r = await api(`/api/sessions/${dev.id}/settings`, { method: "POST", body: { model: null, variant: null } })
|
|
138
|
+
await sup.send(dev.id, "again")
|
|
139
|
+
last = (await (await fetch("http://127.0.0.1:4096/__prompts")).json()).filter((p) => p.id === dev.id).at(-1)
|
|
140
|
+
ok(!r.body.override && last.model?.modelID !== "anthropic.claude-opus-5", "and it can go back to what its agent says")
|
|
141
|
+
|
|
142
|
+
sup.stop()
|
|
143
|
+
mock.kill()
|
|
144
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
145
|
+
process.exit(failed ? 1 : 0)
|
package/test/flow.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A pipeline that does not stall: bots are briefed when their stage starts,
|
|
2
|
+
// every stage sees the whole story, busy bots are not interrupted by peers,
|
|
3
|
+
// a bot that forgets to hand off is reminded and then escalated, the MCP
|
|
4
|
+
// caller is resolved inside a shared worktree, and an open session streams.
|
|
5
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
6
|
+
import { promisify } from "node:util"
|
|
7
|
+
import { mkdtemp, writeFile } from "node:fs/promises"
|
|
8
|
+
import { tmpdir } from "node:os"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { Supervisor, livePart, summarizeMessage } from "../src/supervisor.mjs"
|
|
11
|
+
import { startServer } from "../src/server.mjs"
|
|
12
|
+
import { toolOf, modelLabel, ingestMessages } from "../src/store.mjs"
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile)
|
|
15
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
16
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
17
|
+
let failed = 0
|
|
18
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
19
|
+
|
|
20
|
+
console.log("parsing opencode shapes")
|
|
21
|
+
ok(toolOf({ type: "tool", tool: "bash", state: { status: "completed", input: { command: "ls" } } })?.name === "bash", "v1 tool part")
|
|
22
|
+
ok(toolOf({ type: "tool-invocation", toolName: "read", status: "done" })?.status === "completed", "other spellings of a tool call and of done")
|
|
23
|
+
ok(toolOf({ type: "text", text: "hi" }) === null, "text is not a tool")
|
|
24
|
+
ok(modelLabel({ providerID: "anthropic", modelID: "claude-sonnet-4-5" }) === "anthropic/claude-sonnet-4-5", "model from a v1 assistant message")
|
|
25
|
+
ok(modelLabel({ model: { providerID: "amazon-bedrock", id: "claude-opus-5" } }) === "amazon-bedrock/claude-opus-5", "model nested with id")
|
|
26
|
+
const fake = { model: null, totals: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, tools: 0, toolErrors: 0 }, msgTokens: new Map(), seenTools: new Map(), toolLog: [], contextTokens: 0, record() {}, lastText: "" }
|
|
27
|
+
ingestMessages(fake, [{ info: { id: "m1", role: "assistant", providerID: "anthropic", modelID: "claude-sonnet-4-5", tokens: { input: 10, output: 5 } }, parts: [{ id: "p1", type: "tool", tool: "botfarm_room_post", state: { status: "completed" } }] }])
|
|
28
|
+
ok(fake.totals.tools === 1, "an MCP tool call is counted")
|
|
29
|
+
ok(fake.model === "anthropic/claude-sonnet-4-5", "and the model is picked up from the message, so it can be priced")
|
|
30
|
+
ingestMessages(fake, [
|
|
31
|
+
{ info: { id: "m3", role: "user", model: { providerID: "anthropic", modelID: "claude-opus-4-5", variant: "high" } }, parts: [] },
|
|
32
|
+
{ info: { id: "m2", role: "assistant", providerID: "anthropic", modelID: "claude-opus-4-5", mode: "build", tokens: { input: 1, output: 1 } }, parts: [] },
|
|
33
|
+
])
|
|
34
|
+
ok(fake.model === "anthropic/claude-opus-4-5" && fake.variant === "high" && fake.mode === "build", "the newest model, its reasoning level and the agent are picked up")
|
|
35
|
+
const lp = livePart({ type: "message.part.updated", properties: { part: { id: "p9", messageID: "m9", sessionID: "s", type: "text", text: "Reading the" }, delta: " the" } })
|
|
36
|
+
ok(lp?.part.kind === "text" && lp.messageId === "m9" && lp.delta === " the", "a part event becomes a live frame")
|
|
37
|
+
ok(livePart({ properties: { sessionID: "s", messageID: "m", partID: "p", field: "text", delta: "x" } })?.delta === "x", "so does a bare delta")
|
|
38
|
+
ok(summarizeMessage({ info: { id: "m", role: "assistant" }, parts: [{ type: "reasoning", text: "hmm" }, { type: "tool", tool: "grep", state: { status: "running", input: { pattern: "FRID" } } }] }).parts.length === 2, "transcripts keep thinking and tool steps")
|
|
39
|
+
|
|
40
|
+
const repo = await mkdtemp(join(tmpdir(), "flow-"))
|
|
41
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
42
|
+
await writeFile(join(repo, "README.md"), "# demo\n")
|
|
43
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
44
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
45
|
+
const home = await mkdtemp(join(tmpdir(), "flowhome-"))
|
|
46
|
+
await sleep(600)
|
|
47
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home, "m.json"), registryPath: join(home, "r.json"), mcpBase: "http://127.0.0.1:4817" })
|
|
48
|
+
await sup.start()
|
|
49
|
+
startServer({ supervisor: sup, port: 4817 })
|
|
50
|
+
|
|
51
|
+
const story =
|
|
52
|
+
"DE238: DELETE rest/v1/customers/<custid>/eligibilities should error if a FRID exists. Make sure this error bubbles up to the caller with a 409 and the FRID id in the body, and that nothing is deleted."
|
|
53
|
+
const r = await sup.pipelines.start({ pipeline: "story", repo, branch: "feat/de238", story })
|
|
54
|
+
await sleep(400)
|
|
55
|
+
// What a bot was sent (the mock also writes assistant chatter for busy sessions).
|
|
56
|
+
const texts = async (id) =>
|
|
57
|
+
(await sup.client.messages(id, { limit: 200 })).filter((m) => (m.info ?? m).role !== "assistant").flatMap((m) => (m.parts ?? []).map((p) => p.text ?? ""))
|
|
58
|
+
|
|
59
|
+
console.log("\nbriefing happens when the stage starts")
|
|
60
|
+
const product = await texts(r.sessions.product)
|
|
61
|
+
ok(product.length === 1, "the first bot gets one message")
|
|
62
|
+
ok(product[0].includes("You are the product analyst") && product[0].includes("[botfarm] task"), "with its brief and its task together")
|
|
63
|
+
for (const p of ["dev", "qa", "reviewer"]) ok((await texts(r.sessions[p])).length === 0, `${p} is told nothing yet`)
|
|
64
|
+
const room = sup.rooms.rooms.get(r.roomId)
|
|
65
|
+
ok(room.topic.length <= 91 && room.topic.endsWith("…") && !room.topic.includes("bubbles u…"), `the topic is a label cut at a word ("${room.topic}")`)
|
|
66
|
+
|
|
67
|
+
console.log("\na busy bot is not interrupted by its peers")
|
|
68
|
+
const devS = sup.store.get(r.sessions.dev)
|
|
69
|
+
const prodS = sup.store.get(r.sessions.product)
|
|
70
|
+
prodS.setStatus("busy")
|
|
71
|
+
await sup.rooms.post(room, devS, `@${prodS.handle} can you post the full criteria?`)
|
|
72
|
+
ok((await texts(prodS.id)).length === 1, "a mention waits while the product bot works")
|
|
73
|
+
prodS.setStatus("idle")
|
|
74
|
+
await sup.rooms.flushAll(prodS)
|
|
75
|
+
ok((await texts(prodS.id)).some((t) => t.includes("full criteria")), "and arrives when it finishes")
|
|
76
|
+
await sup.rooms.post(room, null, "operator here")
|
|
77
|
+
ok((await texts(prodS.id)).some((t) => t.includes("operator here")), "the operator reaches a working bot at once")
|
|
78
|
+
ok(!(await texts(devS.id)).some((t) => t.includes("operator here")), "but does not wake a bot whose stage has not started")
|
|
79
|
+
await sup.rooms.post(room, null, `@${prodS.handle} only you`)
|
|
80
|
+
ok(!(await texts(sup.store.get(r.sessions.qa).id)).some((t) => t.includes("only you")), "an @mention from the operator reaches only that bot")
|
|
81
|
+
|
|
82
|
+
console.log("\nthe caller is found inside a shared worktree")
|
|
83
|
+
ok(new Set(Object.values(r.sessions).map((id) => sup.store.get(id).directory)).size === 1, "all four bots share one worktree")
|
|
84
|
+
const token = sup.mcpUrlFor(sup.store.get(r.sessions.reviewer)).split("/").pop()
|
|
85
|
+
sup.mcpCalls.clear()
|
|
86
|
+
for (const id of Object.values(r.sessions)) sup.store.get(id).setStatus("idle")
|
|
87
|
+
sup.mcpCalls.set(prodS.id, Date.now())
|
|
88
|
+
ok((await sup.callerForToken(token)).id === prodS.id, "the session with an botfarm_ tool in flight is the caller, whatever URL it used")
|
|
89
|
+
sup.mcpCalls.clear()
|
|
90
|
+
devS.setStatus("busy")
|
|
91
|
+
ok((await sup.callerForToken(token)).id === devS.id, "failing that, the only busy one")
|
|
92
|
+
devS.setStatus("idle")
|
|
93
|
+
|
|
94
|
+
console.log("\nhanding off starts the next stage with its brief")
|
|
95
|
+
const call = async (name, args) => {
|
|
96
|
+
const res = await fetch(`http://127.0.0.1:4817/mcp/${token}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) })
|
|
97
|
+
return (await res.json()).result
|
|
98
|
+
}
|
|
99
|
+
// The operator's message above woke the dev bot; let it finish first.
|
|
100
|
+
await sup.client.interrupt(devS.id)
|
|
101
|
+
devS.setStatus("idle")
|
|
102
|
+
// Deliberately ambiguous: nobody is marked as calling, so the URL owner
|
|
103
|
+
// (the reviewer) is the best guess. The task id should still win.
|
|
104
|
+
const out = await call("task_complete", { task_id: r.taskIds.analyse, summary: "Criteria written.", acceptance_criteria: ["DELETE with a FRID returns 409", "nothing is deleted"] })
|
|
105
|
+
ok(!out.isError, "the product bot's handoff is accepted even when attributed to a worktree-mate")
|
|
106
|
+
await sleep(300)
|
|
107
|
+
const dev = await texts(devS.id)
|
|
108
|
+
const task = dev.find((t) => t.includes("[botfarm] task"))
|
|
109
|
+
ok(task && task.includes("You are the engineer"), "the dev bot is briefed with its first task")
|
|
110
|
+
ok(task?.includes("DELETE with a FRID returns 409") && task.includes("bubbles up to the caller"), "and gets the criteria and the whole story")
|
|
111
|
+
ok(task?.includes("operator here") && task.includes("before your stage started"), "and what you said in the chat before it started")
|
|
112
|
+
|
|
113
|
+
console.log("\na forgotten handoff is noticed")
|
|
114
|
+
const build = sup.tasks.get(r.taskIds.build)
|
|
115
|
+
ok(build.status === "active", "the build is active")
|
|
116
|
+
const t0 = Date.now() + 60_000
|
|
117
|
+
const quiet = { ...devS.totals }
|
|
118
|
+
Object.assign(devS.totals, { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 })
|
|
119
|
+
sup.tasks.save(Object.assign(sup.tasks.get(r.taskIds.build), { tokensAtStart: 0, noRun: false }))
|
|
120
|
+
await sup.pipelines.checkForgotten(devS, { now: t0 })
|
|
121
|
+
ok(!(await texts(devS.id)).some((t) => t.includes("still open")) && sup.store.events.some((e) => /never ran/.test(e.text)), "a bot that never spent a token is reported as not running, not nagged")
|
|
122
|
+
Object.assign(devS.totals, quiet, { output: (quiet.output ?? 0) + 500 })
|
|
123
|
+
await sup.pipelines.checkForgotten(devS, { now: t0 })
|
|
124
|
+
ok((await texts(devS.id)).some((t) => t.includes("still open")), "going idle with it open gets a reminder")
|
|
125
|
+
await sup.pipelines.checkForgotten(devS, { now: t0 + 5_000 })
|
|
126
|
+
ok(!sup.tasks.forHuman().some((t) => t.askedBy === devS.id), "not escalated straight away")
|
|
127
|
+
await sup.pipelines.checkForgotten(devS, { now: t0 + 60_000 })
|
|
128
|
+
const q = sup.tasks.forHuman().find((t) => t.askedBy === devS.id)
|
|
129
|
+
ok(q && q.title.includes("stopped without finishing"), "stopping again puts it in front of you")
|
|
130
|
+
ok(sup.tasks.get(r.taskIds.build).status === "review", "and the task waits in review")
|
|
131
|
+
|
|
132
|
+
console.log("\nopencode's own errors reach the bot")
|
|
133
|
+
const logFile = join(home, "opencode.log")
|
|
134
|
+
await writeFile(logFile, "timestamp=x level=INFO message=hello\n")
|
|
135
|
+
sup.opencodeLog = logFile
|
|
136
|
+
await sup.watchOpencodeLog() // starts at the end
|
|
137
|
+
const { appendFile } = await import("node:fs/promises")
|
|
138
|
+
await appendFile(logFile, `timestamp=y level=ERROR run=1 message="Failed to drain Session" cause="SessionRunnerModel.VariantUnavailableError: Variant unavailable for amazon-bedrock/anthropic.claude-sonnet-5: medium\\n at z8 (/x.js:4:1)" sessionID=${devS.id}\n`)
|
|
139
|
+
await sup.watchOpencodeLog()
|
|
140
|
+
ok(devS.status === "error" && /Variant unavailable/.test(devS.lastError), `a turn that dies in opencode shows on the bot ("${devS.lastError}")`)
|
|
141
|
+
|
|
142
|
+
console.log("\nan open session streams over the socket")
|
|
143
|
+
const reviewer = sup.store.get(r.sessions.reviewer)
|
|
144
|
+
await sup.send(reviewer.id, "start working") // the mock streams parts for busy sessions
|
|
145
|
+
const frames = []
|
|
146
|
+
const ws = new WebSocket("ws://127.0.0.1:4817/api/socket")
|
|
147
|
+
await new Promise((res) => (ws.onopen = res))
|
|
148
|
+
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.t) frames.push(m) }
|
|
149
|
+
ws.send(`watch:${reviewer.id}`)
|
|
150
|
+
await sleep(3000)
|
|
151
|
+
const tr = frames.filter((f) => f.t === "transcript" && f.sessionId === reviewer.id)
|
|
152
|
+
ok(tr.length >= 2, `transcript frames keep coming while it works (${tr.length} in 3s)`)
|
|
153
|
+
ok(tr.at(-1)?.transcript.some((m) => m.parts?.some((p) => p.kind === "tool")), "with its tool calls in them")
|
|
154
|
+
ok(!frames.some((f) => f.sessionId && f.sessionId !== reviewer.id), "and nothing about sessions nobody is watching")
|
|
155
|
+
ws.send(`unwatch:${reviewer.id}`)
|
|
156
|
+
await sleep(300)
|
|
157
|
+
const before = frames.length
|
|
158
|
+
await sleep(2000)
|
|
159
|
+
ok(frames.length === before, "closing it stops the stream")
|
|
160
|
+
ws.close()
|
|
161
|
+
|
|
162
|
+
sup.stop()
|
|
163
|
+
mock.kill()
|
|
164
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
165
|
+
process.exit(failed ? 1 : 0)
|