@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/briefs/default/build.md +35 -0
  4. package/briefs/default/core.md +52 -0
  5. package/briefs/default/journal.optional.md +7 -0
  6. package/briefs/default/review.md +65 -0
  7. package/briefs/default/routine.md +18 -0
  8. package/briefs/default/screenshots.optional.md +10 -0
  9. package/briefs/default/subagents.optional.md +7 -0
  10. package/docs/cutover.md +121 -0
  11. package/package.json +46 -0
  12. package/src/adapters/gh.ts +77 -0
  13. package/src/adapters/git.ts +82 -0
  14. package/src/adapters/herdr.ts +121 -0
  15. package/src/adapters/run.ts +64 -0
  16. package/src/adopt.ts +47 -0
  17. package/src/brief.ts +124 -0
  18. package/src/check.ts +126 -0
  19. package/src/cli-pause.ts +16 -0
  20. package/src/cli.ts +290 -0
  21. package/src/config.ts +211 -0
  22. package/src/ctx.ts +64 -0
  23. package/src/discover.ts +344 -0
  24. package/src/effects/monitor.ts +148 -0
  25. package/src/effects/spawn.ts +246 -0
  26. package/src/effects/sweep.ts +27 -0
  27. package/src/engine/item.ts +32 -0
  28. package/src/engine/monitor.ts +117 -0
  29. package/src/engine/naming.ts +45 -0
  30. package/src/engine/spawn.ts +101 -0
  31. package/src/engine/sweep.ts +91 -0
  32. package/src/engine/tick.ts +25 -0
  33. package/src/filing.ts +56 -0
  34. package/src/globalstate.ts +228 -0
  35. package/src/journal.ts +13 -0
  36. package/src/kinds/builder.ts +128 -0
  37. package/src/kinds/index.ts +13 -0
  38. package/src/kinds/reviewer.ts +220 -0
  39. package/src/kinds/routine.ts +174 -0
  40. package/src/kinds/shared.ts +49 -0
  41. package/src/kinds/validate.ts +187 -0
  42. package/src/lock.ts +63 -0
  43. package/src/paths.ts +21 -0
  44. package/src/render.ts +42 -0
  45. package/src/router/budget.ts +81 -0
  46. package/src/router/providers/claude.ts +177 -0
  47. package/src/router/providers/codex.ts +120 -0
  48. package/src/router/providers/grok.ts +10 -0
  49. package/src/router/rate.ts +62 -0
  50. package/src/router/route.ts +179 -0
  51. package/src/router/window.ts +39 -0
  52. package/src/runtime/worker.ts +75 -0
  53. package/src/state.ts +128 -0
  54. package/src/status.ts +41 -0
  55. 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
+ }
@@ -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
+ }