@tonoid/agent-loop 1.0.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/LICENSE +21 -0
- package/README.md +378 -0
- package/briefs/default/build.md +35 -0
- package/briefs/default/core.md +52 -0
- package/briefs/default/journal.optional.md +7 -0
- package/briefs/default/review.md +65 -0
- package/briefs/default/routine.md +18 -0
- package/briefs/default/screenshots.optional.md +10 -0
- package/briefs/default/subagents.optional.md +7 -0
- package/docs/cutover.md +121 -0
- package/package.json +46 -0
- package/src/adapters/gh.ts +77 -0
- package/src/adapters/git.ts +82 -0
- package/src/adapters/herdr.ts +121 -0
- package/src/adapters/run.ts +64 -0
- package/src/adopt.ts +47 -0
- package/src/brief.ts +124 -0
- package/src/check.ts +126 -0
- package/src/cli-pause.ts +16 -0
- package/src/cli.ts +290 -0
- package/src/config.ts +211 -0
- package/src/ctx.ts +64 -0
- package/src/discover.ts +344 -0
- package/src/effects/monitor.ts +148 -0
- package/src/effects/spawn.ts +246 -0
- package/src/effects/sweep.ts +27 -0
- package/src/engine/item.ts +32 -0
- package/src/engine/monitor.ts +117 -0
- package/src/engine/naming.ts +45 -0
- package/src/engine/spawn.ts +101 -0
- package/src/engine/sweep.ts +91 -0
- package/src/engine/tick.ts +25 -0
- package/src/filing.ts +56 -0
- package/src/globalstate.ts +228 -0
- package/src/journal.ts +13 -0
- package/src/kinds/builder.ts +128 -0
- package/src/kinds/index.ts +13 -0
- package/src/kinds/reviewer.ts +220 -0
- package/src/kinds/routine.ts +174 -0
- package/src/kinds/shared.ts +49 -0
- package/src/kinds/validate.ts +187 -0
- package/src/lock.ts +63 -0
- package/src/paths.ts +21 -0
- package/src/render.ts +42 -0
- package/src/router/budget.ts +81 -0
- package/src/router/providers/claude.ts +177 -0
- package/src/router/providers/codex.ts +120 -0
- package/src/router/providers/grok.ts +10 -0
- package/src/router/rate.ts +62 -0
- package/src/router/route.ts +179 -0
- package/src/router/window.ts +39 -0
- package/src/runtime/worker.ts +75 -0
- package/src/state.ts +128 -0
- package/src/status.ts +41 -0
- package/src/types.ts +225 -0
package/src/ctx.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Ctx, Config, WorkspaceConfig, Decision, Marks, AccountConfig, AccountUsage, UsageReader } from "./types"
|
|
2
|
+
import type { GlobalStore } from "./globalstate"
|
|
3
|
+
import type { Gh } from "./adapters/gh"
|
|
4
|
+
import type { Git } from "./adapters/git"
|
|
5
|
+
import type { HerdrRead } from "./adapters/herdr"
|
|
6
|
+
import type { LockImpl } from "./lock"
|
|
7
|
+
import { dryMarks } from "./state"
|
|
8
|
+
|
|
9
|
+
export interface CtxOpts {
|
|
10
|
+
workspace: WorkspaceConfig
|
|
11
|
+
// Every discovered workspace, this one included. Account-scoped facts (the
|
|
12
|
+
// in-flight count, and so maxConcurrentPerAccount) are global by spec 7, and
|
|
13
|
+
// one ctx per workspace would shard them: two workspaces would each count to
|
|
14
|
+
// the same cap and the account would get double. Defaults to [workspace].
|
|
15
|
+
workspaces?: WorkspaceConfig[]
|
|
16
|
+
config: Config
|
|
17
|
+
now: Date
|
|
18
|
+
live: boolean
|
|
19
|
+
sleep: (ms: number) => Promise<void>
|
|
20
|
+
lock: LockImpl
|
|
21
|
+
gh: Gh
|
|
22
|
+
gitFor: (repo: string) => Git
|
|
23
|
+
herdr: HerdrRead
|
|
24
|
+
marks: Marks
|
|
25
|
+
global: GlobalStore
|
|
26
|
+
usageFor: UsageReader
|
|
27
|
+
memAvailableMb: () => Promise<number>
|
|
28
|
+
sink: (d: Decision) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function makeCtx(o: CtxOpts): Ctx {
|
|
32
|
+
const memo = new Map<string, Promise<unknown>>()
|
|
33
|
+
const cache = <T,>(key: string, fn: () => Promise<T>): Promise<T> => {
|
|
34
|
+
const hit = memo.get(key)
|
|
35
|
+
if (hit) return hit as Promise<T>
|
|
36
|
+
const p = fn()
|
|
37
|
+
memo.set(key, p)
|
|
38
|
+
return p
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
workspace: o.workspace,
|
|
42
|
+
workspaces: o.workspaces ?? [o.workspace],
|
|
43
|
+
config: o.config,
|
|
44
|
+
now: o.now,
|
|
45
|
+
live: o.live,
|
|
46
|
+
sleep: o.sleep,
|
|
47
|
+
lock: o.lock,
|
|
48
|
+
gh: o.gh,
|
|
49
|
+
git: o.gitFor,
|
|
50
|
+
herdr: o.herdr,
|
|
51
|
+
// Without --live the marks database is read-only too: see dryMarks. Every
|
|
52
|
+
// entry point into the engine builds its context here, so this is the only
|
|
53
|
+
// place the rule has to hold.
|
|
54
|
+
marks: o.live ? o.marks : dryMarks(o.marks),
|
|
55
|
+
global: o.global,
|
|
56
|
+
// One read per account per tick. Several jobs route in one tick, and a
|
|
57
|
+
// second read would also refresh the token a second time.
|
|
58
|
+
usage: (a: AccountConfig): Promise<AccountUsage> =>
|
|
59
|
+
cache(`engine:usage:${a.id}`, () => o.usageFor(a, o.now)),
|
|
60
|
+
memAvailableMb: o.memAvailableMb,
|
|
61
|
+
log: o.sink,
|
|
62
|
+
cache,
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
2
|
+
import { agentLoopHome, resolveFrom } from "./paths"
|
|
3
|
+
import { selects } from "./config"
|
|
4
|
+
import { didYouMean, unknownKey, validateOptions, type Kind } from "./kinds"
|
|
5
|
+
import type { AccountConfig, Config, Job, NamingConfig, WorkspaceConfig } from "./types"
|
|
6
|
+
|
|
7
|
+
// A job name reaches branch names, worktree paths, and herdr tab labels, so it
|
|
8
|
+
// is checked here rather than discovered as a broken ref later.
|
|
9
|
+
export const JOB_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
10
|
+
|
|
11
|
+
// An unknown key is a typo, and every typo below used to parse clean:
|
|
12
|
+
// `naming.mergemethod: merge` left mergeMethod at squash and silently changed
|
|
13
|
+
// how PRs get merged, and `slot: 9` was simply ignored. Spec 3.5 makes `check`
|
|
14
|
+
// the thing that catches this at the commit that broke it.
|
|
15
|
+
const WORKSPACE_KEYS = ["name", "herdrWorkspace", "worktreeBase", "repos", "naming"]
|
|
16
|
+
const NAMING_KEYS = ["labels", "mergeMethod"]
|
|
17
|
+
const LABEL_KEYS = ["claim", "failed", "park", "priority"]
|
|
18
|
+
// `options` is opaque to the loader and validated by the kind; everything else
|
|
19
|
+
// here is engine-level and validated for every kind (spec 3.3).
|
|
20
|
+
const JOB_KEYS = ["kind", "repo", "slots", "order", "model", "requires", "prefer", "distinctFrom", "brief", "options"]
|
|
21
|
+
|
|
22
|
+
export interface LoadOpts {
|
|
23
|
+
kinds: Record<string, Kind>
|
|
24
|
+
accounts: AccountConfig[]
|
|
25
|
+
// False for `check <path>` on a folder with no machine config: there are no
|
|
26
|
+
// accounts to check selectors against, and reporting them all as broken
|
|
27
|
+
// would be worse than saying they were not checked.
|
|
28
|
+
checkSelectors: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
32
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readYaml(path: string): { value: Record<string, unknown>; error?: string } {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = Bun.YAML.parse(readFileSync(path, "utf8"))
|
|
38
|
+
if (!isRecord(parsed)) return { value: {}, error: "must be a mapping" }
|
|
39
|
+
return { value: parsed }
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return { value: {}, error: `is not valid YAML: ${String(e)}` }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function naming(raw: unknown, errs: string[]): NamingConfig {
|
|
46
|
+
const n = isRecord(raw) ? raw : {}
|
|
47
|
+
for (const key of Object.keys(n)) {
|
|
48
|
+
if (!NAMING_KEYS.includes(key)) errs.push(`workspace.yml: naming: ${unknownKey(key, NAMING_KEYS)}`)
|
|
49
|
+
}
|
|
50
|
+
const labels = isRecord(n.labels) ? n.labels : {}
|
|
51
|
+
for (const key of Object.keys(labels)) {
|
|
52
|
+
if (!LABEL_KEYS.includes(key)) errs.push(`workspace.yml: naming.labels: ${unknownKey(key, LABEL_KEYS)}`)
|
|
53
|
+
}
|
|
54
|
+
for (const key of ["claim", "failed", "park"]) {
|
|
55
|
+
// Empty is not "unset with a default": an empty claim label never sticks,
|
|
56
|
+
// so the claim check fails forever and every tick logs "claim did not
|
|
57
|
+
// stick" for every item.
|
|
58
|
+
if (typeof labels[key] !== "string") errs.push(`workspace.yml: naming.labels.${key} is required`)
|
|
59
|
+
else if (labels[key] === "") errs.push(`workspace.yml: naming.labels.${key} must not be empty`)
|
|
60
|
+
}
|
|
61
|
+
// A scalar here silently became [], dropping the builder's priority ordering.
|
|
62
|
+
if (labels.priority !== undefined && labels.priority !== null
|
|
63
|
+
&& !(Array.isArray(labels.priority) && labels.priority.every((x) => typeof x === "string"))) {
|
|
64
|
+
errs.push("workspace.yml: naming.labels.priority must be a list of strings")
|
|
65
|
+
}
|
|
66
|
+
const merge = n.mergeMethod ?? "squash"
|
|
67
|
+
if (merge !== "merge" && merge !== "squash") {
|
|
68
|
+
errs.push(`workspace.yml: naming.mergeMethod must be merge or squash`)
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
labels: {
|
|
72
|
+
claim: String(labels.claim ?? ""),
|
|
73
|
+
failed: String(labels.failed ?? ""),
|
|
74
|
+
park: String(labels.park ?? ""),
|
|
75
|
+
priority: Array.isArray(labels.priority) ? labels.priority.map(String) : [],
|
|
76
|
+
},
|
|
77
|
+
mergeMethod: merge === "merge" ? "merge" : "squash",
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadJob(
|
|
82
|
+
dir: string,
|
|
83
|
+
name: string,
|
|
84
|
+
repos: Record<string, string>,
|
|
85
|
+
o: LoadOpts,
|
|
86
|
+
errs: string[],
|
|
87
|
+
): Job | null {
|
|
88
|
+
const at = `${name}/job.yml`
|
|
89
|
+
// Errors accumulate into the workspace's list, so this job's own success is
|
|
90
|
+
// measured against where that list started, not whether it is empty.
|
|
91
|
+
const before = errs.length
|
|
92
|
+
if (!JOB_NAME.test(name)) {
|
|
93
|
+
errs.push(`${name}: a job folder name must match ${JOB_NAME.source}, because it names branches and worktrees`)
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
const { value: raw, error } = readYaml(`${dir}/${name}/job.yml`)
|
|
97
|
+
if (error) { errs.push(`${at} ${error}`); return null }
|
|
98
|
+
|
|
99
|
+
for (const key of Object.keys(raw)) {
|
|
100
|
+
if (!JOB_KEYS.includes(key)) errs.push(`${at}: ${unknownKey(key, JOB_KEYS)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (raw.kind === undefined || raw.kind === null || raw.kind === "") {
|
|
104
|
+
errs.push(`${at}: kind is required, and must name a shipped kind`)
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const kindName = String(raw.kind)
|
|
108
|
+
const kind = o.kinds[kindName]
|
|
109
|
+
if (!kind) {
|
|
110
|
+
const known = Object.keys(o.kinds)
|
|
111
|
+
const near = didYouMean(kindName, known)
|
|
112
|
+
errs.push(
|
|
113
|
+
near ? `${at}: unknown kind "${kindName}"; did you mean "${near}"?`
|
|
114
|
+
: known.length ? `${at}: unknown kind "${kindName}"; known: ${known.join(", ")}`
|
|
115
|
+
: `${at}: unknown kind "${kindName}"; no kinds are registered`,
|
|
116
|
+
)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const repo = typeof raw.repo === "string" ? raw.repo : ""
|
|
121
|
+
if (!repo) {
|
|
122
|
+
errs.push(`${at}: repo is required, and must be a key of workspace.yml repos`)
|
|
123
|
+
} else if (!(repo in repos)) {
|
|
124
|
+
const near = didYouMean(repo, Object.keys(repos))
|
|
125
|
+
errs.push(
|
|
126
|
+
near
|
|
127
|
+
? `${at}: repo "${repo}" is not a key of repos; did you mean "${near}"?`
|
|
128
|
+
: `${at}: repo "${repo}" is not a key of repos; known: ${Object.keys(repos).join(", ")}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const selectors = (field: "requires" | "prefer"): string[] => {
|
|
133
|
+
const v = raw[field]
|
|
134
|
+
if (v === undefined) return []
|
|
135
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
|
|
136
|
+
errs.push(`${at}: ${field} must be a list of account selectors`)
|
|
137
|
+
return []
|
|
138
|
+
}
|
|
139
|
+
if (o.checkSelectors) {
|
|
140
|
+
for (const sel of v as string[]) {
|
|
141
|
+
if (!o.accounts.some((a) => selects(a, sel))) {
|
|
142
|
+
errs.push(`${at}: ${field} "${sel}", which matches no account`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return v as string[]
|
|
147
|
+
}
|
|
148
|
+
const requires = selectors("requires")
|
|
149
|
+
const prefer = selectors("prefer")
|
|
150
|
+
|
|
151
|
+
// The brief config is engine-level: the loader resolves and checks it, the
|
|
152
|
+
// kind renders it. `append` resolves against the job folder, so a versioned
|
|
153
|
+
// folder carries its own prose and travels with it.
|
|
154
|
+
let brief: { extends?: string; append?: string } | undefined
|
|
155
|
+
if (raw.brief !== undefined) {
|
|
156
|
+
if (!isRecord(raw.brief)) {
|
|
157
|
+
errs.push(`${at}: brief must be a mapping of extends and append`)
|
|
158
|
+
} else {
|
|
159
|
+
brief = {}
|
|
160
|
+
const ext = raw.brief.extends
|
|
161
|
+
if (ext !== undefined) {
|
|
162
|
+
if (typeof ext !== "string") errs.push(`${at}: brief.extends must be a name like default/build`)
|
|
163
|
+
else brief.extends = ext
|
|
164
|
+
}
|
|
165
|
+
const append = raw.brief.append
|
|
166
|
+
if (append !== undefined) {
|
|
167
|
+
if (typeof append !== "string") {
|
|
168
|
+
errs.push(`${at}: brief.append must be a path`)
|
|
169
|
+
} else {
|
|
170
|
+
const path = resolveFrom(`${dir}/${name}`, append)
|
|
171
|
+
if (!existsSync(path)) errs.push(`${at}: brief.append "${append}" does not exist`)
|
|
172
|
+
brief.append = path
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reported the way every other field here is: `slots: "4"` silently became
|
|
179
|
+
// 1 and `order: "10"` silently became 100, and both are load-bearing.
|
|
180
|
+
if (raw.slots !== undefined && typeof raw.slots !== "number") errs.push(`${at}: slots must be a number`)
|
|
181
|
+
if (raw.order !== undefined && typeof raw.order !== "number") errs.push(`${at}: order must be a number`)
|
|
182
|
+
if (raw.distinctFrom !== undefined && typeof raw.distinctFrom !== "boolean") {
|
|
183
|
+
errs.push(`${at}: distinctFrom must be true or false`)
|
|
184
|
+
}
|
|
185
|
+
if (raw.model !== undefined && (typeof raw.model !== "string" || !raw.model)) {
|
|
186
|
+
errs.push(`${at}: model must be the agent's own model name, like opus or sonnet`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const options = isRecord(raw.options) ? raw.options : {}
|
|
190
|
+
if (raw.options !== undefined && !isRecord(raw.options)) {
|
|
191
|
+
errs.push(`${at}: options must be a mapping`)
|
|
192
|
+
}
|
|
193
|
+
const { errors: optionErrors, value } = validateOptions(kind, options)
|
|
194
|
+
for (const e of optionErrors) errs.push(`${at}: ${e}`)
|
|
195
|
+
// The kind's own checks run against the validated values, so a check never
|
|
196
|
+
// sees a missing default or a wrong type it would have to re-test.
|
|
197
|
+
if (kind.check && optionErrors.length === 0) {
|
|
198
|
+
for (const e of kind.check({ name, dir: `${dir}/${name}`, repo, brief, options: value })) {
|
|
199
|
+
errs.push(`${at}: ${e}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (errs.length > before) return null
|
|
204
|
+
|
|
205
|
+
const jobDir = `${dir}/${name}`
|
|
206
|
+
const built = kind.build({ name, dir: jobDir, repo, brief, options: value })
|
|
207
|
+
// The engine-level fields are the loader's, whatever the kind returned for
|
|
208
|
+
// them: they are validated here, so they are authoritative here.
|
|
209
|
+
return {
|
|
210
|
+
...built,
|
|
211
|
+
name,
|
|
212
|
+
dir: jobDir,
|
|
213
|
+
repo,
|
|
214
|
+
slots: typeof raw.slots === "number" ? raw.slots : built.slots,
|
|
215
|
+
order: typeof raw.order === "number" ? raw.order : 100,
|
|
216
|
+
model: typeof raw.model === "string" && raw.model ? raw.model : built.model,
|
|
217
|
+
requires: requires.length ? requires : undefined,
|
|
218
|
+
prefer: prefer.length ? prefer : undefined,
|
|
219
|
+
distinctFrom: raw.distinctFrom === true ? true : built.distinctFrom,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function loadWorkspace(dir: string, o: LoadOpts): { ws?: WorkspaceConfig; errors: string[] } {
|
|
224
|
+
const errs: string[] = []
|
|
225
|
+
if (!existsSync(`${dir}/workspace.yml`)) return { errors: [`${dir}: no workspace.yml`] }
|
|
226
|
+
|
|
227
|
+
const { value: raw, error } = readYaml(`${dir}/workspace.yml`)
|
|
228
|
+
if (error) return { errors: [`${dir}: workspace.yml ${error}`] }
|
|
229
|
+
|
|
230
|
+
const name = String(raw.name ?? "")
|
|
231
|
+
if (!JOB_NAME.test(name)) {
|
|
232
|
+
errs.push(`workspace.yml: name "${name}" must match ${JOB_NAME.source}, because it names the state directory`)
|
|
233
|
+
}
|
|
234
|
+
if (typeof raw.herdrWorkspace !== "string" || !raw.herdrWorkspace) {
|
|
235
|
+
errs.push("workspace.yml: herdrWorkspace is required")
|
|
236
|
+
}
|
|
237
|
+
if (typeof raw.worktreeBase !== "string" || !raw.worktreeBase) {
|
|
238
|
+
errs.push("workspace.yml: worktreeBase is required")
|
|
239
|
+
}
|
|
240
|
+
const repos: Record<string, string> = {}
|
|
241
|
+
if (!isRecord(raw.repos) || Object.keys(raw.repos).length === 0) {
|
|
242
|
+
errs.push("workspace.yml: repos must name at least one repository")
|
|
243
|
+
} else {
|
|
244
|
+
for (const [key, path] of Object.entries(raw.repos)) {
|
|
245
|
+
if (typeof path !== "string") errs.push(`workspace.yml: repos.${key} must be a path`)
|
|
246
|
+
else repos[key] = resolveFrom(dir, path)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const namingConfig = naming(raw.naming, errs)
|
|
250
|
+
|
|
251
|
+
for (const key of Object.keys(raw)) {
|
|
252
|
+
if (!WORKSPACE_KEYS.includes(key)) errs.push(`workspace.yml: ${unknownKey(key, WORKSPACE_KEYS)}`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const jobs: Job[] = []
|
|
256
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
257
|
+
if (!entry.isDirectory()) continue
|
|
258
|
+
if (!existsSync(`${dir}/${entry.name}/job.yml`)) continue
|
|
259
|
+
const job = loadJob(dir, entry.name, repos, o, errs)
|
|
260
|
+
if (job) jobs.push(job)
|
|
261
|
+
}
|
|
262
|
+
// Cross-job, so it belongs here rather than in a kind: a kind's own check
|
|
263
|
+
// sees one job.yml and cannot know what else the workspace holds. Left to
|
|
264
|
+
// run time, a typo here reaches filingBudget() only under --live, after the
|
|
265
|
+
// item is claimed, and tombstones a real pull request with the failed label.
|
|
266
|
+
const names = jobs.map((j) => j.name)
|
|
267
|
+
for (const job of jobs) {
|
|
268
|
+
const queue = job.filing?.queue
|
|
269
|
+
if (!queue || names.includes(queue)) continue
|
|
270
|
+
const near = didYouMean(queue, names)
|
|
271
|
+
errs.push(
|
|
272
|
+
near
|
|
273
|
+
? `${job.name}/job.yml: options.filing.queue "${queue}" names no job in this workspace; did you mean "${near}"?`
|
|
274
|
+
: `${job.name}/job.yml: options.filing.queue "${queue}" names no job in this workspace; known: ${names.join(", ")}`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Order carries meaning: reviewers before builders, so a merge in this tick
|
|
279
|
+
// relieves the builder's review-debt throttle in the same tick.
|
|
280
|
+
jobs.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.name.localeCompare(b.name))
|
|
281
|
+
|
|
282
|
+
if (errs.length) return { errors: errs }
|
|
283
|
+
return {
|
|
284
|
+
ws: {
|
|
285
|
+
name,
|
|
286
|
+
dir,
|
|
287
|
+
herdrWorkspace: String(raw.herdrWorkspace),
|
|
288
|
+
worktreeBase: resolveFrom(dir, String(raw.worktreeBase)),
|
|
289
|
+
repos,
|
|
290
|
+
naming: namingConfig,
|
|
291
|
+
journalPath: `${agentLoopHome()}/${name}/journal.md`,
|
|
292
|
+
jobs,
|
|
293
|
+
},
|
|
294
|
+
errors: [],
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function discover(
|
|
299
|
+
config: Config,
|
|
300
|
+
o: LoadOpts,
|
|
301
|
+
): { workspaces: WorkspaceConfig[]; errors: string[] } {
|
|
302
|
+
const errors: string[] = []
|
|
303
|
+
const loaded: WorkspaceConfig[] = []
|
|
304
|
+
|
|
305
|
+
for (const dir of config.workspaces) {
|
|
306
|
+
// A workspace folder can be unreadable (mode 111, a dead mount, ELOOP, a
|
|
307
|
+
// checkout owned by another uid) and a kind's build() can throw, and
|
|
308
|
+
// neither may stop the rest of the box: only a bad config.yml aborts a
|
|
309
|
+
// tick (spec 3.4). The reason is pushed into the errors the caller
|
|
310
|
+
// reports every tick, so it is contained rather than silenced.
|
|
311
|
+
try {
|
|
312
|
+
const { ws, errors: errs } = loadWorkspace(dir, o)
|
|
313
|
+
for (const e of errs) errors.push(e.startsWith(dir) ? e : `${dir}: ${e}`)
|
|
314
|
+
if (ws) loaded.push(ws)
|
|
315
|
+
} catch (e) {
|
|
316
|
+
errors.push(`${dir}: ${String(e)}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A repo in two workspaces collides on .git/worktrees and index.lock, and a
|
|
321
|
+
// duplicate name collides on the state directory. Neither claimant is
|
|
322
|
+
// knowably the right one, so both stand down and say so.
|
|
323
|
+
const bad = new Set<string>()
|
|
324
|
+
const byName = new Map<string, string[]>()
|
|
325
|
+
const byRepo = new Map<string, string[]>()
|
|
326
|
+
for (const ws of loaded) {
|
|
327
|
+
byName.set(ws.name, [...(byName.get(ws.name) ?? []), ws.dir])
|
|
328
|
+
for (const repo of Object.values(ws.repos)) {
|
|
329
|
+
byRepo.set(repo, [...(byRepo.get(repo) ?? []), ws.dir])
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const [name, dirs] of byName) {
|
|
333
|
+
if (dirs.length < 2) continue
|
|
334
|
+
errors.push(`workspace name "${name}" is used by more than one folder: ${dirs.join(", ")}`)
|
|
335
|
+
for (const d of dirs) bad.add(d)
|
|
336
|
+
}
|
|
337
|
+
for (const [repo, dirs] of byRepo) {
|
|
338
|
+
if (dirs.length < 2) continue
|
|
339
|
+
errors.push(`repo "${repo}" is claimed by more than one workspace: ${dirs.join(", ")}`)
|
|
340
|
+
for (const d of dirs) bad.add(d)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { workspaces: loaded.filter((w) => !bad.has(w.dir)), errors }
|
|
344
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Ctx, Job, WorkItem } from "../types"
|
|
2
|
+
import { worktreePath, matchesCwd } from "../engine/naming"
|
|
3
|
+
import { itemKind, repoOf, trackerless } from "../engine/item"
|
|
4
|
+
import { startWorker } from "../runtime/worker"
|
|
5
|
+
import { preClean } from "./spawn"
|
|
6
|
+
|
|
7
|
+
// The same write as the spawn rollback's. A finished item keeps its claim
|
|
8
|
+
// label otherwise, and discoverClaimed reads --state all, so the label would
|
|
9
|
+
// count against the job's slots forever.
|
|
10
|
+
export { unclaim as applyDone } from "./spawn"
|
|
11
|
+
|
|
12
|
+
// Panes are resolved by cwd on every use: herdr ids are not stable, and the
|
|
13
|
+
// same logical worker has been observed at four different pane ids across
|
|
14
|
+
// four review rounds.
|
|
15
|
+
export async function paneFor(ctx: Ctx, p: Job, key: string): Promise<string> {
|
|
16
|
+
const wt = worktreePath(ctx.workspace.worktreeBase, p.name, key)
|
|
17
|
+
const panes = await ctx.cache("engine:panes", () => ctx.herdr.panes())
|
|
18
|
+
const pane = panes.find((x) => matchesCwd(x.cwd, wt))
|
|
19
|
+
if (!pane) throw new Error(`no pane at ${wt}`)
|
|
20
|
+
return pane.paneId
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The one moment a human is actually wanted: a worker sitting on a question
|
|
24
|
+
// nobody will answer. Gated by the "blocked" mark in the engine, so it is once
|
|
25
|
+
// per item and not once per tick, and it never throws: a missing notifier must
|
|
26
|
+
// not turn a supervised block into a failed pass.
|
|
27
|
+
export async function applyNotifyBlocked(
|
|
28
|
+
ctx: Ctx,
|
|
29
|
+
p: Job,
|
|
30
|
+
item: WorkItem,
|
|
31
|
+
key: string,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const where = item.url ?? `tab ${p.name}-${key}`
|
|
34
|
+
await ctx.herdr
|
|
35
|
+
.notify(
|
|
36
|
+
`${p.name} is blocked`,
|
|
37
|
+
`${p.name} ${key} is waiting on an answer nobody has given it. ${where}`,
|
|
38
|
+
)
|
|
39
|
+
.catch(() => {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function applyNudge(ctx: Ctx, p: Job, item: WorkItem, key: string): Promise<void> {
|
|
43
|
+
const text = p.nudge
|
|
44
|
+
? await p.nudge(ctx, item)
|
|
45
|
+
: `You are still working ${key} and the loop sees no progress. Report where you are, then continue or stop.`
|
|
46
|
+
await ctx.herdr.agentPrompt(await paneFor(ctx, p, key), text)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function applyEscalate(ctx: Ctx, p: Job, item: WorkItem, key: string): Promise<void> {
|
|
50
|
+
const labels = ctx.workspace.naming.labels
|
|
51
|
+
if (!p.escalate) {
|
|
52
|
+
if (trackerless(item)) {
|
|
53
|
+
// Nothing to park and no human to address, and the engine has already
|
|
54
|
+
// logged the escalation and cleared the blocked mark, so returning here
|
|
55
|
+
// would loop forever: blocked, wait the timeout, escalate nothing. The
|
|
56
|
+
// verdict goes to the job's own onFail, which is what writes the journal.
|
|
57
|
+
if (p.onFail) {
|
|
58
|
+
await p.onFail(ctx, item, "the worker was blocked past the escalation timeout and there is nothing to park")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
// With no onFail either there is nowhere at all to put a verdict. This
|
|
62
|
+
// does nothing, and says so rather than claiming another tier handles it.
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
// Without a hook the agent cannot be asked to park itself, so the loop
|
|
66
|
+
// parks the item directly. Freeing the slot is the point: a blocked worker
|
|
67
|
+
// on a single-slot job otherwise means zero throughput until a human
|
|
68
|
+
// wakes up.
|
|
69
|
+
await ctx.gh.label(repoOf(item), itemKind(item), item.number, {
|
|
70
|
+
add: [labels.park],
|
|
71
|
+
remove: [labels.claim],
|
|
72
|
+
})
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
await ctx.herdr.agentPrompt(await paneFor(ctx, p, key), await p.escalate(ctx, item))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Enough to carry a stack trace and the command that produced it. This tail is
|
|
79
|
+
// the only post-mortem the operator gets.
|
|
80
|
+
export const FAIL_TAIL_LINES = 60
|
|
81
|
+
|
|
82
|
+
function accountFor(ctx: Ctx, p: Job, key: string) {
|
|
83
|
+
const id = ctx.global.accountFor(ctx.workspace.name, p.name, key)
|
|
84
|
+
return ctx.config.accounts.find((a) => a.id === id) ?? null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function applyRestart(ctx: Ctx, p: Job, item: WorkItem, key: string): Promise<void> {
|
|
88
|
+
const pane = await paneFor(ctx, p, key)
|
|
89
|
+
const account = accountFor(ctx, p, key)
|
|
90
|
+
// The tab's --env already points this pane at an account's config directory
|
|
91
|
+
// and cannot be changed now, so the restart reuses that account's kind and
|
|
92
|
+
// start args. With no spawns row there is nothing to reuse: guessing a kind
|
|
93
|
+
// would start some other provider's agent against the box's default config,
|
|
94
|
+
// outside the router's accounting entirely. Throwing hands the item to the
|
|
95
|
+
// next tick's fail tier instead.
|
|
96
|
+
if (!account) throw new Error(`no account for ${p.name} ${key}: refusing to guess a restart kind`)
|
|
97
|
+
await startWorker(ctx, {
|
|
98
|
+
pane,
|
|
99
|
+
kind: account.agentKind ?? account.provider,
|
|
100
|
+
name: `${p.name}-${key}`,
|
|
101
|
+
args: account.startArgs ?? [],
|
|
102
|
+
brief: await p.brief(ctx, item),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function applyFail(ctx: Ctx, p: Job, item: WorkItem, key: string): Promise<void> {
|
|
107
|
+
// Read the tail before anything else: closing in on the item can cost the
|
|
108
|
+
// pane, and a post-mortem with no transcript is most of the value gone.
|
|
109
|
+
let tail = ""
|
|
110
|
+
try {
|
|
111
|
+
tail = await ctx.herdr.agentRead(await paneFor(ctx, p, key), FAIL_TAIL_LINES)
|
|
112
|
+
} catch {
|
|
113
|
+
tail = "(no transcript: the pane was already gone)"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (p.onFail) {
|
|
117
|
+
await p.onFail(ctx, item, tail)
|
|
118
|
+
// A tracked item's failure is terminal because the failed label takes it
|
|
119
|
+
// out of discovery. A trackerless one has no label to apply: its worktree
|
|
120
|
+
// is its claim, so the worktree is what has to go. Without this the monitor
|
|
121
|
+
// finds the same finished-but-not-done worker on the next tick and fails it
|
|
122
|
+
// again, once every tick until the occurrence rolls, each one a fresh
|
|
123
|
+
// journal line about a run that ended hours ago.
|
|
124
|
+
if (trackerless(item)) await preClean(ctx, p, key)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (trackerless(item)) {
|
|
129
|
+
// A job whose items have no tracker representation owes an onFail; without
|
|
130
|
+
// one there is nowhere to put a verdict, and silently labelling nothing
|
|
131
|
+
// would look like success.
|
|
132
|
+
throw new Error(`job "${p.name}" item ${item.id} has no tracker and no onFail to report to`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const labels = ctx.workspace.naming.labels
|
|
136
|
+
const repo = repoOf(item)
|
|
137
|
+
const kind = itemKind(item)
|
|
138
|
+
// The label comes first. Without it the item is re-picked and re-spawned
|
|
139
|
+
// every tick forever, and a comment that fails to post must not leave the
|
|
140
|
+
// item live.
|
|
141
|
+
await ctx.gh.label(repo, kind, item.number, { add: [labels.failed], remove: [labels.claim] })
|
|
142
|
+
await ctx.gh.comment(
|
|
143
|
+
repo,
|
|
144
|
+
kind,
|
|
145
|
+
item.number,
|
|
146
|
+
`The loop could not finish this item and has stopped working it.\n\nLast ${FAIL_TAIL_LINES} lines of the worker's transcript:\n\n\`\`\`\n${tail}\n\`\`\`\n\nRemove the \`${labels.failed}\` label to let the loop retry it.`,
|
|
147
|
+
)
|
|
148
|
+
}
|