@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
|
@@ -0,0 +1,2145 @@
|
|
|
1
|
+
// Personas and pipelines: definitions from YAML, runs on top of tasks.
|
|
2
|
+
//
|
|
3
|
+
// A persona is a bot template (system prompt, model, tools, mesh policy).
|
|
4
|
+
// A pipeline is an ordered list of stages, each naming a persona and — this is
|
|
5
|
+
// the part that matters — naming which earlier handoffs it receives. Dropping
|
|
6
|
+
// the QA write-up from the reviewer's prompt is an edit to one line of YAML.
|
|
7
|
+
//
|
|
8
|
+
// Running a pipeline creates: one worktree, one session per distinct persona,
|
|
9
|
+
// one task per stage chained by dependsOn, and one group chat so the bots can
|
|
10
|
+
// talk without going through the operator.
|
|
11
|
+
|
|
12
|
+
import * as git from "./git.mjs"
|
|
13
|
+
import { normalizeQuestions, questionText, formatAnswers } from "./questions.mjs"
|
|
14
|
+
import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"
|
|
15
|
+
import { join, basename } from "node:path"
|
|
16
|
+
import * as YAML from "./yaml.mjs"
|
|
17
|
+
import { render, variables } from "./template.mjs"
|
|
18
|
+
import { difficulty, routeFor, trimText, trimHandoff, localBrief, stuckReason } from "./craft.mjs"
|
|
19
|
+
import { xpForCard, budgetBonus, perksAt } from "./farmhands.mjs"
|
|
20
|
+
import { overQuota } from "./quota.mjs"
|
|
21
|
+
import { mkdir as mkdirp, writeFile as writeF } from "node:fs/promises"
|
|
22
|
+
import { buildPacket } from "./packets.mjs"
|
|
23
|
+
|
|
24
|
+
const DEFAULT_HANDOFF_PROMPT = `
|
|
25
|
+
When you have finished, call botfarm_task_complete with:
|
|
26
|
+
summary what you did, in enough detail that the next bot needs nothing else
|
|
27
|
+
acceptance_criteria the criteria you believe now hold (or the ones you set)
|
|
28
|
+
artifacts files you created or changed, as paths
|
|
29
|
+
open_questions anything you could not resolve
|
|
30
|
+
Do not mark the task complete until the work is actually done.`
|
|
31
|
+
|
|
32
|
+
export class Pipelines {
|
|
33
|
+
constructor({ dir, tasks, supervisor }) {
|
|
34
|
+
this.dir = dir // ~/.botfarm
|
|
35
|
+
this.tasks = tasks
|
|
36
|
+
this.sup = supervisor
|
|
37
|
+
this.personas = new Map()
|
|
38
|
+
this.definitions = new Map()
|
|
39
|
+
this.problems = []
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get db() {
|
|
43
|
+
return this.tasks.db
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- definitions ---------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
async load() {
|
|
49
|
+
this.personas.clear()
|
|
50
|
+
this.definitions.clear()
|
|
51
|
+
this.problems = []
|
|
52
|
+
await this.ensureDefaults()
|
|
53
|
+
for (const [sub, target, kind] of [
|
|
54
|
+
["personas", this.personas, "persona"],
|
|
55
|
+
["pipelines", this.definitions, "pipeline"],
|
|
56
|
+
]) {
|
|
57
|
+
const folder = join(this.dir, sub)
|
|
58
|
+
let files = []
|
|
59
|
+
try {
|
|
60
|
+
files = (await readdir(folder)).filter((f) => /\.ya?ml$/.test(f))
|
|
61
|
+
} catch {}
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
try {
|
|
64
|
+
const doc = YAML.parse(await readFile(join(folder, file), "utf8"))
|
|
65
|
+
const id = doc.id ?? basename(file).replace(/\.ya?ml$/, "")
|
|
66
|
+
const problems = kind === "persona" ? checkPersona(doc) : this.checkPipeline(doc)
|
|
67
|
+
if (problems.length) this.problems.push({ file, problems })
|
|
68
|
+
target.set(id, { ...doc, id, file: join(folder, file) })
|
|
69
|
+
} catch (err) {
|
|
70
|
+
this.problems.push({ file, problems: [err.message] })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { personas: this.personas.size, pipelines: this.definitions.size, problems: this.problems }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
checkPipeline(doc) {
|
|
78
|
+
return checkPipelineDoc(doc, this.personas)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Global definitions (~/.botfarm), or a workspace's own files. */
|
|
82
|
+
async lib(workspaceId) {
|
|
83
|
+
if (workspaceId && this.sup.workspaces?.get(workspaceId)) return this.sup.workspaces.library(workspaceId)
|
|
84
|
+
return { personas: this.personas, definitions: this.definitions, problems: this.problems }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The definition a run was started with, frozen at start. */
|
|
88
|
+
defFor(run) {
|
|
89
|
+
return run?.def ?? (run ? this.definitions.get(run.pipeline) : null)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
personaFor(run, name) {
|
|
93
|
+
return run?.personaDefs?.[name] ?? this.personas.get(name)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async ensureDefaults() {
|
|
97
|
+
for (const [sub, files] of Object.entries(DEFAULTS)) {
|
|
98
|
+
const folder = join(this.dir, sub)
|
|
99
|
+
await mkdir(folder, { recursive: true })
|
|
100
|
+
for (const [name, body] of Object.entries(files)) {
|
|
101
|
+
const path = join(folder, name)
|
|
102
|
+
try {
|
|
103
|
+
await readFile(path)
|
|
104
|
+
} catch {
|
|
105
|
+
await writeFile(path, body)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
list() {
|
|
112
|
+
return {
|
|
113
|
+
personas: [...this.personas.values()].map((p) => ({
|
|
114
|
+
id: p.id,
|
|
115
|
+
title: p.title ?? p.id,
|
|
116
|
+
role: p.role ?? p.id,
|
|
117
|
+
model: p.model ?? null,
|
|
118
|
+
tools: p.tools ?? null,
|
|
119
|
+
may_spawn: !!p.may_spawn,
|
|
120
|
+
file: p.file,
|
|
121
|
+
})),
|
|
122
|
+
pipelines: [...this.definitions.values()].map((p) => ({
|
|
123
|
+
id: p.id,
|
|
124
|
+
title: p.title ?? p.id,
|
|
125
|
+
description: p.description ?? "",
|
|
126
|
+
stages: (p.stages ?? []).map((s) => ({
|
|
127
|
+
id: s.id,
|
|
128
|
+
persona: s.persona,
|
|
129
|
+
receives: s.receives ?? [],
|
|
130
|
+
human: !!s.human,
|
|
131
|
+
title: s.title ?? null,
|
|
132
|
+
after: s.after ?? null,
|
|
133
|
+
split: s.split ?? null,
|
|
134
|
+
parallel: s.parallel ?? null,
|
|
135
|
+
limits: s.limits ?? null,
|
|
136
|
+
})),
|
|
137
|
+
file: p.file,
|
|
138
|
+
})),
|
|
139
|
+
problems: this.problems,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Raw definition, for editing. */
|
|
144
|
+
definition(kind, id) {
|
|
145
|
+
const map = kind === "personas" ? this.personas : this.definitions
|
|
146
|
+
const doc = map.get(id)
|
|
147
|
+
if (!doc) return null
|
|
148
|
+
const { file, ...rest } = doc
|
|
149
|
+
return { id, file, doc: rest, yaml: YAML.stringify(rest) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Write a definition back to YAML. Structured edits and hand edits are the
|
|
154
|
+
* same file, so a pipeline changed in the dashboard is still a file you can
|
|
155
|
+
* diff, commit and send to someone.
|
|
156
|
+
*/
|
|
157
|
+
async saveDefinition(kind, id, body) {
|
|
158
|
+
const folder = join(this.dir, kind)
|
|
159
|
+
await mkdir(folder, { recursive: true })
|
|
160
|
+
const doc = body.yaml ? YAML.parse(body.yaml) : body.doc
|
|
161
|
+
if (!doc || typeof doc !== "object") throw new Error("nothing to save")
|
|
162
|
+
doc.id = id
|
|
163
|
+
const problems = kind === "personas" ? checkPersona(doc) : this.checkPipeline(doc)
|
|
164
|
+
const path = join(folder, `${id}.yaml`)
|
|
165
|
+
await writeFile(path, YAML.stringify(doc) + "\n")
|
|
166
|
+
await this.load()
|
|
167
|
+
return { id, path, problems, doc }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async deleteDefinition(kind, id) {
|
|
171
|
+
const map = kind === "personas" ? this.personas : this.definitions
|
|
172
|
+
const doc = map.get(id)
|
|
173
|
+
if (doc?.file) await rm(doc.file).catch(() => {})
|
|
174
|
+
await this.load()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- runs ----------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
runs() {
|
|
180
|
+
return this.db.all("runs").sort((a, b) => b.created - a.created)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
run(id) {
|
|
184
|
+
return this.db.get("runs", id)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Start a pipeline: worktree, one session per persona, one task per stage.
|
|
189
|
+
* Only the first stage is queued; the rest are blocked on their predecessor
|
|
190
|
+
* until a handoff lands.
|
|
191
|
+
*/
|
|
192
|
+
async start({ pipeline, repo, branch, story, title, group, project, services, workspace = null, limits = null, parallel = null }) {
|
|
193
|
+
const ws = workspace ? this.sup.workspaces?.get(workspace) : null
|
|
194
|
+
if (workspace && !ws) throw new Error(`no workspace "${workspace}"`)
|
|
195
|
+
const lib = await this.lib(ws?.id)
|
|
196
|
+
const def = lib.definitions.get(pipeline)
|
|
197
|
+
if (!def) throw new Error(`no pipeline called "${pipeline}"`)
|
|
198
|
+
if (!story?.trim()) throw new Error("a pipeline needs a story or task to work on")
|
|
199
|
+
repo ??= ws?.path
|
|
200
|
+
services = ws ? this.sup.workspaces.serviceEntries(ws, services ?? ws.services ?? []) : services ?? []
|
|
201
|
+
const label = title?.trim() || topicFrom(story).replace(/…$/, "")
|
|
202
|
+
|
|
203
|
+
// Every run is its own workstream — its own branch, team, chat and board
|
|
204
|
+
// — unless you name an existing one to add to. Two DE-something stories
|
|
205
|
+
// through the same pipeline are two workstreams, not one crowded board.
|
|
206
|
+
let target = project || group ? this.sup.projects.find(project ?? group) : null
|
|
207
|
+
if (!target) {
|
|
208
|
+
if (ws && !branch) branch = `botfarm/${slugify(label)}`
|
|
209
|
+
const clash = branch && this.sup.projects.list().find((p) => p.repo === repo && p.branch === branch && p.status !== "archived")
|
|
210
|
+
if (clash) throw new Error(`workstream "${clash.name}" is already on branch ${branch} — pick another branch`)
|
|
211
|
+
target = this.sup.projects.create({ name: label, repo, branch, services, unique: true })
|
|
212
|
+
}
|
|
213
|
+
const projectId = target.id
|
|
214
|
+
const runId = "run_" + Date.now().toString(36)
|
|
215
|
+
const stages = def.stages ?? []
|
|
216
|
+
// Human stages have no bot behind them, so they need no session.
|
|
217
|
+
const personas = [...new Set(stages.filter((s) => !s.human && s.persona).map((s) => s.persona))]
|
|
218
|
+
|
|
219
|
+
const sessions = {}
|
|
220
|
+
const briefs = {}
|
|
221
|
+
const personaDefs = {}
|
|
222
|
+
// Every bot's opencode agent goes into the worktree config before the
|
|
223
|
+
// first session opens it, so all of them load together.
|
|
224
|
+
const team = Object.fromEntries(personas.map((n) => [n, teamEntry(lib.personas.get(n), n)]))
|
|
225
|
+
for (const name of personas) {
|
|
226
|
+
const persona = lib.personas.get(name)
|
|
227
|
+
if (!persona) throw new Error(`pipeline "${pipeline}" wants agent "${name}", which is not defined`)
|
|
228
|
+
personaDefs[name] = { ...persona, file: undefined }
|
|
229
|
+
const s = await this.sup.spawn({
|
|
230
|
+
repo,
|
|
231
|
+
services, // nested service repos get a worktree each, in the same layout
|
|
232
|
+
branch, // every persona shares one worktree: they are working on the same change
|
|
233
|
+
title: `${persona.title ?? name} · ${title ?? story.slice(0, 40)}`,
|
|
234
|
+
label: `${name}`,
|
|
235
|
+
group: projectId,
|
|
236
|
+
agent: persona.agent ?? null,
|
|
237
|
+
model: persona.model ?? null,
|
|
238
|
+
variant: persona.variant ?? null,
|
|
239
|
+
persona: name,
|
|
240
|
+
personaTitle: persona.title ?? name,
|
|
241
|
+
team,
|
|
242
|
+
role: persona.role ?? name,
|
|
243
|
+
adjectives: persona.adjectives ?? null,
|
|
244
|
+
tools: persona.tools ?? null,
|
|
245
|
+
policy: {
|
|
246
|
+
talk: persona.talk ?? "open",
|
|
247
|
+
rooms: persona.rooms ?? "member",
|
|
248
|
+
spawn: !!persona.may_spawn,
|
|
249
|
+
...(persona.policy ?? {}),
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
sessions[name] = s.id
|
|
253
|
+
// The brief is held until this bot's first stage is dispatched, and then
|
|
254
|
+
// sent together with the task. Briefing every bot up front left the
|
|
255
|
+
// later stages awake with a persona and nothing to do, so they went
|
|
256
|
+
// looking for work: reading a truncated room topic and asking the
|
|
257
|
+
// product bot for criteria it had not written yet.
|
|
258
|
+
const brief = [persona.prompt, def.context, stageContextFor(def, name)].filter(Boolean).join("\n\n")
|
|
259
|
+
if (brief.trim()) briefs[name] = render(brief, { story, run: runId, project: projectId })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The project's chat is the run's chat; there is only one conversation.
|
|
263
|
+
const room = this.sup.projects.room(projectId)
|
|
264
|
+
for (const id of Object.values(sessions)) {
|
|
265
|
+
const s = this.sup.store.get(id)
|
|
266
|
+
if (s) this.sup.rooms.join(room, s, { silent: true })
|
|
267
|
+
}
|
|
268
|
+
// The topic is a label, not the spec: every stage gets the whole story in
|
|
269
|
+
// its task. A topic cut mid-sentence reads like truncated requirements.
|
|
270
|
+
room.topic = title ?? topicFrom(story)
|
|
271
|
+
|
|
272
|
+
const taskIds = {}
|
|
273
|
+
let previous = null
|
|
274
|
+
for (const [i, stage] of stages.entries()) {
|
|
275
|
+
// A stage waits for the one before it, unless it says what it waits for
|
|
276
|
+
// ("after: [analyse]") — which is how dev and QA start together for TDD.
|
|
277
|
+
const deps = Array.isArray(stage.after)
|
|
278
|
+
? stage.after.map((id) => taskIds[id]).filter(Boolean)
|
|
279
|
+
: previous ? [previous] : []
|
|
280
|
+
const task = this.tasks.create({
|
|
281
|
+
title: stage.title ?? `${stage.id} — ${title ?? story.slice(0, 40)}`,
|
|
282
|
+
brief: story,
|
|
283
|
+
runId,
|
|
284
|
+
projectId,
|
|
285
|
+
stage: stage.id,
|
|
286
|
+
persona: stage.persona,
|
|
287
|
+
// Addressed to the part, and to the session currently playing it.
|
|
288
|
+
role: lib.personas.get(stage.persona)?.role ?? stage.persona,
|
|
289
|
+
assignee: stage.human ? null : sessions[stage.persona],
|
|
290
|
+
owner: stage.human ? "human" : "session",
|
|
291
|
+
kind: stage.human ? "question" : "work",
|
|
292
|
+
dependsOn: deps,
|
|
293
|
+
createdBy: "pipeline",
|
|
294
|
+
order: i,
|
|
295
|
+
})
|
|
296
|
+
if (stage.split || stage.limits) {
|
|
297
|
+
// A split stage becomes one card per item of an earlier handoff's list,
|
|
298
|
+
// worked by up to `parallel` bots of its kind at once; the stages after
|
|
299
|
+
// it wait for every piece (see expandSplits and joinParent).
|
|
300
|
+
if (stage.split) task.split = { from: stage.split === true ? "tasks" : String(stage.split), parallel: clampParallel(parallel?.[stage.id] ?? stage.parallel ?? 3) }
|
|
301
|
+
if (stage.limits) task.limits = cleanLimits(stage.limits)
|
|
302
|
+
this.tasks.save(task)
|
|
303
|
+
}
|
|
304
|
+
taskIds[stage.id] = task.id
|
|
305
|
+
previous = task.id
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const run = {
|
|
309
|
+
id: runId,
|
|
310
|
+
pipeline: def.id,
|
|
311
|
+
title: title ?? story.slice(0, 80),
|
|
312
|
+
story,
|
|
313
|
+
repo,
|
|
314
|
+
branch,
|
|
315
|
+
projectId,
|
|
316
|
+
roomId: room.id,
|
|
317
|
+
sessions,
|
|
318
|
+
taskIds,
|
|
319
|
+
briefs,
|
|
320
|
+
briefed: [],
|
|
321
|
+
workspaceId: ws?.id ?? null,
|
|
322
|
+
def: { ...def, file: undefined },
|
|
323
|
+
personaDefs,
|
|
324
|
+
limits: cleanLimits(limits ?? def.limits),
|
|
325
|
+
paused: null,
|
|
326
|
+
pausedMs: 0,
|
|
327
|
+
status: "running",
|
|
328
|
+
created: Date.now(),
|
|
329
|
+
}
|
|
330
|
+
this.db.put("runs", run)
|
|
331
|
+
// Re-read: creating the chat above saved a newer copy of the project.
|
|
332
|
+
target = this.sup.projects.get(projectId) ?? target
|
|
333
|
+
Object.assign(target, {
|
|
334
|
+
workspaceId: ws?.id ?? target.workspaceId ?? null,
|
|
335
|
+
pipeline: def.id,
|
|
336
|
+
pipelineTitle: def.title ?? def.id,
|
|
337
|
+
runId,
|
|
338
|
+
story,
|
|
339
|
+
status: "running",
|
|
340
|
+
repo: target.repo ?? repo,
|
|
341
|
+
branch: target.branch ?? branch,
|
|
342
|
+
worktree: this.sup.store.get(Object.values(sessions)[0])?.directory ?? target.worktree ?? null,
|
|
343
|
+
})
|
|
344
|
+
this.sup.projects.save(target)
|
|
345
|
+
this.sup.store.note(`pipeline ${def.id} started: ${stages.length} stages, ${personas.length} bots`, null, "info")
|
|
346
|
+
|
|
347
|
+
// The first stage goes out now. Nobody else has been told anything yet,
|
|
348
|
+
// so the later bots sit idle until their own stage is handed to them.
|
|
349
|
+
// Every stage with nothing to wait for starts now (usually just the first;
|
|
350
|
+
// with "after:" several can). Nobody else has been told anything yet.
|
|
351
|
+
const openers = stages.filter((st) => !st.human && !this.tasks.get(taskIds[st.id])?.dependsOn?.length)
|
|
352
|
+
await this.dispatch({ force: true, sessions: [...new Set(openers.map((st) => sessions[st.persona]))].map((id) => this.sup.store.get(id)).filter(Boolean) })
|
|
353
|
+
await this.sup.saveManifest(projectId)
|
|
354
|
+
return run
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Run a workstream again from one stage. Everything before it keeps its
|
|
359
|
+
* handoff; that stage and everything after go back to waiting their turn.
|
|
360
|
+
* With `fresh`, every bot is replaced by a new opencode session in the same
|
|
361
|
+
* worktree — which is how a changed opencode.json, agent definition or MCP
|
|
362
|
+
* setup reaches them — and the old sessions leave the board (their
|
|
363
|
+
* transcripts stay in opencode).
|
|
364
|
+
*/
|
|
365
|
+
async restart(projectId, { stage = null, fresh = true } = {}) {
|
|
366
|
+
const project = this.sup.projects.get(projectId)
|
|
367
|
+
const run = project?.runId ? this.run(project.runId) : null
|
|
368
|
+
if (!run) throw new Error("this workstream was not started from a pipeline")
|
|
369
|
+
const def = this.defFor(run)
|
|
370
|
+
const stages = def?.stages ?? []
|
|
371
|
+
const tasks = stages.map((s) => this.tasks.get(run.taskIds[s.id]))
|
|
372
|
+
const from = stage
|
|
373
|
+
? stages.findIndex((s) => s.id === stage)
|
|
374
|
+
: Math.max(0, tasks.findIndex((t) => t && t.status !== "done"))
|
|
375
|
+
if (from < 0) throw new Error(`no stage "${stage}" in this pipeline`)
|
|
376
|
+
|
|
377
|
+
// Stop whoever is mid-turn so nothing lands on a card we are resetting.
|
|
378
|
+
const old = Object.entries(run.sessions).map(([persona, id]) => [persona, this.sup.store.get(id)]).filter(([, s]) => s)
|
|
379
|
+
for (const [, s] of old) if (s.status === "busy") await this.sup.abort(s.id).catch(() => {})
|
|
380
|
+
|
|
381
|
+
const swap = new Map()
|
|
382
|
+
if (fresh) {
|
|
383
|
+
// Bring the worktree's config up to date, then have opencode forget
|
|
384
|
+
// the location so the new sessions actually read it.
|
|
385
|
+
const dir = old[0]?.[1]?.directory
|
|
386
|
+
if (dir) {
|
|
387
|
+
await git.carryConfig(run.repo, dir).catch(() => {})
|
|
388
|
+
const reloaded = await this.sup.client.disposeInstance(dir)
|
|
389
|
+
if (!reloaded) this.sup.store.note("opencode would not reload this worktree's config; if MCP changes are missing, restart opencode serve", null, "warn")
|
|
390
|
+
}
|
|
391
|
+
const room = this.sup.rooms.rooms.get(run.roomId)
|
|
392
|
+
for (const [key, s] of old) {
|
|
393
|
+
const name = s.persona ?? key
|
|
394
|
+
const persona = run.personaDefs?.[name] ?? (await this.lib(run.workspaceId)).personas.get(name)
|
|
395
|
+
if (!persona) continue
|
|
396
|
+
const next = await this.sup.spawn({
|
|
397
|
+
repo: run.repo,
|
|
398
|
+
services: project.services ?? [],
|
|
399
|
+
branch: run.branch,
|
|
400
|
+
title: `${persona.title ?? name} · ${project.name}`,
|
|
401
|
+
label: name,
|
|
402
|
+
group: projectId,
|
|
403
|
+
agent: persona.agent ?? null,
|
|
404
|
+
model: persona.model ?? null,
|
|
405
|
+
variant: persona.variant ?? null,
|
|
406
|
+
persona: name,
|
|
407
|
+
personaTitle: persona.title ?? name,
|
|
408
|
+
team: Object.fromEntries(old.map(([k, x]) => [x.persona ?? k, teamEntry(run.personaDefs?.[x.persona ?? k], x.persona ?? k)]).filter(([, v]) => v)),
|
|
409
|
+
role: persona.role ?? name,
|
|
410
|
+
adjectives: persona.adjectives ?? null,
|
|
411
|
+
tools: persona.tools ?? null,
|
|
412
|
+
policy: { talk: persona.talk ?? "open", rooms: persona.rooms ?? "member", spawn: !!persona.may_spawn, ...(persona.policy ?? {}) },
|
|
413
|
+
})
|
|
414
|
+
swap.set(s.id, next.id)
|
|
415
|
+
run.sessions[key] = next.id
|
|
416
|
+
if (room && !room.members.has(next.id)) this.sup.rooms.join(room, this.sup.store.get(next.id), { silent: true })
|
|
417
|
+
}
|
|
418
|
+
run.briefed = []
|
|
419
|
+
for (const t of this.tasks.all({ projectId })) {
|
|
420
|
+
if (swap.has(t.assignee)) { t.assignee = swap.get(t.assignee); this.tasks.save(t) }
|
|
421
|
+
}
|
|
422
|
+
// What the old bots spent still counts against the workstream's budget.
|
|
423
|
+
run.retired ??= { tokens: 0, usd: 0 }
|
|
424
|
+
for (const [oldId] of swap) {
|
|
425
|
+
const o = this.sup.store.get(oldId)
|
|
426
|
+
if (o) { run.retired.tokens += tokensOf(o); run.retired.usd += this.costOf(o) }
|
|
427
|
+
}
|
|
428
|
+
for (const [oldId] of swap) await this.sup.release(oldId, { disableMesh: false }).catch(() => {})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const [i, st] of stages.entries()) {
|
|
432
|
+
const t = this.tasks.get(run.taskIds[st.id]) // re-read: the swap above reassigned them
|
|
433
|
+
if (!t || i < from) continue
|
|
434
|
+
// A split card goes back to being one card; its pieces are made again
|
|
435
|
+
// from the (possibly new) handoff when it comes round.
|
|
436
|
+
if (t.children) {
|
|
437
|
+
for (const id of t.children) this.tasks.remove(id)
|
|
438
|
+
delete t.children
|
|
439
|
+
t.owner = "session"
|
|
440
|
+
t.assignee = run.sessions[t.persona] ?? null
|
|
441
|
+
}
|
|
442
|
+
t.budget = null
|
|
443
|
+
t.pausedAt = null
|
|
444
|
+
if (t.baseDeps) t.dependsOn = t.baseDeps
|
|
445
|
+
for (const k of ["baseDeps", "rework", "recheck", "rounds", "round", "history", "lastSendBack", "extraRounds", "prefer", "preferUntil", "pendingSendBack"]) delete t[k]
|
|
446
|
+
Object.assign(t, {
|
|
447
|
+
status: i === from ? (t.owner === "human" ? "waiting" : "queued") : "blocked",
|
|
448
|
+
queuedAt: i === from ? Date.now() : null,
|
|
449
|
+
handoff: null, answer: null, answers: null, files: [],
|
|
450
|
+
startedAt: null, nudgedAt: null, escalated: false,
|
|
451
|
+
})
|
|
452
|
+
this.tasks.save(t)
|
|
453
|
+
}
|
|
454
|
+
run.status = "running"
|
|
455
|
+
run.paused = null
|
|
456
|
+
this.db.put("runs", run)
|
|
457
|
+
this.sup.projects.save({ ...this.sup.projects.get(projectId), status: "running" })
|
|
458
|
+
const room = this.sup.rooms.rooms.get(run.roomId)
|
|
459
|
+
if (room) await this.sup.rooms.post(room, null, `Restarting from ${stages[from].id}${fresh ? " with fresh bots" : ""}. Earlier handoffs are kept.`).catch(() => {})
|
|
460
|
+
this.sup.store.note(`${project.name}: restarted from ${stages[from].id}${fresh ? ` with ${swap.size} fresh bot(s)` : ""}`)
|
|
461
|
+
this.tasks.unblock()
|
|
462
|
+
await this.dispatch({ force: true, sessions: this.sup.projects.sessions(projectId) })
|
|
463
|
+
await this.sup.saveManifest(projectId)
|
|
464
|
+
return { from: stages[from].id, fresh: swap.size }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Bring one more agent into a workstream's team, in the same worktree. */
|
|
468
|
+
async addBot(projectId, agentId) {
|
|
469
|
+
const project = this.sup.projects.get(projectId)
|
|
470
|
+
if (!project) throw new Error("unknown workstream")
|
|
471
|
+
const lib = await this.lib(project.workspaceId ?? this.sup.workspaces?.forPath(project.repo)?.id)
|
|
472
|
+
const persona = lib.personas.get(agentId)
|
|
473
|
+
if (!persona) throw new Error(`no agent called "${agentId}"`)
|
|
474
|
+
const s = await this.sup.spawn({
|
|
475
|
+
repo: project.repo,
|
|
476
|
+
services: project.services ?? [],
|
|
477
|
+
branch: project.branch,
|
|
478
|
+
title: `${persona.title ?? agentId} · ${project.name}`,
|
|
479
|
+
label: agentId,
|
|
480
|
+
group: projectId,
|
|
481
|
+
agent: persona.agent ?? null,
|
|
482
|
+
model: persona.model ?? null,
|
|
483
|
+
variant: persona.variant ?? null,
|
|
484
|
+
persona: agentId,
|
|
485
|
+
personaTitle: persona.title ?? agentId,
|
|
486
|
+
role: persona.role ?? agentId,
|
|
487
|
+
adjectives: persona.adjectives ?? null,
|
|
488
|
+
tools: persona.tools ?? null,
|
|
489
|
+
policy: { talk: persona.talk ?? "open", rooms: persona.rooms ?? "member", spawn: !!persona.may_spawn, ...(persona.policy ?? {}) },
|
|
490
|
+
})
|
|
491
|
+
const run = project.runId ? this.run(project.runId) : null
|
|
492
|
+
if (run) {
|
|
493
|
+
run.sessions[`${agentId}${run.sessions[agentId] ? "-" + s.handle : ""}`] = s.id
|
|
494
|
+
run.briefs ??= {}
|
|
495
|
+
run.briefs[agentId] ??= render(persona.prompt ?? "", { story: run.story, run: run.id, project: projectId })
|
|
496
|
+
run.personaDefs ??= {}
|
|
497
|
+
run.personaDefs[agentId] ??= { ...persona, file: undefined }
|
|
498
|
+
this.db.put("runs", run)
|
|
499
|
+
}
|
|
500
|
+
const room = this.sup.projects.room(projectId)
|
|
501
|
+
if (room && !room.members.has(s.id)) this.sup.rooms.join(room, this.sup.store.get(s.id), { silent: true })
|
|
502
|
+
await this.sup.saveManifest(projectId)
|
|
503
|
+
return s
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- handoffs ------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* An agent finishing a stage. The handoff is structured on purpose: prose
|
|
510
|
+
* alone gives the next bot nothing to template against, and "what did you
|
|
511
|
+
* actually change" is the question every downstream stage asks first.
|
|
512
|
+
*/
|
|
513
|
+
async complete(task, { summary, acceptance_criteria = [], artifacts = [], open_questions = [], tasks = [] }) {
|
|
514
|
+
if (!summary?.trim()) throw new Error("a handoff needs a summary — the next bot cannot see your conversation")
|
|
515
|
+
// Questions can come as plain strings or with choices; keep both the
|
|
516
|
+
// structured form (for the operator's answer form) and plain lines.
|
|
517
|
+
const questions = normalizeQuestions(open_questions)
|
|
518
|
+
open_questions = questionText(questions)
|
|
519
|
+
task.handoff = {
|
|
520
|
+
summary: summary.trim(),
|
|
521
|
+
acceptance_criteria,
|
|
522
|
+
artifacts,
|
|
523
|
+
open_questions,
|
|
524
|
+
questions,
|
|
525
|
+
tasks: normalizeItems(tasks),
|
|
526
|
+
at: Date.now(),
|
|
527
|
+
by: task.persona,
|
|
528
|
+
}
|
|
529
|
+
// The next bot reads a brief, not the whole write-up: squeezed by rules,
|
|
530
|
+
// or by a local model when one is configured. The full text stays here.
|
|
531
|
+
const hcfg = this.sup.config?.handoffs ?? {}
|
|
532
|
+
const maxChars = Number(hcfg.maxChars) > 200 ? Number(hcfg.maxChars) : 1800
|
|
533
|
+
task.handoff.brief = hcfg.local?.url && hcfg.local?.model
|
|
534
|
+
? await localBrief(task.handoff.summary, { ...hcfg.local, maxChars })
|
|
535
|
+
: trimText(task.handoff.summary, maxChars)
|
|
536
|
+
const worker = this.sup.store.get(task.assignee)
|
|
537
|
+
if (worker?.route?.taskId === task.id) worker.route = null
|
|
538
|
+
this.awardXp(task, worker)
|
|
539
|
+
const reworked = task.rework ? task.round : null
|
|
540
|
+
task.rework = null
|
|
541
|
+
task.recheck = null
|
|
542
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
543
|
+
// Open questions travel with the handoff to the next stage, which can
|
|
544
|
+
// settle them or ask. Stopping the whole line for them is opt-in per
|
|
545
|
+
// stage ("hold_on_questions: true"): a product bot always has some.
|
|
546
|
+
const stage = this.defFor(run)?.stages?.find((s) => s.id === task.stage)
|
|
547
|
+
// One piece of a split stage never holds the line on its own: its questions
|
|
548
|
+
// travel with the joined handoff.
|
|
549
|
+
const hold = !task.parentId && open_questions.length && (stage ? !!stage.hold_on_questions : !task.runId)
|
|
550
|
+
task.status = hold ? "review" : "done"
|
|
551
|
+
task.files = artifacts
|
|
552
|
+
this.tasks.save(task)
|
|
553
|
+
|
|
554
|
+
// The card's report: time, tokens, cost, who, the files it changed (with
|
|
555
|
+
// their diffs as they stood when it finished) and any screenshots.
|
|
556
|
+
const report = await this.cardReport(task).catch((e) => { this.sup.store.note(`card report: ${e.message}`, null, "warn"); return null })
|
|
557
|
+
if (run && this.sup.rooms.rooms.get(run.roomId)) {
|
|
558
|
+
const room = this.sup.rooms.rooms.get(run.roomId)
|
|
559
|
+
const author = this.sup.store.get(task.assignee)
|
|
560
|
+
await this.sup.rooms
|
|
561
|
+
.post(room, author, `Finished ${task.stage}${reworked ? ` (round ${reworked})` : ""}${task.item ? ` (${task.item.index}/${task.item.total}: ${task.item.title})` : ""}: ${summary.slice(0, 600)}${open_questions.length ? `\n\nOpen questions: ${open_questions.join("; ")}` : ""}`, { quiet: true, meta: report ? this.cardMeta(task, report) : null })
|
|
562
|
+
.catch(() => {})
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (task.parentId) await this.joinParent(task.parentId)
|
|
566
|
+
this.tasks.unblock()
|
|
567
|
+
await this.dispatch()
|
|
568
|
+
this.checkRunDone(task.runId)
|
|
569
|
+
return task
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
checkRunDone(runId) {
|
|
573
|
+
const run = runId ? this.run(runId) : null
|
|
574
|
+
if (!run || run.status === "done") return
|
|
575
|
+
const all = Object.values(run.taskIds).map((id) => this.tasks.get(id)).filter(Boolean)
|
|
576
|
+
if (!all.every((t) => t.status === "done")) return
|
|
577
|
+
run.status = "done"
|
|
578
|
+
run.finishedAt = Date.now()
|
|
579
|
+
this.db.put("runs", run)
|
|
580
|
+
const ws = this.sup.projects.get(run.projectId)
|
|
581
|
+
if (ws && ws.status === "running") this.sup.projects.save({ ...ws, status: "done" })
|
|
582
|
+
this.sup.saveManifest(run.projectId).catch(() => {})
|
|
583
|
+
this.sup.store.note(`pipeline ${run.pipeline} finished`, null, "info")
|
|
584
|
+
this.harvest(run).catch((e) => this.sup.store.note(`harvest: ${e.message}`, null, "warn"))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The operator lets a held stage go on, optionally answering its open
|
|
589
|
+
* questions. The answers ride along to every stage that receives it.
|
|
590
|
+
*/
|
|
591
|
+
async release(task, note = "", answers = null) {
|
|
592
|
+
if (answers && task.handoff?.questions?.length) {
|
|
593
|
+
const said = formatAnswers(task.handoff.questions, answers)
|
|
594
|
+
note = [said, note?.trim()].filter(Boolean).join("\n\n")
|
|
595
|
+
task.handoff.answers = answers
|
|
596
|
+
}
|
|
597
|
+
if (note?.trim()) {
|
|
598
|
+
task.handoff ??= { summary: "", acceptance_criteria: [], artifacts: [], open_questions: [], at: Date.now(), by: task.persona }
|
|
599
|
+
task.handoff.operator_notes = note.trim()
|
|
600
|
+
}
|
|
601
|
+
task.status = "done"
|
|
602
|
+
this.tasks.save(task)
|
|
603
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
604
|
+
const room = run ? this.sup.rooms.rooms.get(run.roomId) : null
|
|
605
|
+
if (room && note?.trim()) await this.sup.rooms.post(room, null, `On ${task.stage ?? task.title}: ${note.trim()}`).catch(() => {})
|
|
606
|
+
if (task.parentId) await this.joinParent(task.parentId)
|
|
607
|
+
this.tasks.unblock()
|
|
608
|
+
await this.dispatch()
|
|
609
|
+
this.checkRunDone(task.runId)
|
|
610
|
+
return task
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Context handed to a stage: the story, the AC so far, and chosen handoffs. */
|
|
614
|
+
contextFor(task) {
|
|
615
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
616
|
+
const def = this.defFor(run)
|
|
617
|
+
const stage = def?.stages?.find((s) => s.id === task.stage)
|
|
618
|
+
const handoffs = {}
|
|
619
|
+
let acceptance = []
|
|
620
|
+
for (const id of stage?.receives ?? []) {
|
|
621
|
+
const t = this.tasks.get(run?.taskIds?.[id])
|
|
622
|
+
if (t?.handoff) {
|
|
623
|
+
handoffs[id] = { ...trimHandoff(t.handoff, { maxChars: Number(this.sup.config?.handoffs?.maxChars) > 200 ? Number(this.sup.config.handoffs.maxChars) : 1800 }), stage: id, persona: t.persona }
|
|
624
|
+
if (t.handoff.acceptance_criteria?.length) acceptance = t.handoff.acceptance_criteria
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
story: task.brief,
|
|
629
|
+
title: task.title,
|
|
630
|
+
task: { id: task.id, title: task.title, stage: task.stage },
|
|
631
|
+
acceptance_criteria: acceptance.length ? acceptance : task.acceptance ?? [],
|
|
632
|
+
handoffs,
|
|
633
|
+
received: Object.values(handoffs),
|
|
634
|
+
run: run ? { id: run.id, pipeline: run.pipeline, branch: run.branch, repo: run.repo } : null,
|
|
635
|
+
peers: run ? Object.entries(run.sessions).map(([persona, id]) => ({ persona, handle: this.sup.store.get(id)?.handle })) : [],
|
|
636
|
+
room: run ? this.sup.rooms.rooms.get(run.roomId)?.name : null,
|
|
637
|
+
item: task.item ?? null,
|
|
638
|
+
siblings: task.parentId ? (this.tasks.get(task.parentId)?.children ?? []).filter((id) => id !== task.id).map((id) => this.tasks.get(id)).filter(Boolean).map((t) => ({ title: t.title, handle: this.sup.store.get(t.assignee)?.handle ?? null })) : [],
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
promptFor(task) {
|
|
643
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
644
|
+
const def = this.defFor(run)
|
|
645
|
+
const stage = def?.stages?.find((s) => s.id === task.stage)
|
|
646
|
+
const ctx = this.contextFor(task)
|
|
647
|
+
let body = stage?.prompt
|
|
648
|
+
? render(stage.prompt, ctx)
|
|
649
|
+
: render(GENERIC_TASK_PROMPT, ctx)
|
|
650
|
+
// Every stage sees the whole story. A stage prompt that leaves it out is
|
|
651
|
+
// usually an oversight, and without it the bot goes asking for it in chat.
|
|
652
|
+
if (stage?.prompt && !variables(stage.prompt).includes("story") && ctx.story) {
|
|
653
|
+
body = `Story:\n\n${ctx.story}\n\n${body}`
|
|
654
|
+
}
|
|
655
|
+
// Questions an earlier stage left open, and anything the operator said
|
|
656
|
+
// about them, whether or not the stage prompt mentions them.
|
|
657
|
+
const open = ctx.received.filter((h) => h.open_questions?.length || h.operator_notes)
|
|
658
|
+
if (open.length) {
|
|
659
|
+
body += "\n\n" + open.map((h) => [
|
|
660
|
+
h.open_questions?.length ? `@${h.persona} (${h.stage}) left these open:\n${h.open_questions.map((q) => ` - ${q}`).join("\n")}` : "",
|
|
661
|
+
h.operator_notes ? `The operator's answer: ${h.operator_notes}` : h.open_questions?.length ? "Settle them from the code and the story where you can; ask in the chat or with botfarm_ask_human if one blocks you." : "",
|
|
662
|
+
].filter(Boolean).join("\n")).join("\n\n")
|
|
663
|
+
}
|
|
664
|
+
if (task.item && !variables(stage?.prompt ?? "").includes("item")) {
|
|
665
|
+
body += `\n\nYour part — ${task.item.index} of ${task.item.total}: ${task.item.title}` + (task.item.detail ? `\n${task.item.detail}` : "")
|
|
666
|
+
}
|
|
667
|
+
if (task.item) {
|
|
668
|
+
body += "\n\n" + [
|
|
669
|
+
`This stage was split into ${task.item.total} pieces, worked at the same time by several bots in the same worktree.`,
|
|
670
|
+
ctx.siblings.length ? `The others: ${ctx.siblings.map((x) => `"${x.title}"${x.handle ? ` (@${x.handle})` : ""}`).join(", ")}.` : "",
|
|
671
|
+
"Do only your piece. Touch only the files it needs, never revert or reformat someone else's changes,",
|
|
672
|
+
"and if you must change a file another piece owns, say so in the chat first. Do not commit; the next stage sees the whole worktree.",
|
|
673
|
+
].filter(Boolean).join("\n")
|
|
674
|
+
}
|
|
675
|
+
if (task.rework) {
|
|
676
|
+
const r = task.rework
|
|
677
|
+
const last = task.history?.at(-1)
|
|
678
|
+
body = [
|
|
679
|
+
r.early
|
|
680
|
+
? `⚠ @${r.by} already checked this and found a problem — fix it as part of this card:`
|
|
681
|
+
: `↩ Round ${r.round}: @${r.by}${r.fromStage ? ` (${r.fromStage})` : ""} sent this card back. Fix what they found — only that, and anything it breaks:`,
|
|
682
|
+
this.reworkText(r),
|
|
683
|
+
last?.summary ? `\nWhat was handed off last time${last.by ? ` (by @${last.by})` : ""}:\n${trimText(last.summary, 900)}` : "",
|
|
684
|
+
r.early ? "" : "\nThe worktree already has the earlier work in it; change what is needed rather than starting over. Run the checks that failed yourself if you can. Then call botfarm_task_complete again, with a summary of what you changed this round and why it fixes each failure.",
|
|
685
|
+
"\n--- the original card ---\n",
|
|
686
|
+
].filter(Boolean).join("\n") + "\n" + body
|
|
687
|
+
}
|
|
688
|
+
if (task.recheck) {
|
|
689
|
+
const rc = task.recheck
|
|
690
|
+
const fixed = rc.cards.map((id) => this.tasks.get(id)).filter((t) => t?.handoff)
|
|
691
|
+
body += `\n\n↻ Fix ${rc.round}: you sent ${rc.to} back (${rc.reason ? trimText(rc.reason, 200) : "see your last message"}). The fix is in:\n` +
|
|
692
|
+
(fixed.length ? fixed.map((t) => `@${this.sup.store.get(t.assignee)?.handle ?? t.persona}${t.item ? ` (piece ${t.item.index})` : ""}: ${trimText(t.handoff.summary, 700)}`).join("\n\n") : "(no handoff text)") +
|
|
693
|
+
`\n\nVerify it again from scratch — run the tests and builds, do not take the summary's word for it.`
|
|
694
|
+
}
|
|
695
|
+
const targets = this.sendBackTargets(task)
|
|
696
|
+
if (targets.length) {
|
|
697
|
+
const used = Object.values(task.rounds ?? {}).reduce((a, b) => Math.max(a, b), 0)
|
|
698
|
+
body += `\n\nIf what you received does not hold up — a failing test, a broken build, a missed criterion — do not fix it yourself: call botfarm_send_back with the stage and exactly what failed. ` +
|
|
699
|
+
`The bot that did it fixes it, and this card comes back to you to verify. You can send back to: ${targets.map((t) => `${t.stage} (${t.by.length ? t.by.map((h) => "@" + h).join(", ") : t.persona}${t.pieces ? `, ${t.pieces} pieces — name the pieces or files` : ""})`).join(", ")}. ` +
|
|
700
|
+
`Rounds so far: ${used} of ${this.maxRounds(task)}. When it passes, complete your card as usual.`
|
|
701
|
+
}
|
|
702
|
+
const project = task.projectId ? this.sup.projects.get(task.projectId) : null
|
|
703
|
+
const repos = project?.services?.length ? this.sup.reposText(project) : ""
|
|
704
|
+
if (repos) body += `\n\nRepos in this worktree (each on the workstream's branch; commit in the repo you changed):\n${repos}`
|
|
705
|
+
const shared = this.sharedContext(task)
|
|
706
|
+
if (shared) body += "\n\n" + shared
|
|
707
|
+
let handoff = render(stage?.handoff_prompt ?? DEFAULT_HANDOFF_PROMPT, ctx)
|
|
708
|
+
const feeds = this.splitFedBy(def, stage)
|
|
709
|
+
if (feeds && !task.item) {
|
|
710
|
+
handoff += `\n tasks REQUIRED: the work for the next stage (${feeds.id}) split into independent pieces,` +
|
|
711
|
+
`\n as [{ "title": "...", "detail": "..." }]. Each becomes its own card for a ${feeds.persona} bot;` +
|
|
712
|
+
`\n up to ${feeds.parallel ?? 3} run at the same time in the same worktree, so make them touch different` +
|
|
713
|
+
`\n files or areas. Usually 2–10 pieces; one piece if it cannot be split. Add` +
|
|
714
|
+
`\n "difficulty": "easy" | "normal" | "hard" to a piece when it is obvious — easy ones run on a cheaper model.`
|
|
715
|
+
}
|
|
716
|
+
return [
|
|
717
|
+
`[botfarm] task ${task.id} — ${task.title}`,
|
|
718
|
+
"",
|
|
719
|
+
body,
|
|
720
|
+
"",
|
|
721
|
+
handoff,
|
|
722
|
+
].join("\n")
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The split stage that takes its pieces from this stage's handoff, if any. */
|
|
726
|
+
splitFedBy(def, stage) {
|
|
727
|
+
if (!def || !stage) return null
|
|
728
|
+
const stages = def.stages ?? []
|
|
729
|
+
const i = stages.findIndex((x) => x.id === stage.id)
|
|
730
|
+
const field = (x) => String(x.split === true ? "tasks" : x.split)
|
|
731
|
+
return stages.find((x, j) => {
|
|
732
|
+
if (!x.split || j <= i) return false
|
|
733
|
+
const f = field(x)
|
|
734
|
+
if (f.includes(".")) return f.split(".")[0] === stage.id
|
|
735
|
+
const deps = Array.isArray(x.after) ? x.after : [stages[j - 1]?.id]
|
|
736
|
+
return f === "tasks" && (deps.includes(stage.id) || (x.receives ?? []).includes(stage.id))
|
|
737
|
+
}) ?? null
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// --- fan-out / join ------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
/** The list a split card fans out over: `tasks` (or `stage.field`) from the handoffs it waits on. */
|
|
743
|
+
splitItems(parent) {
|
|
744
|
+
const from = parent.split?.from ?? "tasks"
|
|
745
|
+
const [stageId, field] = from.includes(".") ? from.split(".") : [null, from]
|
|
746
|
+
const run = parent.runId ? this.run(parent.runId) : null
|
|
747
|
+
const sources = stageId
|
|
748
|
+
? [this.tasks.get(run?.taskIds?.[stageId])]
|
|
749
|
+
: (parent.dependsOn ?? []).map((id) => this.tasks.get(id))
|
|
750
|
+
for (const t of sources) {
|
|
751
|
+
const items = normalizeItems(t?.handoff?.[field])
|
|
752
|
+
if (items.length) return items.slice(0, 50)
|
|
753
|
+
}
|
|
754
|
+
return []
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Split cards whose inputs have landed become one card per item, addressed
|
|
759
|
+
* to the part (any bot of that kind in this workstream), and enough bots of
|
|
760
|
+
* that kind are brought in to work `parallel` of them at once.
|
|
761
|
+
*/
|
|
762
|
+
async expandSplits() {
|
|
763
|
+
const spawned = []
|
|
764
|
+
for (const parent of this.tasks.all().filter((t) => t.split && !t.children && t.status === "queued" && this.tasks.ready(t))) {
|
|
765
|
+
const run = parent.runId ? this.run(parent.runId) : null
|
|
766
|
+
if (run?.paused) continue
|
|
767
|
+
const items = this.splitItems(parent)
|
|
768
|
+
if (!items.length) {
|
|
769
|
+
parent.split = null
|
|
770
|
+
this.tasks.save(parent)
|
|
771
|
+
this.sup.store.note(`"${parent.title}": the handoff before it had no ${parent.split?.from ?? "tasks"} list, so it runs as one card`, null, "warn")
|
|
772
|
+
continue
|
|
773
|
+
}
|
|
774
|
+
const persona = parent.persona
|
|
775
|
+
const role = run?.personaDefs?.[persona]?.role ?? persona
|
|
776
|
+
const children = items.map((it, i) => {
|
|
777
|
+
const c = this.tasks.create({
|
|
778
|
+
title: it.title,
|
|
779
|
+
brief: parent.brief,
|
|
780
|
+
runId: parent.runId,
|
|
781
|
+
projectId: parent.projectId,
|
|
782
|
+
stage: parent.stage,
|
|
783
|
+
persona,
|
|
784
|
+
role,
|
|
785
|
+
owner: "role",
|
|
786
|
+
kind: "work",
|
|
787
|
+
createdBy: "pipeline",
|
|
788
|
+
order: (parent.order ?? 0) + (i + 1) / 1000,
|
|
789
|
+
})
|
|
790
|
+
Object.assign(c, { parentId: parent.id, item: { ...it, index: i + 1, total: items.length }, limits: parent.limits ?? null })
|
|
791
|
+
return this.tasks.save(c)
|
|
792
|
+
})
|
|
793
|
+
Object.assign(parent, { children: children.map((c) => c.id), status: "active", assignee: null, owner: "group", startedAt: Date.now() })
|
|
794
|
+
this.tasks.save(parent)
|
|
795
|
+
const want = Math.min(parent.split.parallel ?? 1, items.length)
|
|
796
|
+
if (run) spawned.push(...(await this.ensureBots(run, persona, want)))
|
|
797
|
+
this.sup.store.note(`${parent.stage} split into ${items.length} cards for up to ${want} ${persona} bot${want > 1 ? "s" : ""}`, null, "info")
|
|
798
|
+
}
|
|
799
|
+
return spawned
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Bots of one kind on a run, bringing in more (same worktree, same brief) up to `n`. */
|
|
803
|
+
async ensureBots(run, persona, n) {
|
|
804
|
+
const have = Object.values(run.sessions ?? {}).map((id) => this.sup.store.get(id)).filter((s) => s && (s.persona ?? null) === persona)
|
|
805
|
+
const out = []
|
|
806
|
+
const project = this.sup.projects.get(run.projectId)
|
|
807
|
+
const def = run.personaDefs?.[persona]
|
|
808
|
+
if (!def || !project) return out
|
|
809
|
+
for (let k = have.length; k < n; k++) {
|
|
810
|
+
const s = await this.sup.spawn({
|
|
811
|
+
repo: run.repo,
|
|
812
|
+
services: project.services ?? [],
|
|
813
|
+
branch: run.branch,
|
|
814
|
+
title: `${def.title ?? persona} ${k + 1} · ${project.name}`,
|
|
815
|
+
label: persona,
|
|
816
|
+
group: run.projectId,
|
|
817
|
+
agent: def.agent ?? null,
|
|
818
|
+
model: def.model ?? null,
|
|
819
|
+
variant: def.variant ?? null,
|
|
820
|
+
persona,
|
|
821
|
+
personaTitle: def.title ?? persona,
|
|
822
|
+
role: def.role ?? persona,
|
|
823
|
+
adjectives: def.adjectives ?? null,
|
|
824
|
+
tools: def.tools ?? null,
|
|
825
|
+
policy: { talk: def.talk ?? "open", rooms: def.rooms ?? "member", spawn: !!def.may_spawn, ...(def.policy ?? {}) },
|
|
826
|
+
}).catch((e) => { this.sup.store.note(`could not add a ${persona} bot: ${e.message}`, null, "warn"); return null })
|
|
827
|
+
if (!s) break
|
|
828
|
+
const fresh = this.run(run.id) ?? run
|
|
829
|
+
fresh.sessions[`${persona}-${k + 1}`] = s.id
|
|
830
|
+
this.db.put("runs", fresh)
|
|
831
|
+
run.sessions = fresh.sessions
|
|
832
|
+
const room = this.sup.rooms.rooms.get(run.roomId)
|
|
833
|
+
const live = this.sup.store.get(s.id)
|
|
834
|
+
if (room && live && !room.members.has(s.id)) this.sup.rooms.join(room, live, { silent: true })
|
|
835
|
+
if (live) out.push(live)
|
|
836
|
+
}
|
|
837
|
+
if (out.length) await this.sup.saveManifest(run.projectId).catch(() => {})
|
|
838
|
+
return out
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** When the last piece of a split card lands, the card lands with all their handoffs joined. */
|
|
842
|
+
async joinParent(parentId) {
|
|
843
|
+
const parent = this.tasks.get(parentId)
|
|
844
|
+
if (!parent?.children || parent.status === "done") return
|
|
845
|
+
const kids = parent.children.map((id) => this.tasks.get(id)).filter(Boolean)
|
|
846
|
+
if (!kids.every((k) => ["done", "cancelled"].includes(k.status))) return
|
|
847
|
+
const done = kids.filter((k) => k.handoff)
|
|
848
|
+
const uniq = (xs) => [...new Set(xs)]
|
|
849
|
+
parent.handoff = {
|
|
850
|
+
summary: done.map((k) => `[${k.item?.index}/${k.item?.total}] ${k.title} — @${this.sup.store.get(k.assignee)?.handle ?? k.persona}:\n${k.handoff.summary}`).join("\n\n")
|
|
851
|
+
+ (kids.length > done.length ? `\n\n${kids.length - done.length} piece(s) were stopped before finishing.` : ""),
|
|
852
|
+
acceptance_criteria: uniq(done.flatMap((k) => k.handoff.acceptance_criteria ?? [])),
|
|
853
|
+
artifacts: uniq(done.flatMap((k) => k.handoff.artifacts ?? [])),
|
|
854
|
+
open_questions: uniq(done.flatMap((k) => k.handoff.open_questions ?? [])),
|
|
855
|
+
questions: done.flatMap((k) => k.handoff.questions ?? []),
|
|
856
|
+
parts: done.map((k) => ({ title: k.title, summary: k.handoff.summary, by: k.assignee })),
|
|
857
|
+
at: Date.now(),
|
|
858
|
+
by: parent.persona,
|
|
859
|
+
}
|
|
860
|
+
parent.files = parent.handoff.artifacts
|
|
861
|
+
parent.status = "done"
|
|
862
|
+
this.tasks.save(parent)
|
|
863
|
+
const run = parent.runId ? this.run(parent.runId) : null
|
|
864
|
+
const room = run ? this.sup.rooms.rooms.get(run.roomId) : null
|
|
865
|
+
const sum = (k) => kids.reduce((a, x) => a + (x.report?.[k] ?? 0), 0)
|
|
866
|
+
parent.report = { minutes: parent.startedAt ? (Date.now() - parent.startedAt) / 60000 : 0, tokens: sum("tokens"), usd: sum("usd"), files: parent.handoff.artifacts.slice(0, 60), diffs: [], images: kids.flatMap((k) => (k.report?.images ?? []).map((im, i) => ({ ...im, task: k.id, n: i }))).slice(0, 12), bots: [...new Set(kids.map((k) => this.sup.store.get(k.assignee)?.handle).filter(Boolean))], pieces: kids.length }
|
|
867
|
+
this.tasks.save(parent)
|
|
868
|
+
if (room) await this.sup.rooms.post(room, null, `Finished ${parent.stage}: all ${kids.length} pieces are in (${done.length} done${kids.length > done.length ? `, ${kids.length - done.length} stopped` : ""}).`, { quiet: true, meta: this.cardMeta(parent, parent.report) }).catch(() => {})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Hand queued tasks to sessions. Normally a busy session is left alone until
|
|
873
|
+
* it finishes, which is the whole point of queuing — but two cases must not
|
|
874
|
+
* wait for an idle transition:
|
|
875
|
+
*
|
|
876
|
+
* force: a session that was just created and handed its persona brief is
|
|
877
|
+
* "busy" by definition, so a fresh pipeline would otherwise sit
|
|
878
|
+
* there doing nothing until something else woke it.
|
|
879
|
+
* stalled: a queued task nobody has taken for a while. opencode queues
|
|
880
|
+
* prompts durably, so delivering to a busy session is safe; a
|
|
881
|
+
* missed idle event should not strand a run forever.
|
|
882
|
+
*/
|
|
883
|
+
async dispatch({ force = false, sessions = null } = {}) {
|
|
884
|
+
const spawned = await this.expandSplits()
|
|
885
|
+
const candidates = sessions ? [...sessions, ...spawned] : this.sup.store.list()
|
|
886
|
+
const fresh = new Set(spawned.map((s) => s.id))
|
|
887
|
+
for (const session of candidates) {
|
|
888
|
+
const task = this.tasks.nextFor(session.id, { role: session.role ?? session.persona, projectId: session.project ?? null, alive: (id) => this.aliveOn(id, session.project) })
|
|
889
|
+
if (!task) continue
|
|
890
|
+
if (task.runId && this.run(task.runId)?.paused) continue
|
|
891
|
+
// Rework waits for the bot that did it first while it has its chance,
|
|
892
|
+
// rather than being pushed into its queue behind other work.
|
|
893
|
+
if (task.prefer === session.id && session.status !== "idle" && Date.now() <= (task.preferUntil ?? 0)) continue
|
|
894
|
+
const stalled = Date.now() - (task.queuedAt ?? task.updated) > 45_000
|
|
895
|
+
if (session.status !== "idle" && !force && !stalled && !fresh.has(session.id)) continue
|
|
896
|
+
if (stalled && session.status !== "idle") {
|
|
897
|
+
this.sup.store.note(`queueing "${task.title}" behind @${session.handle}'s current work`, session.id, "warn")
|
|
898
|
+
}
|
|
899
|
+
task.status = "active"
|
|
900
|
+
task.startedAt = Date.now()
|
|
901
|
+
task.nudgedAt = null
|
|
902
|
+
task.tokensAtStart = tokensOf(session)
|
|
903
|
+
task.usdAtStart = this.costOf(session)
|
|
904
|
+
await this.routeCard(task, session).catch(() => {})
|
|
905
|
+
// Where the worktree stood when this card began, so its report shows what
|
|
906
|
+
// this card changed — through any tool, MCP server or script.
|
|
907
|
+
task.baseline = session.directory ? await git.snapshot(session.directory).catch(() => null) : null
|
|
908
|
+
task.pausedMs = 0
|
|
909
|
+
task.noRun = false
|
|
910
|
+
// Role work becomes this session's work the moment it is picked up, so
|
|
911
|
+
// two bots playing the same part cannot both take it.
|
|
912
|
+
if (!task.assignee) task.assignee = session.id
|
|
913
|
+
if (task.prefer) {
|
|
914
|
+
if (task.prefer !== session.id) this.sup.store.note(`"${task.title}" went to @${session.handle} — @${this.sup.store.get(task.prefer)?.handle ?? "the bot that did it first"} was not free`, session.id, "info")
|
|
915
|
+
task.prefer = null
|
|
916
|
+
task.preferUntil = null
|
|
917
|
+
}
|
|
918
|
+
this.tasks.save(task)
|
|
919
|
+
const brief = this.takeBrief(task, session)
|
|
920
|
+
const text = this.promptFor(task)
|
|
921
|
+
if (brief) {
|
|
922
|
+
// First contact: the persona and the task arrive as one operator turn,
|
|
923
|
+
// so the bot starts with its instructions and its work together.
|
|
924
|
+
await this.sup.send(session.id, `${brief}\n\n---\n\n${text}`)
|
|
925
|
+
} else {
|
|
926
|
+
// Whatever was said in the room since this bot last looked rides along
|
|
927
|
+
// with the card, so nobody has to be woken separately to read it.
|
|
928
|
+
const room = task.runId ? this.sup.rooms.rooms.get(this.run(task.runId)?.roomId) : null
|
|
929
|
+
await this.sup.deliver(session, {
|
|
930
|
+
text: text + earlier(room, session, "since you last looked"),
|
|
931
|
+
description: `task ${task.stage ?? ""} ${task.title}`.trim(),
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
this.sup.store.note(`@${session.handle} picked up "${task.title}"`, session.id)
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* A bot on a pipeline run with no open cards: everything assigned to it is
|
|
940
|
+
* done or cancelled. Chatter between bots does not wake it (only the
|
|
941
|
+
* operator, a direct question, or a new card does). Sessions outside
|
|
942
|
+
* pipeline runs are never resting: they keep the plain chat behaviour.
|
|
943
|
+
*/
|
|
944
|
+
resting(session) {
|
|
945
|
+
const onRun = this.runs().some((run) => Object.values(run.sessions ?? {}).includes(session.id))
|
|
946
|
+
if (!onRun) return false
|
|
947
|
+
return !this.tasks.all({ assignee: session.id }).some((t) => !["done", "cancelled"].includes(t.status))
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** A bot in a run whose first stage has not been handed to it yet. */
|
|
951
|
+
awaitingStage(session) {
|
|
952
|
+
for (const run of this.runs()) {
|
|
953
|
+
if (run.status !== "running" || !run.briefs) continue
|
|
954
|
+
if (Object.values(run.sessions ?? {}).includes(session.id)) return !(run.briefed ?? []).includes(session.id)
|
|
955
|
+
}
|
|
956
|
+
return false
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** The persona brief for this session's first task in a run, once. */
|
|
960
|
+
takeBrief(task, session) {
|
|
961
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
962
|
+
if (!run?.briefs) return null
|
|
963
|
+
run.briefed ??= []
|
|
964
|
+
if (run.briefed.includes(session.id)) return null
|
|
965
|
+
run.briefed.push(session.id)
|
|
966
|
+
this.db.put("runs", run)
|
|
967
|
+
const persona = session.persona ?? task.persona
|
|
968
|
+
const body = run.briefs[persona]
|
|
969
|
+
const peers = Object.entries(run.sessions ?? {})
|
|
970
|
+
.filter(([, id]) => id !== session.id)
|
|
971
|
+
.map(([p, id]) => `@${this.sup.store.get(id)?.handle ?? p} (${p})`)
|
|
972
|
+
const room = this.sup.rooms.rooms.get(run.roomId)
|
|
973
|
+
return [
|
|
974
|
+
`[botfarm] You are @${session.handle ?? persona}, the ${persona} on this run.` +
|
|
975
|
+
(peers.length ? ` The others are ${peers.join(", ")}${room ? `, in ${room.name}` : ""}.` : ""),
|
|
976
|
+
"Your task is below and has everything you need. Other stages run before and after yours: do not do their work,",
|
|
977
|
+
"and do not wait for anyone unless your task says to.",
|
|
978
|
+
body ? `\n${body}` : "",
|
|
979
|
+
earlier(room, session),
|
|
980
|
+
].join("\n")
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// --- sending work back ----------------------------------------------------
|
|
984
|
+
//
|
|
985
|
+
// Plan → Code → Verify → Fail → Code → Verify → Pass. A checker (QA, the
|
|
986
|
+
// reviewer, the operator) that finds something wrong does not fix it: it
|
|
987
|
+
// sends the earlier card back with what failed. That card goes back to To
|
|
988
|
+
// do for the bot that did it first (another of its kind takes it if that
|
|
989
|
+
// bot is gone or stays busy), and the checker's card waits for the fix and
|
|
990
|
+
// then comes back to the same checker to verify again. Rounds are counted
|
|
991
|
+
// per checker card and target; past `max_rounds`, or when the same failure
|
|
992
|
+
// comes back twice, it stops and asks the operator.
|
|
993
|
+
|
|
994
|
+
/** A session that still works on this workstream. */
|
|
995
|
+
aliveOn(id, projectId) {
|
|
996
|
+
const s = id ? this.sup.store.get(id) : null
|
|
997
|
+
return !!s && (!projectId || !s.project || s.project === projectId) && s.status !== "error"
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** The earlier stages a card may send back to, with who did them. */
|
|
1001
|
+
sendBackTargets(task) {
|
|
1002
|
+
const run = task?.runId ? this.run(task.runId) : null
|
|
1003
|
+
const def = this.defFor(run)
|
|
1004
|
+
const stages = def?.stages ?? []
|
|
1005
|
+
const stage = stages.find((s) => s.id === task.stage)
|
|
1006
|
+
if (!stage || stage.send_back === false || this.maxRounds(task) === 0) return []
|
|
1007
|
+
const i = stages.indexOf(stage)
|
|
1008
|
+
const allowed = Array.isArray(stage.send_back) ? stage.send_back : stages.slice(0, i).filter((s) => !s.human).map((s) => s.id)
|
|
1009
|
+
return allowed.map((id) => {
|
|
1010
|
+
const t = this.tasks.get(run.taskIds?.[id])
|
|
1011
|
+
const who = t?.children ? [...new Set(t.children.map((c) => this.sup.store.get(this.tasks.get(c)?.assignee)?.handle).filter(Boolean))] : [this.sup.store.get(t?.assignee)?.handle].filter(Boolean)
|
|
1012
|
+
return { stage: id, persona: stages.find((s) => s.id === id)?.persona ?? null, by: who, pieces: t?.children?.length ?? 0 }
|
|
1013
|
+
}).filter((x) => x.persona)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
maxRounds(task) {
|
|
1017
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
1018
|
+
const def = this.defFor(run)
|
|
1019
|
+
const stage = def?.stages?.find((s) => s.id === task.stage)
|
|
1020
|
+
const base = stage?.max_rounds ?? def?.max_rounds ?? this.sup.config?.maxRounds ?? 3
|
|
1021
|
+
return base + (task.extraRounds ?? 0)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Send earlier work back. `from` is the checker's card (null when the
|
|
1026
|
+
* operator does it from the board). `to` is a stage id or a card id.
|
|
1027
|
+
* `pieces` (1-based numbers or card ids) or `files` pick pieces of a split
|
|
1028
|
+
* stage; with neither, a new "fix" piece is added.
|
|
1029
|
+
*/
|
|
1030
|
+
async sendBack(from, { to, reason = "", failures = [], files = [], pieces = [], by = null } = {}) {
|
|
1031
|
+
reason = String(reason ?? "").trim()
|
|
1032
|
+
failures = (Array.isArray(failures) ? failures : [failures]).map((f) => String(typeof f === "object" && f ? JSON.stringify(f) : f ?? "").trim()).filter(Boolean).slice(0, 30)
|
|
1033
|
+
files = (Array.isArray(files) ? files : [files]).map(String).filter(Boolean).slice(0, 60)
|
|
1034
|
+
if (!reason && !failures.length) throw new Error("say what is wrong: a reason and/or the failures — the bot fixing it cannot see your conversation")
|
|
1035
|
+
const get = (id) => (typeof id === "string" && id ? this.tasks.get(id) : null)
|
|
1036
|
+
const runId = from?.runId ?? get(to)?.runId
|
|
1037
|
+
const run = runId ? this.run(runId) : null
|
|
1038
|
+
if (!run) throw new Error("sending back only works inside a pipeline workstream")
|
|
1039
|
+
let target = get(run.taskIds?.[to]) ?? get(to)
|
|
1040
|
+
if (target?.runId !== run.id) target = null
|
|
1041
|
+
if (!target) throw new Error(`no stage or card "${to}" on this workstream. Stages: ${Object.keys(run.taskIds).join(", ")}`)
|
|
1042
|
+
if (target.owner === "human" || target.kind === "question") throw new Error("that card is for the operator; ask them with botfarm_ask_human instead")
|
|
1043
|
+
const top = target.parentId ? this.tasks.get(target.parentId) : target
|
|
1044
|
+
const key = top.stage ?? top.id
|
|
1045
|
+
if (from) {
|
|
1046
|
+
if (from.id === target.id || from.id === top.id) throw new Error("that is your own card — fix it yourself or finish it")
|
|
1047
|
+
const ok = this.sendBackTargets(from).map((x) => x.stage)
|
|
1048
|
+
if (!ok.includes(key)) throw new Error(ok.length ? `${from.stage} can send back to: ${ok.join(", ")}` : `${from.stage} cannot send work back (its pipeline says so)`)
|
|
1049
|
+
}
|
|
1050
|
+
const by_ = by ?? (from ? this.sup.store.get(from.assignee)?.handle ?? from.persona : "operator")
|
|
1051
|
+
const room = this.sup.rooms.rooms.get(run.roomId)
|
|
1052
|
+
|
|
1053
|
+
// Rounds, and the same failure twice, are counted per checker card.
|
|
1054
|
+
let round = 1
|
|
1055
|
+
if (from) {
|
|
1056
|
+
from.rounds ??= {}
|
|
1057
|
+
round = (from.rounds[key] ?? 0) + 1
|
|
1058
|
+
const max = this.maxRounds(from)
|
|
1059
|
+
const sig = (failures.length ? failures.join("\n") : reason).toLowerCase().replace(/\d+(\.\d+)?\s*(ms|s)\b/g, "").replace(/\s+/g, " ").slice(0, 600)
|
|
1060
|
+
const same = round > 1 && sig.length >= 20 && from.lastSendBack?.[key]?.sig === sig
|
|
1061
|
+
if (round > max || same) {
|
|
1062
|
+
const why = same ? `the same failure came back after round ${round - 1}` : `${key} has been sent back ${round - 1} time${round > 2 ? "s" : ""} (max ${max})`
|
|
1063
|
+
from.status = "review"
|
|
1064
|
+
from.budget = { kind: "rounds", why, to: key, round: round - 1, max, at: Date.now() }
|
|
1065
|
+
from.pausedAt = Date.now()
|
|
1066
|
+
from.pendingSendBack = { to, reason, failures, files, pieces }
|
|
1067
|
+
this.tasks.save(from)
|
|
1068
|
+
this.sup.store.note(`⏸ "${from.title}": ${why} — waiting for you`, from.assignee, "warn")
|
|
1069
|
+
this.sup.replay?.note(from.projectId, `${from.stage}: ${why}`, "warn")
|
|
1070
|
+
if (room) await this.sup.rooms.post(room, this.sup.store.get(from.assignee) ?? null, `⏸ I wanted to send ${key} back again, but ${why}. Waiting for the operator.`, { quiet: true }).catch(() => {})
|
|
1071
|
+
this.sup.onChange()
|
|
1072
|
+
return { paused: true, why, round: round - 1, max }
|
|
1073
|
+
}
|
|
1074
|
+
from.lastSendBack = { ...(from.lastSendBack ?? {}), [key]: { sig, at: Date.now() } }
|
|
1075
|
+
} else {
|
|
1076
|
+
round = (top.round ?? 1) + 1
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Which cards go back.
|
|
1080
|
+
let reopen = []
|
|
1081
|
+
let added = null
|
|
1082
|
+
if (top.children) {
|
|
1083
|
+
const kids = top.children.map((id) => this.tasks.get(id)).filter(Boolean)
|
|
1084
|
+
const want = (Array.isArray(pieces) ? pieces : [pieces]).filter((p) => p !== undefined && p !== null && p !== "")
|
|
1085
|
+
if (target !== top) reopen = [target]
|
|
1086
|
+
else if (want.length) reopen = kids.filter((k) => want.some((p) => String(p) === k.id || Number(p) === k.item?.index))
|
|
1087
|
+
else if (files.length) {
|
|
1088
|
+
const hit = (a, b) => a === b || a.endsWith("/" + b) || b.endsWith("/" + a)
|
|
1089
|
+
reopen = kids.filter((k) => [...(k.files ?? []), ...(k.handoff?.artifacts ?? []), ...(k.report?.files ?? []).map((f) => f.path ?? f)].some((f) => files.some((x) => hit(String(f), x))))
|
|
1090
|
+
}
|
|
1091
|
+
if (!reopen.length && kids.length === 1) reopen = kids
|
|
1092
|
+
if (!reopen.length) {
|
|
1093
|
+
// Nobody owns what failed: a new piece, for any bot of that kind.
|
|
1094
|
+
const total = kids.length + 1
|
|
1095
|
+
added = this.tasks.create({
|
|
1096
|
+
title: `Fix: ${(failures[0] ?? reason).split("\n")[0].slice(0, 80)}`,
|
|
1097
|
+
brief: top.brief, runId: top.runId, projectId: top.projectId, stage: top.stage, persona: top.persona,
|
|
1098
|
+
role: run.personaDefs?.[top.persona]?.role ?? top.persona, owner: "role", kind: "work", createdBy: by_,
|
|
1099
|
+
order: (top.order ?? 0) + total / 1000,
|
|
1100
|
+
})
|
|
1101
|
+
Object.assign(added, { parentId: top.id, item: { title: added.title, detail: reason, index: total, total }, limits: top.limits ?? null, round, rework: { round, reason, failures, files, by: by_, fromTask: from?.id ?? null, fromStage: from?.stage ?? null } })
|
|
1102
|
+
this.tasks.save(added)
|
|
1103
|
+
top.children = [...top.children, added.id]
|
|
1104
|
+
}
|
|
1105
|
+
} else reopen = [target]
|
|
1106
|
+
|
|
1107
|
+
for (const t of reopen) {
|
|
1108
|
+
const prev = t.assignee
|
|
1109
|
+
if (["done", "cancelled", "review"].includes(t.status)) {
|
|
1110
|
+
t.history = [...(t.history ?? []), { round: t.round ?? 1, summary: String(t.handoff?.summary ?? "").slice(0, 2000), usd: t.report?.usd ?? null, tokens: t.report?.tokens ?? null, minutes: t.report?.minutes ?? null, by: this.sup.store.get(prev)?.handle ?? null, at: Date.now() }].slice(-12)
|
|
1111
|
+
t.round = (t.round ?? 1) + 1
|
|
1112
|
+
t.rework = { round: t.round, reason, failures, files, by: by_, fromTask: from?.id ?? null, fromStage: from?.stage ?? null }
|
|
1113
|
+
t.status = "queued"
|
|
1114
|
+
t.queuedAt = Date.now()
|
|
1115
|
+
t.budget = null
|
|
1116
|
+
t.xp = null
|
|
1117
|
+
t.pausedAt = null
|
|
1118
|
+
// Addressed to the part, preferring the bot that did it.
|
|
1119
|
+
t.owner = "role"
|
|
1120
|
+
t.role = run.personaDefs?.[t.persona]?.role ?? t.persona
|
|
1121
|
+
t.assignee = null
|
|
1122
|
+
t.prefer = this.aliveOn(prev, run.projectId) ? prev : null
|
|
1123
|
+
t.preferUntil = t.prefer ? Date.now() + (this.sup.config?.preferMs ?? 90_000) : null
|
|
1124
|
+
this.tasks.save(t)
|
|
1125
|
+
} else {
|
|
1126
|
+
// Still being worked on (dev and QA side by side): tell its bot now.
|
|
1127
|
+
this.tasks.note(t.id, `${by_} found: ${[reason, ...failures].filter(Boolean).join(" · ").slice(0, 1500)}`, by_)
|
|
1128
|
+
t.rework = { round: t.round ?? 1, reason, failures, files, by: by_, fromTask: from?.id ?? null, fromStage: from?.stage ?? null, early: true }
|
|
1129
|
+
this.tasks.save(t)
|
|
1130
|
+
const s = t.status === "active" ? this.sup.store.get(t.assignee) : null
|
|
1131
|
+
if (s) await this.sup.deliver(s, { text: `[botfarm] @${by_} checked your work on "${t.title}" and found a problem:\n${this.reworkText(t.rework)}\nFix it as part of this card before you complete it.`, description: `rework ${t.title}` }).catch(() => {})
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (top.children && (reopen.length || added)) { top.status = "active"; this.tasks.save(top) }
|
|
1135
|
+
|
|
1136
|
+
// Make sure someone of that kind is there to take it.
|
|
1137
|
+
const persona = top.persona
|
|
1138
|
+
if (!Object.values(run.sessions ?? {}).some((id) => this.aliveOn(id, run.projectId) && this.sup.store.get(id)?.persona === persona)) {
|
|
1139
|
+
await this.ensureBots(run, persona, 1).catch(() => {})
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// The checker's card (or, from the board, whatever was waiting on it) waits for the fix.
|
|
1143
|
+
const waitOn = top.children ? top.id : target.id
|
|
1144
|
+
const waiters = from ? [from] : this.tasks.all({ runId: run.id }).filter((t) => t.id !== top.id && !t.parentId && this.dependsOnTransitively(t, top.id) && ["active", "queued", "review"].includes(t.status) && t.owner !== "human")
|
|
1145
|
+
for (const w of waiters) {
|
|
1146
|
+
if (w.status === "active" && !from) {
|
|
1147
|
+
const s = this.sup.store.get(w.assignee)
|
|
1148
|
+
if (s && (s.status === "busy" || s.status === "waiting")) await this.sup.abort(s.id).catch(() => {})
|
|
1149
|
+
}
|
|
1150
|
+
w.baseDeps ??= [...(w.dependsOn ?? [])]
|
|
1151
|
+
w.dependsOn = [...new Set([...(w.dependsOn ?? []), waitOn])]
|
|
1152
|
+
w.status = "blocked"
|
|
1153
|
+
w.queuedAt = null
|
|
1154
|
+
w.recheck = { round, to: key, reason, cards: [...reopen.map((t) => t.id), ...(added ? [added.id] : [])], by: by_ }
|
|
1155
|
+
if (from && w === from) from.rounds[key] = round
|
|
1156
|
+
this.tasks.save(w)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const names = [...reopen, ...(added ? [added] : [])]
|
|
1160
|
+
const whoFor = names.map((t) => t.prefer ? "@" + (this.sup.store.get(t.prefer)?.handle ?? t.persona) : `a ${t.persona} bot`)
|
|
1161
|
+
const max = from ? this.maxRounds(from) : null
|
|
1162
|
+
const text = `↩ ${by_ === "operator" ? "You" : "@" + by_} sent ${key}${names.length && top.children ? ` (${names.map((t) => t.item ? `piece ${t.item.index}` : t.title).join(", ")})` : ""} back to ${[...new Set(whoFor)].join(", ") || "its bot"}${from ? ` — fix ${round} of ${max}` : ""}: ${(reason || failures[0]).slice(0, 400)}${failures.length > (reason ? 0 : 1) ? `\n${failures.slice(reason ? 0 : 1, 6).map((f) => "• " + f.slice(0, 200)).join("\n")}` : ""}`
|
|
1163
|
+
if (room) await this.sup.rooms.post(room, from ? this.sup.store.get(from.assignee) : null, text, { quiet: true, meta: { kind: "sendback", to: key, round, max, cards: names.map((t) => t.id), from: from?.id ?? null } }).catch(() => {})
|
|
1164
|
+
this.sup.store.note(text.split("\n")[0], from?.assignee ?? null, "warn")
|
|
1165
|
+
this.sup.replay?.note(run.projectId, text.split("\n")[0])
|
|
1166
|
+
this.tasks.unblock()
|
|
1167
|
+
await this.dispatch()
|
|
1168
|
+
return { sent: names.map((t) => ({ id: t.id, title: t.title, to: t.prefer ? this.sup.store.get(t.prefer)?.handle ?? null : null })), round, max, waiting: waiters.map((w) => w.id) }
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
dependsOnTransitively(task, id, seen = new Set()) {
|
|
1172
|
+
for (const d of task.dependsOn ?? []) {
|
|
1173
|
+
if (d === id) return true
|
|
1174
|
+
if (seen.has(d)) continue
|
|
1175
|
+
seen.add(d)
|
|
1176
|
+
const t = this.tasks.get(d)
|
|
1177
|
+
if (t && this.dependsOnTransitively(t, id, seen)) return true
|
|
1178
|
+
}
|
|
1179
|
+
return false
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
reworkText(r) {
|
|
1183
|
+
return [
|
|
1184
|
+
r.reason ? ` ${r.reason}` : "",
|
|
1185
|
+
r.failures?.length ? `What failed:\n${r.failures.map((f) => ` - ${f}`).join("\n")}` : "",
|
|
1186
|
+
r.files?.length ? `Files: ${r.files.join(", ")}` : "",
|
|
1187
|
+
].filter(Boolean).join("\n")
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** The operator answers a card that stopped sending work back. */
|
|
1191
|
+
async roundsDecision(task, action) {
|
|
1192
|
+
const pending = task.pendingSendBack
|
|
1193
|
+
const was = task.budget
|
|
1194
|
+
task.pausedMs = (task.pausedMs ?? 0) + (task.pausedAt ? Date.now() - task.pausedAt : 0)
|
|
1195
|
+
task.pausedAt = null
|
|
1196
|
+
task.budget = null
|
|
1197
|
+
task.pendingSendBack = null
|
|
1198
|
+
const s = this.sup.store.get(task.assignee)
|
|
1199
|
+
if (action === "continue" && pending) {
|
|
1200
|
+
// One more round, and forget the "same failure" match so it can go.
|
|
1201
|
+
task.extraRounds = (task.extraRounds ?? 0) + Math.max(0, (was.round + 1) - this.maxRounds(task))
|
|
1202
|
+
if (task.lastSendBack?.[was.to]) task.lastSendBack[was.to].sig = null
|
|
1203
|
+
task.status = "active"
|
|
1204
|
+
this.tasks.save(task)
|
|
1205
|
+
return this.sendBack(task, pending)
|
|
1206
|
+
}
|
|
1207
|
+
// Accept: the checker finishes with what it has.
|
|
1208
|
+
task.status = "active"
|
|
1209
|
+
task.nudgedAt = null
|
|
1210
|
+
this.tasks.save(task)
|
|
1211
|
+
if (s) await this.sup.deliver(s, {
|
|
1212
|
+
text: `[botfarm] The operator read where ${was.to} stands and says: no more rounds. Finish task ${task.id} — "${task.title}" — now with botfarm_task_complete, and list whatever still fails in open_questions so it is not lost.`,
|
|
1213
|
+
description: `finish ${task.title}`,
|
|
1214
|
+
})
|
|
1215
|
+
return task
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// --- routing, shared notes, loops, XP -------------------------------------
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Pick the model for a card by how hard it looks: the agent's `tiers:` or
|
|
1222
|
+
* the workspace's botfarm/routing.botfarm.yml. The bot's own override (Model…)
|
|
1223
|
+
* always wins. Recorded on the card so the board can show it.
|
|
1224
|
+
*/
|
|
1225
|
+
async routeCard(task, session) {
|
|
1226
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
1227
|
+
const stage = this.defFor(run)?.stages?.find((s) => s.id === task.stage)
|
|
1228
|
+
const ctx = this.contextFor(task)
|
|
1229
|
+
const d = difficulty({
|
|
1230
|
+
explicit: task.item?.difficulty ?? stage?.difficulty ?? task.difficultyWanted ?? null,
|
|
1231
|
+
title: task.title,
|
|
1232
|
+
detail: task.item?.detail ?? "",
|
|
1233
|
+
story: task.item ? "" : task.brief ?? "",
|
|
1234
|
+
criteria: ctx.acceptance_criteria ?? [],
|
|
1235
|
+
})
|
|
1236
|
+
const wsId = run?.workspaceId ?? this.sup.workspaceIdOf(session)
|
|
1237
|
+
const routing = wsId ? await this.sup.workspaces.routing(wsId).catch(() => null) : null
|
|
1238
|
+
const persona = run?.personaDefs?.[task.persona ?? session.persona] ?? null
|
|
1239
|
+
const tier = routeFor(d.level, { agentTiers: persona?.tiers ?? null, routing })
|
|
1240
|
+
const route = tier ? this.applyQuota(tier, { wsId, projectId: task.projectId, persona: task.persona ?? session.persona }) : null
|
|
1241
|
+
task.difficulty = { ...d, route: route ? { model: route.model, variant: route.variant, fellBack: route.fellBack ?? null } : null }
|
|
1242
|
+
session.route = route ? { ...route, source: tier.source, persona: task.persona ?? session.persona, taskId: task.id, level: d.level, wsId, projectId: task.projectId, switchMode: routing?.switch === "next-turn" ? "next-turn" : "now" } : null
|
|
1243
|
+
if (route) this.sup.store.note(`"${task.title}" looks ${d.level} (${d.why}) → ${route.model ?? "same model"}${route.variant ? ` · ${route.variant}` : ""}${route.fellBack ? ` (${route.fellBack.why})` : ""}`, session.id, "info")
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* A tier with a quota on its model: once the workspace has spent it in the
|
|
1248
|
+
* period (a day, week, month or this workstream), use the fallback instead.
|
|
1249
|
+
*/
|
|
1250
|
+
applyQuota(tier, { wsId, projectId, persona = null }) {
|
|
1251
|
+
if (!tier.quota || !tier.model || !this.sup.ledger) return tier
|
|
1252
|
+
// An agent's own quota counts only that agent's spend; a workspace quota counts everyone's.
|
|
1253
|
+
const key = tier.source === "agent" && persona ? `${tier.model}@${persona}` : tier.model
|
|
1254
|
+
const spent = this.sup.ledger.spent(wsId, key, tier.quota.per, projectId)
|
|
1255
|
+
const hit = overQuota(tier.quota, spent)
|
|
1256
|
+
if (!hit) return tier
|
|
1257
|
+
const { quota, fallback, ...rest } = tier
|
|
1258
|
+
const label = { usd: `$${Number(hit.limit).toFixed(2)}`, tokens: `${Math.round(hit.limit / 1000)}k tokens`, minutes: `${Math.round(hit.limit)} min` }[hit.kind]
|
|
1259
|
+
return {
|
|
1260
|
+
model: fallback?.model ?? undefined,
|
|
1261
|
+
variant: fallback?.variant ?? (fallback?.model ? undefined : rest.variant),
|
|
1262
|
+
fellBack: { from: tier.model, why: `${tier.model.split("/").pop()} quota of ${label} per ${tier.quota.per} used`, hit },
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Cards already running on a model whose quota just ran out move to the
|
|
1268
|
+
* fallback: at once (the turn is stopped and picked up again on the
|
|
1269
|
+
* fallback) or from the bot's next turn, per the routing file's `switch:`.
|
|
1270
|
+
*/
|
|
1271
|
+
async checkQuotas() {
|
|
1272
|
+
for (const s of this.sup.store.list()) {
|
|
1273
|
+
const r = s.route
|
|
1274
|
+
if (!r?.quota || r.fellBack || s.override?.model) continue
|
|
1275
|
+
const next = this.applyQuota(r, { wsId: r.wsId, projectId: r.projectId, persona: r.persona })
|
|
1276
|
+
if (!next.fellBack) continue
|
|
1277
|
+
s.route = { ...next, source: r.source, persona: r.persona, taskId: r.taskId, level: r.level, wsId: r.wsId, projectId: r.projectId, switchMode: r.switchMode }
|
|
1278
|
+
const task = this.tasks.get(r.taskId)
|
|
1279
|
+
if (task?.difficulty) { task.difficulty = { ...task.difficulty, route: { model: next.model, variant: next.variant, fellBack: next.fellBack } }; this.tasks.save(task) }
|
|
1280
|
+
const to = next.model ? next.model.split("/").pop() : "its agent's own model"
|
|
1281
|
+
this.sup.store.note(`@${s.handle}: ${next.fellBack.why} — switching to ${to}${next.variant ? ` · ${next.variant}` : ""}`, s.id, "warn")
|
|
1282
|
+
this.sup.replay?.note(task?.projectId ?? s.project, `@${s.handle} moved to ${to}: ${next.fellBack.why}`, "warn")
|
|
1283
|
+
if (r.switchMode === "now" && s.status === "busy" && task?.status === "active") {
|
|
1284
|
+
await this.sup.abort(s.id).catch(() => {})
|
|
1285
|
+
await this.sup.deliver(s, {
|
|
1286
|
+
text: `[botfarm] ${next.fellBack.why}, so you are now running on ${to}. Carry on with task ${task.id} — "${task.title}" — from where you left off.`,
|
|
1287
|
+
description: `continue ${task.title} on ${to}`,
|
|
1288
|
+
}).catch(() => {})
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* What a finished card cost and changed. Diffs are taken now, per file the
|
|
1295
|
+
* card handed over (capped), so later stages' edits do not rewrite history.
|
|
1296
|
+
* Screenshots are image attachments from the bot's tool results since the
|
|
1297
|
+
* card started, and image files among its artifacts.
|
|
1298
|
+
*/
|
|
1299
|
+
async cardReport(task) {
|
|
1300
|
+
const s = this.sup.store.get(task.assignee)
|
|
1301
|
+
const usage = this.taskUsage(task)
|
|
1302
|
+
const dir = s?.directory ?? (task.runId ? this.sup.projects.get(task.projectId)?.worktree : null)
|
|
1303
|
+
const listed = [...new Set((task.handoff?.artifacts ?? []).map(String))].slice(0, 60)
|
|
1304
|
+
let files = listed
|
|
1305
|
+
const diffs = []
|
|
1306
|
+
let budget = 160_000
|
|
1307
|
+
if (dir && task.baseline) {
|
|
1308
|
+
// What changed on disk during the card, however it was changed.
|
|
1309
|
+
const changes = await git.changesSince(dir, task.baseline).catch(() => [])
|
|
1310
|
+
const norm = (f) => String(f).replace(/^\.\//, "").replace(dir + "/", "")
|
|
1311
|
+
const listedSet = new Set(listed.map(norm))
|
|
1312
|
+
for (const c of changes) diffs.push({ ...c, unlisted: !listedSet.has(c.file) })
|
|
1313
|
+
files = [...new Set([...listed.map(norm), ...changes.map((c) => c.file)])].slice(0, 80)
|
|
1314
|
+
} else if (dir) {
|
|
1315
|
+
for (const f of files.slice(0, 20)) {
|
|
1316
|
+
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(f)) continue
|
|
1317
|
+
let d = await git.diffFile(dir, f).catch(() => "")
|
|
1318
|
+
if (!d) continue
|
|
1319
|
+
// A new, untracked file comes back as its contents: show it as all added.
|
|
1320
|
+
if (!/^diff --git /m.test(d)) d = `--- /dev/null\n+++ b/${f}\n@@ new file @@\n` + d.replace(/\n$/, "").split("\n").map((l) => "+" + l).join("\n")
|
|
1321
|
+
const lines = d.split("\n")
|
|
1322
|
+
const added = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).length
|
|
1323
|
+
const removed = lines.filter((l) => l.startsWith("-") && !l.startsWith("---")).length
|
|
1324
|
+
const cap = Math.min(budget, 24_000)
|
|
1325
|
+
if (cap <= 0) { diffs.push({ file: f, added, removed, truncated: true, diff: "" }); continue }
|
|
1326
|
+
const truncated = d.length > cap
|
|
1327
|
+
if (truncated) d = d.slice(0, cap)
|
|
1328
|
+
budget -= d.length
|
|
1329
|
+
diffs.push({ file: f, added, removed, truncated, diff: d })
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const images = []
|
|
1333
|
+
for (const f of files.filter((x) => /\.(png|jpe?g|gif|webp)$/i.test(x) && !diffs.some((d) => d.file === x && d.deleted)).slice(0, 6)) images.push({ source: "worktree", path: f, name: f.split("/").pop() })
|
|
1334
|
+
if (s) {
|
|
1335
|
+
const msgs = await this.sup.client.messages(s.id, { limit: 60, order: "desc" }).catch(() => [])
|
|
1336
|
+
const since = task.startedAt ?? 0
|
|
1337
|
+
const shots = []
|
|
1338
|
+
for (const m of msgs) {
|
|
1339
|
+
const info = m.info ?? m
|
|
1340
|
+
const at = info.time?.created ?? info.time?.start ?? 0
|
|
1341
|
+
if (at && at < since) continue
|
|
1342
|
+
for (const p of m.parts ?? []) {
|
|
1343
|
+
const atts = [
|
|
1344
|
+
...(p.type === "file" ? [p] : []),
|
|
1345
|
+
...(Array.isArray(p.state?.attachments) ? p.state.attachments : []),
|
|
1346
|
+
...(Array.isArray(p.attachments) ? p.attachments : []),
|
|
1347
|
+
]
|
|
1348
|
+
for (const a of atts) {
|
|
1349
|
+
const mime = a.mime ?? a.mimeType ?? ""
|
|
1350
|
+
const url = a.url ?? (a.data ? `data:${mime};base64,${a.data}` : "")
|
|
1351
|
+
if (/^image\//.test(mime) && /^data:/.test(url)) shots.push({ mime, url, name: a.filename ?? a.name ?? `${p.tool ?? "screenshot"}` })
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (shots.length) {
|
|
1356
|
+
const out = join(this.sup.home, "shots")
|
|
1357
|
+
await mkdirp(out, { recursive: true })
|
|
1358
|
+
for (const [i, sh] of shots.slice(0, 8).entries()) {
|
|
1359
|
+
const ext = (sh.mime.split("/")[1] ?? "png").replace("jpeg", "jpg").replace(/[^a-z0-9]/g, "")
|
|
1360
|
+
const file = `${task.id}-${i}.${ext}`
|
|
1361
|
+
const b64 = sh.url.slice(sh.url.indexOf(",") + 1)
|
|
1362
|
+
await writeF(join(out, file), Buffer.from(b64, "base64")).catch(() => {})
|
|
1363
|
+
images.push({ source: "shot", file, name: sh.name })
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const report = {
|
|
1368
|
+
minutes: task.startedAt ? Math.max(0, Date.now() - task.startedAt - (task.pausedMs ?? 0)) / 60000 : 0,
|
|
1369
|
+
tokens: usage.tokens,
|
|
1370
|
+
usd: usage.usd,
|
|
1371
|
+
model: s ? (this.sup.turnSettings(s).model ?? s.model ?? null) : null,
|
|
1372
|
+
handle: s?.handle ?? null,
|
|
1373
|
+
files,
|
|
1374
|
+
diffs,
|
|
1375
|
+
images,
|
|
1376
|
+
tracked: !!task.baseline,
|
|
1377
|
+
}
|
|
1378
|
+
delete task.baseline // done with it; keep the task record small
|
|
1379
|
+
task.report = report
|
|
1380
|
+
this.tasks.save(task)
|
|
1381
|
+
return report
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** The small part of a report that rides on the chat message; the diffs are fetched when opened. */
|
|
1385
|
+
cardMeta(task, r) {
|
|
1386
|
+
return {
|
|
1387
|
+
kind: "card",
|
|
1388
|
+
taskId: task.id,
|
|
1389
|
+
title: task.title,
|
|
1390
|
+
stage: task.stage ?? null,
|
|
1391
|
+
piece: task.item ? `${task.item.index}/${task.item.total}` : null,
|
|
1392
|
+
minutes: r.minutes,
|
|
1393
|
+
tokens: r.tokens,
|
|
1394
|
+
usd: r.usd,
|
|
1395
|
+
model: r.model ?? null,
|
|
1396
|
+
xp: task.xp?.gained ?? null,
|
|
1397
|
+
files: (r.diffs?.length ? r.diffs.map((d) => ({ file: d.file, added: d.added, removed: d.removed, ...(d.unlisted ? { unlisted: true } : {}), ...(d.created ? { created: true } : {}), ...(d.deleted ? { deleted: true } : {}) })) : (r.files ?? []).map((f) => ({ file: f }))).slice(0, 40),
|
|
1398
|
+
images: (r.images ?? []).length,
|
|
1399
|
+
pieces: r.pieces ?? null,
|
|
1400
|
+
bots: r.bots ?? null,
|
|
1401
|
+
open: task.handoff?.open_questions?.length ?? 0,
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** Stop every bot on a workstream until you resume it. */
|
|
1406
|
+
async pauseByHand(projectId) {
|
|
1407
|
+
const project = this.sup.projects.get(projectId)
|
|
1408
|
+
const run = project?.runId ? this.run(project.runId) : null
|
|
1409
|
+
if (!run) throw new Error("this workstream was not started from a pipeline")
|
|
1410
|
+
if (run.paused) return run
|
|
1411
|
+
await this.pauseRun(run, { kind: "manual" })
|
|
1412
|
+
return run
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** What the rest of the team already knows: saved notes, and files they have read. */
|
|
1416
|
+
sharedContext(task) {
|
|
1417
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
1418
|
+
if (!run) return ""
|
|
1419
|
+
const notes = run.notes ?? []
|
|
1420
|
+
const me = task.assignee
|
|
1421
|
+
const read = new Map()
|
|
1422
|
+
for (const id of Object.values(run.sessions ?? {})) {
|
|
1423
|
+
if (id === me) continue
|
|
1424
|
+
const s = this.sup.store.get(id)
|
|
1425
|
+
for (const c of s?.toolLog ?? []) {
|
|
1426
|
+
if (!/(^|_)read$|^read/i.test(c.name) || !c.summary || c.status === "error") continue
|
|
1427
|
+
if (!read.has(c.summary)) read.set(c.summary, s.handle)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
const out = []
|
|
1431
|
+
if (notes.length) out.push(`Team notes (read the ones you need with botfarm_notes_read before exploring): ${notes.slice(-15).map((n) => `"${n.topic}" (@${n.by})`).join(", ")}.`)
|
|
1432
|
+
if (read.size) out.push(`Files teammates have already read — check the notes or ask before re-reading them all: ${[...read].slice(-20).map(([f, h]) => `${f} (@${h})`).join(", ")}.`)
|
|
1433
|
+
out.push("When you have mapped an area others will need (a module, a flow, where something lives), save 3–8 lines with botfarm_notes_write so nobody has to read it all again.")
|
|
1434
|
+
return out.join("\n")
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
notes(runId) {
|
|
1438
|
+
return this.run(runId)?.notes ?? []
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
addNote(runId, { topic, text, files = [], by }) {
|
|
1442
|
+
const run = this.run(runId)
|
|
1443
|
+
if (!run) throw new Error("no workstream for this bot")
|
|
1444
|
+
const t = String(topic ?? "").trim().slice(0, 80)
|
|
1445
|
+
if (!t || !String(text ?? "").trim()) throw new Error("a note needs a topic and some text")
|
|
1446
|
+
run.notes ??= []
|
|
1447
|
+
const at = run.notes.findIndex((n) => n.topic.toLowerCase() === t.toLowerCase())
|
|
1448
|
+
const note = { topic: t, text: trimText(String(text), 1500), files: (files ?? []).slice(0, 30).map(String), by, at: Date.now() }
|
|
1449
|
+
if (at >= 0) run.notes[at] = note
|
|
1450
|
+
else run.notes.push(note)
|
|
1451
|
+
run.notes = run.notes.slice(-60)
|
|
1452
|
+
this.db.put("runs", run)
|
|
1453
|
+
this.sup.replay?.note(run.projectId, `@${by} saved a note: ${t}`)
|
|
1454
|
+
return note
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Is this card's bot going round in circles? Only calls since the card started count. */
|
|
1458
|
+
async loopCheck(task, usage, now = Date.now()) {
|
|
1459
|
+
if (task.loopQuietUntil && now < task.loopQuietUntil) return null
|
|
1460
|
+
const s = this.sup.store.get(task.assignee)
|
|
1461
|
+
if (!s || s.status !== "busy") return null
|
|
1462
|
+
const calls = (s.toolLog ?? []).filter((c) => (c.at ?? 0) >= (task.startedAt ?? 0))
|
|
1463
|
+
const persona = task.runId ? this.run(task.runId)?.personaDefs?.[task.persona] : null
|
|
1464
|
+
const canEdit = !persona?.tools?.length || persona.tools.some((t) => /edit|write|patch|\*/.test(t))
|
|
1465
|
+
const ceiling = Number(this.sup.config?.loops?.tokensWithoutEdits) > 0 ? Number(this.sup.config.loops.tokensWithoutEdits) : 250_000
|
|
1466
|
+
// Only ask git once the token ceiling is in play, and at most once a minute per card.
|
|
1467
|
+
let edited
|
|
1468
|
+
if (canEdit && usage.tokens > ceiling && task.baseline && s.directory) {
|
|
1469
|
+
this.editedCache ??= new Map()
|
|
1470
|
+
const hit = this.editedCache.get(task.id)
|
|
1471
|
+
if (hit && now - hit.at < 60_000) edited = hit.edited
|
|
1472
|
+
else { edited = await git.changedSince(s.directory, task.baseline).catch(() => undefined); this.editedCache.set(task.id, { at: now, edited }) }
|
|
1473
|
+
}
|
|
1474
|
+
return stuckReason(calls, { tokens: usage.tokens, canEdit, tokenCeiling: ceiling, edited })
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
levelFor(task) {
|
|
1478
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
1479
|
+
const wsId = run?.workspaceId ?? null
|
|
1480
|
+
return this.sup.farmhands?.get(wsId, task.persona ?? "bot")?.level ?? 1
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** A card's limits with the level perks (Seasoned +10%, Veteran +20%) applied. */
|
|
1484
|
+
effectiveLimits(task) {
|
|
1485
|
+
const bonus = budgetBonus(this.levelFor(task))
|
|
1486
|
+
if (!task.limits || !bonus) return task.limits
|
|
1487
|
+
return Object.fromEntries(Object.entries(task.limits).map(([k, v]) => [k, v * (1 + bonus)]))
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
awardXp(task, worker) {
|
|
1491
|
+
if (!this.sup.farmhands || !task.persona || task.owner === "human") return null
|
|
1492
|
+
const run = task.runId ? this.run(task.runId) : null
|
|
1493
|
+
const usage = this.taskUsage(task)
|
|
1494
|
+
let { xp, reasons } = xpForCard({ task, usage, paused: !!task.pausedMs || !!task.autoExtended, openQuestions: task.handoff?.open_questions?.length ?? 0 })
|
|
1495
|
+
// A fix round earns a little, never a full card again: bugs must not pay.
|
|
1496
|
+
// Catching something that needed fixing is worth a little to the checker.
|
|
1497
|
+
if (task.rework && task.round > 1) { xp = 3; reasons = [[`fixed in round ${task.round}`, 3]] }
|
|
1498
|
+
else if (task.rounds && Object.keys(task.rounds).length) { const n = Object.values(task.rounds).reduce((a, b) => a + b, 0); reasons.push([`sent back ${n} time${n > 1 ? "s" : ""} and verified the fix`, 2]); xp += 2 }
|
|
1499
|
+
const out = this.sup.farmhands.award(run?.workspaceId ?? null, task.persona, xp, { reasons, card: task.title })
|
|
1500
|
+
task.xp = { gained: xp, reasons: reasons.map(([r, n]) => `${r} +${n}`) }
|
|
1501
|
+
if (run) {
|
|
1502
|
+
run.xp ??= {}
|
|
1503
|
+
run.xp[task.persona] = (run.xp[task.persona] ?? 0) + xp
|
|
1504
|
+
if (out.levelUp) (run.levelUps ??= []).push({ persona: task.persona, level: out.levelUp.level, hat: out.levelUp.hat, title: out.levelUp.title })
|
|
1505
|
+
this.db.put("runs", run)
|
|
1506
|
+
}
|
|
1507
|
+
if (out.levelUp) {
|
|
1508
|
+
this.sup.store.note(`🎉 ${task.persona} reached level ${out.levelUp.level} — ${out.levelUp.title} ${out.levelUp.hat ?? ""}`, worker?.id ?? null, "info")
|
|
1509
|
+
this.sup.replay?.note(task.projectId, `${task.persona} reached level ${out.levelUp.level} ${out.levelUp.hat ?? ""}`)
|
|
1510
|
+
}
|
|
1511
|
+
return out
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** A finished workstream: totals for the harvest log, the celebration, and its PR packet. */
|
|
1515
|
+
async harvest(run) {
|
|
1516
|
+
const project = this.sup.projects.get(run.projectId)
|
|
1517
|
+
const bots = this.sup.projects.sessions(run.projectId)
|
|
1518
|
+
const used = this.runUsage(run)
|
|
1519
|
+
const cards = this.tasks.all({ runId: run.id }).filter((t) => t.status === "done" && !t.children)
|
|
1520
|
+
const h = {
|
|
1521
|
+
id: run.id,
|
|
1522
|
+
ws: run.workspaceId ?? null,
|
|
1523
|
+
project: run.projectId,
|
|
1524
|
+
name: project?.name ?? run.title,
|
|
1525
|
+
at: Date.now(),
|
|
1526
|
+
minutes: used.minutes,
|
|
1527
|
+
usd: used.usd,
|
|
1528
|
+
tokens: used.tokens,
|
|
1529
|
+
budget: run.limits?.usd ?? null,
|
|
1530
|
+
saved: run.limits?.usd ? run.limits.usd - used.usd : null,
|
|
1531
|
+
cards: cards.length,
|
|
1532
|
+
botMinutes: cards.reduce((a, t) => a + (t.startedAt ? Math.max(0, (t.updated ?? Date.now()) - t.startedAt - (t.pausedMs ?? 0)) : 0), 0) / 60000,
|
|
1533
|
+
xp: Object.values(run.xp ?? {}).reduce((a, n) => a + n, 0),
|
|
1534
|
+
xpBy: run.xp ?? {},
|
|
1535
|
+
levelUps: run.levelUps ?? [],
|
|
1536
|
+
bots: bots.map((s) => ({ handle: s.handle, persona: s.persona })),
|
|
1537
|
+
}
|
|
1538
|
+
this.sup.farmhands?.saveHarvest(h)
|
|
1539
|
+
if (project) this.sup.projects.save({ ...this.sup.projects.get(run.projectId), harvest: h })
|
|
1540
|
+
this.sup.replay?.harvest(run.projectId, h)
|
|
1541
|
+
await this.sup.writePacket(run.projectId).catch(() => {})
|
|
1542
|
+
this.sup.onChange()
|
|
1543
|
+
return h
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// --- budgets ---------------------------------------------------------------
|
|
1547
|
+
|
|
1548
|
+
costOf(session) {
|
|
1549
|
+
if (!session?.cost) return 0
|
|
1550
|
+
return session.cost(this.sup.store.pricing?.() ?? {})?.usd ?? 0
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Minutes, tokens and dollars one card has used since it was picked up. */
|
|
1554
|
+
taskUsage(task, now = Date.now()) {
|
|
1555
|
+
const s = this.sup.store.get(task.assignee)
|
|
1556
|
+
const paused = (task.pausedMs ?? 0) + (task.pausedAt ? now - task.pausedAt : 0)
|
|
1557
|
+
return {
|
|
1558
|
+
minutes: task.startedAt ? Math.max(0, now - task.startedAt - paused) / 60000 : 0,
|
|
1559
|
+
tokens: s ? Math.max(0, tokensOf(s) - (task.tokensAtStart ?? 0)) : 0,
|
|
1560
|
+
usd: s ? Math.max(0, this.costOf(s) - (task.usdAtStart ?? 0)) : 0,
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** What a whole workstream has used: every bot on it, and its time since it started. */
|
|
1565
|
+
runUsage(run, now = Date.now()) {
|
|
1566
|
+
const sessions = this.sup.projects.sessions(run.projectId)
|
|
1567
|
+
const paused = (run.pausedMs ?? 0) + (run.paused?.at ? now - run.paused.at : 0)
|
|
1568
|
+
return {
|
|
1569
|
+
minutes: Math.max(0, now - run.created - paused) / 60000,
|
|
1570
|
+
tokens: sessions.reduce((a, s) => a + tokensOf(s), 0) + (run.retired?.tokens ?? 0),
|
|
1571
|
+
usd: sessions.reduce((a, s) => a + this.costOf(s), 0) + (run.retired?.usd ?? 0),
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Stop anything that has gone past its time, token or dollar limit and ask
|
|
1577
|
+
* the operator. A card over its own limit stops alone; a workstream over its
|
|
1578
|
+
* limit stops every bot on it. Nothing continues until you say so.
|
|
1579
|
+
*/
|
|
1580
|
+
async checkBudgets(now = Date.now()) {
|
|
1581
|
+
await this.checkQuotas().catch((e) => this.sup.store.note(`quota check: ${e.message}`, null, "warn"))
|
|
1582
|
+
for (const run of this.runs()) {
|
|
1583
|
+
if (run.status !== "running" || run.paused) continue
|
|
1584
|
+
const hit = overLimit(run.limits, this.runUsage(run, now))
|
|
1585
|
+
if (hit) { await this.pauseRun(run, hit); continue }
|
|
1586
|
+
}
|
|
1587
|
+
for (const task of this.tasks.all({ status: "active" })) {
|
|
1588
|
+
if (task.children || !task.assignee) continue
|
|
1589
|
+
if (task.runId && this.run(task.runId)?.paused) continue
|
|
1590
|
+
const usage = this.taskUsage(task, now)
|
|
1591
|
+
const loop = await this.loopCheck(task, usage, now)
|
|
1592
|
+
if (loop) { await this.pauseTask(task, loop); continue }
|
|
1593
|
+
if (!task.limits) continue
|
|
1594
|
+
const limits = this.effectiveLimits(task)
|
|
1595
|
+
const hit = overLimit(limits, usage)
|
|
1596
|
+
if (!hit) continue
|
|
1597
|
+
// Trusted bots (level 5) extend themselves once per card before asking.
|
|
1598
|
+
const lvl = this.levelFor(task)
|
|
1599
|
+
if (perksAt(lvl).some((p) => p.kind === "autoextend") && !task.autoExtended) {
|
|
1600
|
+
task.autoExtended = true
|
|
1601
|
+
task.limits = Object.fromEntries(Object.entries(task.limits).map(([k, v]) => [k, k === hit.kind ? Math.max(v, hit.used) * 1.25 : v]))
|
|
1602
|
+
this.tasks.save(task)
|
|
1603
|
+
this.sup.store.note(`"${task.title}" reached its ${limitText(hit)} limit — its bot is Trusted (level ${lvl}), so it got 25% more once`, task.assignee, "info")
|
|
1604
|
+
continue
|
|
1605
|
+
}
|
|
1606
|
+
await this.pauseTask(task, hit)
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
async pauseTask(task, hit) {
|
|
1611
|
+
const s = this.sup.store.get(task.assignee)
|
|
1612
|
+
if (s && (s.status === "busy" || s.status === "waiting")) await this.sup.abort(s.id).catch(() => {})
|
|
1613
|
+
task.status = "review"
|
|
1614
|
+
task.budget = { ...hit, at: Date.now() }
|
|
1615
|
+
task.pausedAt = Date.now()
|
|
1616
|
+
this.tasks.save(task)
|
|
1617
|
+
this.sup.store.note(hit.kind === "loop"
|
|
1618
|
+
? `⏸ @${s?.handle ?? "a bot"} looks stuck on "${task.title}": ${hit.why} — stopped and waiting for you`
|
|
1619
|
+
: `⏸ "${task.title}" reached its ${limitText(hit)} limit — stopped @${s?.handle ?? "its bot"} and waiting for you`, s?.id ?? null, "warn")
|
|
1620
|
+
this.sup.replay?.note(task.projectId, hit.kind === "loop" ? `@${s?.handle} looked stuck: ${hit.why}` : `"${task.title}" hit its ${hit.kind} limit`, "warn")
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/** Give a paused card more room and let its bot carry on (or stop it for good). */
|
|
1624
|
+
async budgetTask(task, { action = "continue", add = null, limits = undefined } = {}) {
|
|
1625
|
+
if (!task.budget) throw new Error("this card is not paused on a limit")
|
|
1626
|
+
const now = Date.now()
|
|
1627
|
+
if (task.budget.kind === "rounds" && action !== "stop") return this.roundsDecision(task, action)
|
|
1628
|
+
if (action === "stop") {
|
|
1629
|
+
task.status = "cancelled"
|
|
1630
|
+
task.budget = { ...task.budget, stopped: true }
|
|
1631
|
+
this.tasks.save(task)
|
|
1632
|
+
this.sup.store.note(`stopped "${task.title}" at its limit`, task.assignee, "warn")
|
|
1633
|
+
if (task.parentId) await this.joinParent(task.parentId)
|
|
1634
|
+
this.tasks.unblock()
|
|
1635
|
+
await this.dispatch()
|
|
1636
|
+
this.checkRunDone(task.runId)
|
|
1637
|
+
return task
|
|
1638
|
+
}
|
|
1639
|
+
if (task.budget.kind === "loop") {
|
|
1640
|
+
// Not a limit: nothing to raise. Leave the loop check quiet for a while
|
|
1641
|
+
// and tell the bot what it looked like.
|
|
1642
|
+
task.loopQuietUntil = now + 10 * 60_000
|
|
1643
|
+
} else if (limits !== undefined) {
|
|
1644
|
+
const next = cleanLimits(limits)
|
|
1645
|
+
const hit = overLimit(next, this.taskUsage({ ...task, pausedAt: null, pausedMs: (task.pausedMs ?? 0) + (task.pausedAt ? now - task.pausedAt : 0) }, now))
|
|
1646
|
+
if (hit) throw new Error(`still over the ${limitText(hit)} limit — raise it past what is used`)
|
|
1647
|
+
task.limits = next
|
|
1648
|
+
} else task.limits = raiseLimits(task.limits, task.budget, add)
|
|
1649
|
+
task.pausedMs = (task.pausedMs ?? 0) + (task.pausedAt ? now - task.pausedAt : 0)
|
|
1650
|
+
task.pausedAt = null
|
|
1651
|
+
const was = task.budget
|
|
1652
|
+
task.budget = null
|
|
1653
|
+
task.status = "active"
|
|
1654
|
+
task.nudgedAt = null
|
|
1655
|
+
this.tasks.save(task)
|
|
1656
|
+
const s = this.sup.store.get(task.assignee)
|
|
1657
|
+
if (s && was.kind === "loop") await this.sup.deliver(s, {
|
|
1658
|
+
text: `[botfarm] You were stopped on task ${task.id} — "${task.title}" — because you looked stuck: ${was.why}. The operator says carry on. Try a different approach rather than repeating the same step; check botfarm_notes_read and ask in the chat if you are missing something.`,
|
|
1659
|
+
description: `continue ${task.title}`,
|
|
1660
|
+
})
|
|
1661
|
+
else if (s) await this.sup.deliver(s, {
|
|
1662
|
+
text: `[botfarm] You were stopped at this card's ${limitText(was)} limit. The operator gave you more room (${limitsText(task.limits)}). Carry on with task ${task.id} — "${task.title}" — from where you left off, and call botfarm_task_complete when it is done.`,
|
|
1663
|
+
description: `continue ${task.title}`,
|
|
1664
|
+
})
|
|
1665
|
+
return task
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async pauseRun(run, hit) {
|
|
1669
|
+
const interrupted = []
|
|
1670
|
+
for (const s of this.sup.projects.sessions(run.projectId)) {
|
|
1671
|
+
if (s.status !== "busy" && s.status !== "waiting") continue
|
|
1672
|
+
await this.sup.abort(s.id).catch(() => {})
|
|
1673
|
+
for (const t of this.tasks.all({ assignee: s.id, status: "active" })) interrupted.push(t.id)
|
|
1674
|
+
}
|
|
1675
|
+
run.paused = { ...hit, at: Date.now(), interrupted }
|
|
1676
|
+
this.db.put("runs", run)
|
|
1677
|
+
this.sup.store.note(hit.kind === "manual" ? `⏸ workstream paused — ${interrupted.length} bot(s) stopped until you resume it` : `⏸ workstream reached its ${limitText(hit)} limit — every bot on it is stopped until you raise it`, null, "warn")
|
|
1678
|
+
this.sup.replay?.note(run.projectId, hit.kind === "manual" ? "paused by you" : `paused at its ${hit.kind} limit`, "warn")
|
|
1679
|
+
this.sup.onChange()
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Raise (or clear) a workstream's limits and pick up where it stopped — or stop it. */
|
|
1683
|
+
async budgetRun(projectId, { action = "continue", limits = undefined } = {}) {
|
|
1684
|
+
const project = this.sup.projects.get(projectId)
|
|
1685
|
+
const run = project?.runId ? this.run(project.runId) : null
|
|
1686
|
+
if (!run) throw new Error("this workstream was not started from a pipeline")
|
|
1687
|
+
const now = Date.now()
|
|
1688
|
+
if (action === "stop") {
|
|
1689
|
+
for (const s of this.sup.projects.sessions(projectId)) if (s.status === "busy") await this.sup.abort(s.id).catch(() => {})
|
|
1690
|
+
for (const t of this.tasks.all({ runId: run.id })) if (!["done", "cancelled"].includes(t.status)) this.tasks.update(t.id, { status: "cancelled" })
|
|
1691
|
+
run.status = "stopped"
|
|
1692
|
+
run.paused = null
|
|
1693
|
+
this.db.put("runs", run)
|
|
1694
|
+
this.sup.projects.save({ ...project, status: "stopped" })
|
|
1695
|
+
this.sup.store.note(`stopped workstream ${project.name}`, null, "warn")
|
|
1696
|
+
return run
|
|
1697
|
+
}
|
|
1698
|
+
if (limits !== undefined) run.limits = cleanLimits(limits)
|
|
1699
|
+
const paused = run.paused
|
|
1700
|
+
if (paused) {
|
|
1701
|
+
// Still over after the change? Say so rather than resume into another stop.
|
|
1702
|
+
const hit = overLimit(run.limits, this.runUsage({ ...run, paused: null, pausedMs: (run.pausedMs ?? 0) + (now - paused.at) }, now))
|
|
1703
|
+
if (hit) throw new Error(`still over the ${limitText(hit)} limit — raise it past what is used`)
|
|
1704
|
+
run.pausedMs = (run.pausedMs ?? 0) + (now - paused.at)
|
|
1705
|
+
run.paused = null
|
|
1706
|
+
}
|
|
1707
|
+
this.db.put("runs", run)
|
|
1708
|
+
if (paused) {
|
|
1709
|
+
for (const id of paused.interrupted ?? []) {
|
|
1710
|
+
const t = this.tasks.get(id)
|
|
1711
|
+
const s = t && this.sup.store.get(t.assignee)
|
|
1712
|
+
if (t?.status !== "active" || !s) continue
|
|
1713
|
+
await this.sup.deliver(s, { text: `[botfarm] The workstream was paused${paused.kind === "manual" ? " by the operator" : ` at its ${limitText(paused)} limit`} and has been resumed. Carry on with task ${t.id} — "${t.title}" — from where you left off.`, description: `resume ${t.title}` }).catch(() => {})
|
|
1714
|
+
}
|
|
1715
|
+
this.sup.store.note(`resumed workstream ${project.name} (${limitsText(run.limits)})`, null, "info")
|
|
1716
|
+
await this.dispatch()
|
|
1717
|
+
}
|
|
1718
|
+
this.sup.onChange()
|
|
1719
|
+
return run
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** For the dashboard: limits, use and pause state of the workstream behind a project. */
|
|
1723
|
+
budgetView(project) {
|
|
1724
|
+
const run = project?.runId ? this.run(project.runId) : null
|
|
1725
|
+
if (!run) return null
|
|
1726
|
+
return { limits: run.limits ?? null, used: this.runUsage(run), paused: run.paused ?? null, status: run.status }
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// --- watchdog --------------------------------------------------------------
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* A session went idle. If it still holds an active task, it most likely
|
|
1733
|
+
* finished its turn without calling botfarm_task_complete — and nothing
|
|
1734
|
+
* downstream will ever start. Remind it once; if it stops again, hand the
|
|
1735
|
+
* task to the operator instead of letting the run sit there forever.
|
|
1736
|
+
*/
|
|
1737
|
+
idle(session, { delay = 5_000 } = {}) {
|
|
1738
|
+
this.idleTimers ??= new Map()
|
|
1739
|
+
clearTimeout(this.idleTimers.get(session.id))
|
|
1740
|
+
this.idleTimers.set(
|
|
1741
|
+
session.id,
|
|
1742
|
+
setTimeout(() => {
|
|
1743
|
+
this.idleTimers.delete(session.id)
|
|
1744
|
+
const fresh = this.sup.store.get(session.id)
|
|
1745
|
+
// Busy again: opencode was only between turns.
|
|
1746
|
+
if (!fresh || fresh.status !== "idle") return
|
|
1747
|
+
this.checkForgotten(fresh).catch((err) => this.sup.store.note(`watchdog: ${err.message}`, session.id, "warn"))
|
|
1748
|
+
}, delay),
|
|
1749
|
+
)
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
async checkForgotten(session, { now = Date.now(), grace = 15_000 } = {}) {
|
|
1753
|
+
const open = this.tasks.all({ assignee: session.id, status: "active" }).filter((t) => t.owner !== "human" && !(t.runId && this.run(t.runId)?.paused))
|
|
1754
|
+
for (const task of open) {
|
|
1755
|
+
if (now - (task.startedAt ?? task.updated) < grace) continue
|
|
1756
|
+
// Handed the card but never spent a token: the turn did not run at all
|
|
1757
|
+
// (wrong model, provider error). A reminder would fail the same way.
|
|
1758
|
+
if (tokensOf(session) === (task.tokensAtStart ?? 0)) {
|
|
1759
|
+
if (!task.noRun) {
|
|
1760
|
+
task.noRun = true
|
|
1761
|
+
this.tasks.save(task)
|
|
1762
|
+
this.sup.store.note(`@${session.handle} was handed "${task.title}" but never ran${session.lastError ? `: ${session.lastError}` : " — see the opencode log"}. Fix it, then Restart the workstream.`, session.id, "error")
|
|
1763
|
+
}
|
|
1764
|
+
continue
|
|
1765
|
+
}
|
|
1766
|
+
if (!task.nudgedAt) {
|
|
1767
|
+
task.nudgedAt = now
|
|
1768
|
+
this.tasks.save(task)
|
|
1769
|
+
await this.sup.deliver(session, {
|
|
1770
|
+
text: [
|
|
1771
|
+
`[botfarm] You stopped, but task ${task.id} (${task.title}) is still open.`,
|
|
1772
|
+
"",
|
|
1773
|
+
"Nothing downstream starts until you hand off. If the work is done, call botfarm_task_complete now with",
|
|
1774
|
+
"your summary, acceptance_criteria, artifacts and open_questions. If you are blocked, call",
|
|
1775
|
+
"botfarm_task_complete with what you have and put the blocker in open_questions, or use botfarm_ask_human.",
|
|
1776
|
+
].join("\n"),
|
|
1777
|
+
description: `reminder: hand off ${task.stage ?? task.title}`,
|
|
1778
|
+
})
|
|
1779
|
+
this.sup.store.note(`@${session.handle} went idle without handing off "${task.title}" — reminded it`, session.id, "warn")
|
|
1780
|
+
continue
|
|
1781
|
+
}
|
|
1782
|
+
if (task.escalated || now - task.nudgedAt < grace) continue
|
|
1783
|
+
task.escalated = true
|
|
1784
|
+
task.status = "review"
|
|
1785
|
+
this.tasks.save(task)
|
|
1786
|
+
this.tasks.create({
|
|
1787
|
+
title: `@${session.handle} stopped without finishing "${task.stage ?? task.title}"`.slice(0, 120),
|
|
1788
|
+
brief: [
|
|
1789
|
+
`@${session.handle} went idle twice with ${task.id} still open, even after a reminder.`,
|
|
1790
|
+
session.lastText ? `Its last words: "${session.lastText}"` : "",
|
|
1791
|
+
"Your answer is sent to it. Or move the task to done on the board to let the run continue.",
|
|
1792
|
+
].filter(Boolean).join("\n\n"),
|
|
1793
|
+
kind: "question",
|
|
1794
|
+
owner: "human",
|
|
1795
|
+
askedBy: session.id,
|
|
1796
|
+
projectId: task.projectId,
|
|
1797
|
+
runId: task.runId,
|
|
1798
|
+
createdBy: "watchdog",
|
|
1799
|
+
})
|
|
1800
|
+
this.sup.store.note(`@${session.handle} is stuck on "${task.title}" — it needs you`, session.id, "error")
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// --- export --------------------------------------------------------------
|
|
1805
|
+
|
|
1806
|
+
/** Turn a finished run back into a pipeline definition you can share. */
|
|
1807
|
+
exportRun(runId) {
|
|
1808
|
+
const run = this.run(runId)
|
|
1809
|
+
if (!run) throw new Error("unknown run")
|
|
1810
|
+
const def = this.defFor(run)
|
|
1811
|
+
const stages = (def?.stages ?? []).map((s) => {
|
|
1812
|
+
const task = this.tasks.get(run.taskIds[s.id])
|
|
1813
|
+
return {
|
|
1814
|
+
...s,
|
|
1815
|
+
notes: task?.handoff?.summary ? `previous run: ${task.handoff.summary.slice(0, 200)}` : undefined,
|
|
1816
|
+
}
|
|
1817
|
+
})
|
|
1818
|
+
return YAML.stringify({
|
|
1819
|
+
id: `${run.pipeline}-from-${run.id.slice(-4)}`,
|
|
1820
|
+
title: def?.title ?? run.pipeline,
|
|
1821
|
+
description: `Exported from run ${run.id}`,
|
|
1822
|
+
stages,
|
|
1823
|
+
})
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** What was said in the chat before this bot's stage started, handed over once. */
|
|
1828
|
+
function earlier(room, session, when = "before your stage started") {
|
|
1829
|
+
if (!room) return ""
|
|
1830
|
+
const ids = room.pending.get(session.id) ?? []
|
|
1831
|
+
if (!ids.length) return ""
|
|
1832
|
+
room.pending.set(session.id, [])
|
|
1833
|
+
room.unread?.set(session.id, 0)
|
|
1834
|
+
const msgs = room.messages.filter((m) => ids.includes(m.id))
|
|
1835
|
+
if (!msgs.length) return ""
|
|
1836
|
+
return [
|
|
1837
|
+
`\n\nSaid in ${room.name} ${when} (lines marked operator are the human running this; @handles are other agents):`,
|
|
1838
|
+
...msgs.map((m) => `${m.from === "operator" ? "operator" : "@" + m.from}: ${m.text}`),
|
|
1839
|
+
].join("\n")
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** Items for a split: strings or { title, detail } objects, from a handoff list. */
|
|
1843
|
+
function normalizeItems(list) {
|
|
1844
|
+
if (!Array.isArray(list)) return []
|
|
1845
|
+
return list.map((x) => {
|
|
1846
|
+
if (typeof x === "string") return x.trim() ? { title: x.trim().slice(0, 200) } : null
|
|
1847
|
+
if (x && typeof x === "object") {
|
|
1848
|
+
const title = String(x.title ?? x.name ?? x.summary ?? "").trim()
|
|
1849
|
+
const detail = String(x.detail ?? x.description ?? x.details ?? "").trim()
|
|
1850
|
+
const diff = ["easy", "normal", "hard"].includes(String(x.difficulty ?? "").toLowerCase()) ? String(x.difficulty).toLowerCase() : null
|
|
1851
|
+
return title ? { title: title.slice(0, 200), ...(detail ? { detail: detail.slice(0, 4000) } : {}), ...(diff ? { difficulty: diff } : {}) } : null
|
|
1852
|
+
}
|
|
1853
|
+
return null
|
|
1854
|
+
}).filter(Boolean)
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const LIMIT_UNITS = { minutes: "time", tokens: "token", usd: "dollar" }
|
|
1858
|
+
function overLimit(limits, used) {
|
|
1859
|
+
if (!limits) return null
|
|
1860
|
+
for (const kind of ["usd", "tokens", "minutes"]) {
|
|
1861
|
+
if (limits[kind] > 0 && used[kind] >= limits[kind]) return { kind, limit: limits[kind], used: used[kind] }
|
|
1862
|
+
}
|
|
1863
|
+
return null
|
|
1864
|
+
}
|
|
1865
|
+
const fmtLimit = (kind, v) => kind === "usd" ? `$${Number(v).toFixed(2)}` : kind === "tokens" ? `${Math.round(v).toLocaleString("en-US")} tokens` : `${Math.round(v)} min`
|
|
1866
|
+
const limitText = (hit) => `${LIMIT_UNITS[hit.kind]} (${fmtLimit(hit.kind, hit.limit)})`
|
|
1867
|
+
const limitsText = (l) => l ? Object.entries(l).map(([k, v]) => fmtLimit(k, v)).join(", ") : "no limits"
|
|
1868
|
+
/** The limit that was hit goes up by what the operator adds, or by half again. */
|
|
1869
|
+
function raiseLimits(limits, hit, add) {
|
|
1870
|
+
const out = { ...(limits ?? {}) }
|
|
1871
|
+
const extra = cleanLimits(add)
|
|
1872
|
+
if (extra) for (const [k, v] of Object.entries(extra)) out[k] = Math.max(out[k] ?? 0, hit.kind === k ? hit.used : 0) + v
|
|
1873
|
+
else if (hit) out[hit.kind] = Math.max(hit.limit, hit.used) * 1.5
|
|
1874
|
+
return cleanLimits(out)
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const clampParallel = (n) => Math.max(1, Math.min(10, Math.round(Number(n) || 1)))
|
|
1878
|
+
|
|
1879
|
+
/** { minutes, tokens, usd } with only positive numbers kept, or null. */
|
|
1880
|
+
export function cleanLimits(l) {
|
|
1881
|
+
if (!l || typeof l !== "object") return null
|
|
1882
|
+
const out = {}
|
|
1883
|
+
for (const k of ["minutes", "tokens", "usd"]) {
|
|
1884
|
+
const v = Number(l[k] ?? (k === "usd" ? l.dollars ?? l.cost : undefined))
|
|
1885
|
+
if (Number.isFinite(v) && v > 0) out[k] = v
|
|
1886
|
+
}
|
|
1887
|
+
return Object.keys(out).length ? out : null
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const tokensOf = (s) => {
|
|
1891
|
+
const t = s?.totals ?? {}
|
|
1892
|
+
return (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function teamEntry(p, name) {
|
|
1896
|
+
if (!p) return null
|
|
1897
|
+
return { agent: p.agent ?? null, model: p.model ?? null, variant: p.variant ?? null, tools: p.tools ?? null, title: p.title ?? name }
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function slugify(s, max = 36) {
|
|
1901
|
+
const full = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
1902
|
+
return (full.length <= max ? full : full.slice(0, max).replace(/-[^-]*$/, "")) || "work"
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function topicFrom(story) {
|
|
1906
|
+
const line = String(story ?? "").split(/\n/).map((l) => l.trim()).find(Boolean) ?? ""
|
|
1907
|
+
if (line.length <= 90) return line
|
|
1908
|
+
return line.slice(0, 90).replace(/\s+\S*$/, "") + "…"
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function stageContextFor(def, persona) {
|
|
1912
|
+
return (def.stages ?? [])
|
|
1913
|
+
.filter((st) => st.persona === persona && st.context)
|
|
1914
|
+
.map((st) => st.context)
|
|
1915
|
+
.join("\n\n")
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
export function checkPersona(doc) {
|
|
1919
|
+
const out = []
|
|
1920
|
+
if (!doc.prompt) out.push("no prompt")
|
|
1921
|
+
if (doc.tools && !Array.isArray(doc.tools)) out.push("tools should be a list")
|
|
1922
|
+
if (doc.tiers && (typeof doc.tiers !== "object" || Object.keys(doc.tiers).some((k) => !["easy", "normal", "hard"].includes(k)))) out.push("tiers has easy, normal and hard, each { model, variant }")
|
|
1923
|
+
return out
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const GENERIC_TASK_PROMPT = `{{story}}
|
|
1927
|
+
|
|
1928
|
+
{{#acceptance_criteria}}Acceptance criteria:
|
|
1929
|
+
{{#acceptance_criteria}} - {{.}}
|
|
1930
|
+
{{/acceptance_criteria}}
|
|
1931
|
+
{{/acceptance_criteria}}
|
|
1932
|
+
{{#received}}
|
|
1933
|
+
From {{persona}} ({{stage}}):
|
|
1934
|
+
{{summary}}
|
|
1935
|
+
{{#artifacts}} changed: {{.}}
|
|
1936
|
+
{{/artifacts}}
|
|
1937
|
+
{{/received}}`
|
|
1938
|
+
|
|
1939
|
+
// Shipped once into ~/.botfarm so there is something to run on day one, and never
|
|
1940
|
+
// overwritten afterwards — they are yours to edit.
|
|
1941
|
+
export const DEFAULTS = {
|
|
1942
|
+
personas: {
|
|
1943
|
+
"product.yaml": `id: product
|
|
1944
|
+
title: Product Bot
|
|
1945
|
+
role: product
|
|
1946
|
+
# A light touch on purpose: this bot polishes criteria, it does not study the
|
|
1947
|
+
# code. Only read-only tools, and a small budget of them.
|
|
1948
|
+
tools: [read, grep, glob, list]
|
|
1949
|
+
# model: anthropic/claude-haiku-4-5 # a smaller model is usually plenty here
|
|
1950
|
+
prompt: |
|
|
1951
|
+
You are the product analyst on this change. Your job is to polish the
|
|
1952
|
+
acceptance criteria you are handed into a list a developer can build against
|
|
1953
|
+
and a tester can verify. You are not the engineer: understanding the code
|
|
1954
|
+
deeply, designing the change and finding edge cases in the implementation
|
|
1955
|
+
are the dev's job, not yours.
|
|
1956
|
+
|
|
1957
|
+
Work quickly:
|
|
1958
|
+
- Start from the story and any criteria it already has. Keep what is there;
|
|
1959
|
+
tighten wording, make each one observable, fill obvious gaps.
|
|
1960
|
+
- Look at the code only to get names right (the endpoint, the entity, the
|
|
1961
|
+
error type) — a handful of searches at most, no deep reading, no running
|
|
1962
|
+
anything. If you catch yourself tracing call chains, stop.
|
|
1963
|
+
- "Login works" is not a criterion. "After SSO login the user lands on /home
|
|
1964
|
+
and the session cookie is SameSite=Lax" is. Four to eight is usually right.
|
|
1965
|
+
- Things you genuinely cannot decide go in open_questions, with choices where
|
|
1966
|
+
there are obvious ones. Do not ask what the dev can find out from the code.
|
|
1967
|
+
|
|
1968
|
+
Then call botfarm_task_complete. You do not write production code.
|
|
1969
|
+
`,
|
|
1970
|
+
"dev.yaml": `id: dev
|
|
1971
|
+
title: Dev Bot
|
|
1972
|
+
role: dev
|
|
1973
|
+
prompt: |
|
|
1974
|
+
You are the engineer on this change.
|
|
1975
|
+
|
|
1976
|
+
You will be handed acceptance criteria. Build the smallest change that
|
|
1977
|
+
satisfies all of them, following the conventions already in the repository.
|
|
1978
|
+
If a criterion is ambiguous or wrong, say so in your handoff rather than
|
|
1979
|
+
guessing quietly — and ask the product bot in the group chat if it blocks you.
|
|
1980
|
+
|
|
1981
|
+
Commit nothing you have not run.
|
|
1982
|
+
`,
|
|
1983
|
+
"qa.yaml": `id: qa
|
|
1984
|
+
title: QA Bot
|
|
1985
|
+
role: qa
|
|
1986
|
+
prompt: |
|
|
1987
|
+
You are the tester on this change.
|
|
1988
|
+
|
|
1989
|
+
You will be handed the criteria and a description of what was built. Verify
|
|
1990
|
+
each criterion against the running code, then write automated tests that
|
|
1991
|
+
would fail if the behaviour regressed. Tests that pass whether or not the
|
|
1992
|
+
feature works are worse than no tests; check that each one fails before the
|
|
1993
|
+
fix and passes after.
|
|
1994
|
+
|
|
1995
|
+
Report every criterion you could not verify. Do not fix the code yourself —
|
|
1996
|
+
raise a task for the dev bot instead.
|
|
1997
|
+
may_spawn: false
|
|
1998
|
+
`,
|
|
1999
|
+
"reviewer.yaml": `id: reviewer
|
|
2000
|
+
title: Double-Check Bot
|
|
2001
|
+
role: reviewer
|
|
2002
|
+
prompt: |
|
|
2003
|
+
You are the final check.
|
|
2004
|
+
|
|
2005
|
+
You will be handed the original story, the acceptance criteria, what was
|
|
2006
|
+
built and what was tested. Your only question is whether the whole chain
|
|
2007
|
+
holds together: does the delivered change actually satisfy the criteria, and
|
|
2008
|
+
do the criteria actually satisfy the story?
|
|
2009
|
+
|
|
2010
|
+
Be specific about gaps. "Criterion 3 is untested: the test asserts the
|
|
2011
|
+
redirect fires but not that the cookie is set" is useful. "Looks good" is
|
|
2012
|
+
not. If something is missing, raise a task for whoever should fix it.
|
|
2013
|
+
`,
|
|
2014
|
+
},
|
|
2015
|
+
pipelines: {
|
|
2016
|
+
"story.yaml": `id: story
|
|
2017
|
+
title: Story to verified change
|
|
2018
|
+
description: Product sharpens the story, dev builds it, QA proves it, a reviewer checks the chain.
|
|
2019
|
+
stages:
|
|
2020
|
+
- id: analyse
|
|
2021
|
+
persona: product
|
|
2022
|
+
title: Polish the acceptance criteria
|
|
2023
|
+
receives: []
|
|
2024
|
+
prompt: |
|
|
2025
|
+
Story:
|
|
2026
|
+
|
|
2027
|
+
{{story}}
|
|
2028
|
+
|
|
2029
|
+
Polish this into acceptance criteria. Keep the criteria it already has,
|
|
2030
|
+
sharpen them, add only what is clearly missing. Glance at the code only
|
|
2031
|
+
to get names right — the dev does the deep reading. Put the list in
|
|
2032
|
+
acceptance_criteria on your handoff; everything downstream is built and
|
|
2033
|
+
tested against it.
|
|
2034
|
+
|
|
2035
|
+
- id: build
|
|
2036
|
+
persona: dev
|
|
2037
|
+
title: Implement the change
|
|
2038
|
+
receives: [analyse]
|
|
2039
|
+
prompt: |
|
|
2040
|
+
Original story:
|
|
2041
|
+
|
|
2042
|
+
{{story}}
|
|
2043
|
+
|
|
2044
|
+
{{#handoffs.analyse}}
|
|
2045
|
+
{{persona}} sharpened it into these criteria:
|
|
2046
|
+
{{#acceptance_criteria}} - {{.}}
|
|
2047
|
+
{{/acceptance_criteria}}
|
|
2048
|
+
|
|
2049
|
+
Their notes: {{summary}}
|
|
2050
|
+
{{#open_questions}}Unresolved: {{.}}
|
|
2051
|
+
{{/open_questions}}
|
|
2052
|
+
{{/handoffs.analyse}}
|
|
2053
|
+
|
|
2054
|
+
Implement it. {{#room}}You can reach the others in {{room}}.{{/room}}
|
|
2055
|
+
|
|
2056
|
+
- id: verify
|
|
2057
|
+
persona: qa
|
|
2058
|
+
title: Test the change and automate the proof
|
|
2059
|
+
receives: [analyse, build]
|
|
2060
|
+
prompt: |
|
|
2061
|
+
{{#handoffs.analyse}}
|
|
2062
|
+
Acceptance criteria:
|
|
2063
|
+
{{#acceptance_criteria}} - {{.}}
|
|
2064
|
+
{{/acceptance_criteria}}
|
|
2065
|
+
{{/handoffs.analyse}}
|
|
2066
|
+
|
|
2067
|
+
{{#handoffs.build}}
|
|
2068
|
+
What was built, per {{persona}}:
|
|
2069
|
+
{{summary}}
|
|
2070
|
+
{{#artifacts}} changed: {{.}}
|
|
2071
|
+
{{/artifacts}}
|
|
2072
|
+
{{/handoffs.build}}
|
|
2073
|
+
|
|
2074
|
+
Verify each criterion and write the automated tests. List in
|
|
2075
|
+
open_questions any criterion you could not verify.
|
|
2076
|
+
|
|
2077
|
+
- id: check
|
|
2078
|
+
persona: reviewer
|
|
2079
|
+
title: Check the chain end to end
|
|
2080
|
+
receives: [analyse, build, verify]
|
|
2081
|
+
prompt: |
|
|
2082
|
+
Original story:
|
|
2083
|
+
|
|
2084
|
+
{{story}}
|
|
2085
|
+
|
|
2086
|
+
{{#received}}
|
|
2087
|
+
--- {{persona}} ({{stage}}) ---
|
|
2088
|
+
{{summary}}
|
|
2089
|
+
{{#open_questions}} open: {{.}}
|
|
2090
|
+
{{/open_questions}}
|
|
2091
|
+
{{/received}}
|
|
2092
|
+
|
|
2093
|
+
Does the delivered change satisfy the criteria, and do the criteria
|
|
2094
|
+
satisfy the story? Raise a task for anything that does not hold.
|
|
2095
|
+
|
|
2096
|
+
# A stage with "human: true" is yours: it lands in Needs you rather than
|
|
2097
|
+
# being dispatched, and the pipeline waits for your answer.
|
|
2098
|
+
- id: signoff
|
|
2099
|
+
human: true
|
|
2100
|
+
title: Sign off before this ships
|
|
2101
|
+
receives: [check]
|
|
2102
|
+
prompt: |
|
|
2103
|
+
{{#handoffs.check}}{{summary}}{{/handoffs.check}}
|
|
2104
|
+
|
|
2105
|
+
Anything you want changed before this ships?
|
|
2106
|
+
`,
|
|
2107
|
+
},
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
export function checkPipelineDoc(doc, personas) {
|
|
2111
|
+
const out = []
|
|
2112
|
+
const stages = doc.stages ?? []
|
|
2113
|
+
if (!stages.length) out.push("no stages")
|
|
2114
|
+
const ids = stages.map((s) => s.id)
|
|
2115
|
+
for (const s of stages) {
|
|
2116
|
+
if (!s.id) out.push("a stage has no id")
|
|
2117
|
+
if (!s.persona && !s.human) out.push(`stage ${s.id}: no persona (or mark it human: true)`)
|
|
2118
|
+
if (s.persona && !s.human && !personas.has(s.persona)) out.push(`stage ${s.id}: no persona called "${s.persona}"`)
|
|
2119
|
+
for (const r of s.receives ?? []) {
|
|
2120
|
+
if (!ids.includes(r)) out.push(`stage ${s.id} receives "${r}", which is not a stage in this pipeline`)
|
|
2121
|
+
}
|
|
2122
|
+
const before = ids.slice(0, ids.indexOf(s.id))
|
|
2123
|
+
if (s.after !== undefined && !Array.isArray(s.after)) out.push(`stage ${s.id}: after must be a list of earlier stage ids`)
|
|
2124
|
+
for (const a of Array.isArray(s.after) ? s.after : []) {
|
|
2125
|
+
if (!before.includes(a)) out.push(`stage ${s.id} is after "${a}", which is not an earlier stage`)
|
|
2126
|
+
}
|
|
2127
|
+
if (s.split && s.human) out.push(`stage ${s.id}: a human stage cannot be split`)
|
|
2128
|
+
if (s.difficulty !== undefined && !["easy", "normal", "hard"].includes(s.difficulty)) out.push(`stage ${s.id}: difficulty must be easy, normal or hard`)
|
|
2129
|
+
if (s.max_rounds !== undefined && !(Number.isInteger(s.max_rounds) && s.max_rounds >= 0 && s.max_rounds <= 20)) out.push(`stage ${s.id}: max_rounds is a whole number from 0 (never send back) to 20`)
|
|
2130
|
+
if (s.send_back !== undefined && s.send_back !== false) {
|
|
2131
|
+
const list = Array.isArray(s.send_back) ? s.send_back : null
|
|
2132
|
+
if (!list) out.push(`stage ${s.id}: send_back is a list of earlier stage ids, or false`)
|
|
2133
|
+
for (const a of list ?? []) if (!before.includes(a)) out.push(`stage ${s.id} can send back to "${a}", which is not an earlier stage`)
|
|
2134
|
+
}
|
|
2135
|
+
if (s.split && typeof s.split === "string" && s.split.includes(".") && !before.includes(s.split.split(".")[0])) out.push(`stage ${s.id} splits on "${s.split}", but ${s.split.split(".")[0]} is not an earlier stage`)
|
|
2136
|
+
if (s.parallel !== undefined && !(Number(s.parallel) >= 1 && Number(s.parallel) <= 10)) out.push(`stage ${s.id}: parallel must be 1–10`)
|
|
2137
|
+
if (s.limits !== undefined && (typeof s.limits !== "object" || !cleanLimits(s.limits))) out.push(`stage ${s.id}: limits needs minutes, tokens and/or usd as positive numbers`)
|
|
2138
|
+
// A prompt referring to a handoff it does not receive renders empty and
|
|
2139
|
+
// the bot silently works with less context than the author intended.
|
|
2140
|
+
for (const v of variables(s.prompt ?? "")) {
|
|
2141
|
+
if (v === "handoffs" && !(s.receives ?? []).length) out.push(`stage ${s.id}: prompt uses handoffs but receives nothing`)
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
return out
|
|
2145
|
+
}
|