@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
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { Ctx, FilingConfig, Job, WorkItem } from "../types"
|
|
2
|
+
import { type Kind, oneOf, unknownKey } from "./validate"
|
|
3
|
+
import { issues, prs, unblocked } from "./shared"
|
|
4
|
+
import { renderBrief } from "../brief"
|
|
5
|
+
import { filingBudget } from "../filing"
|
|
6
|
+
import { repoOf } from "../engine/item"
|
|
7
|
+
|
|
8
|
+
interface Options {
|
|
9
|
+
identity: string
|
|
10
|
+
headRef: string
|
|
11
|
+
rounds: number
|
|
12
|
+
commentPrefix: string
|
|
13
|
+
mergeMode: string
|
|
14
|
+
passLabel: string
|
|
15
|
+
filing?: Record<string, unknown>
|
|
16
|
+
deleteRemote: boolean
|
|
17
|
+
copyIntoWorktree: string[]
|
|
18
|
+
journal: boolean
|
|
19
|
+
subagents: boolean
|
|
20
|
+
screenshots: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const IDENTITIES = ["pr", "closing-issue", "head-ref-issue"]
|
|
24
|
+
const MERGE_MODES = ["merge", "squash", "none"]
|
|
25
|
+
const FILING_KEYS = ["queue", "maxOpen", "perRound", "dedupeBy"]
|
|
26
|
+
|
|
27
|
+
// "build/b12" and "build/b12-a-title-slug" both name issue 12. Anchored on the
|
|
28
|
+
// separator so "b4" cannot be read out of "b48".
|
|
29
|
+
const HEAD_ISSUE = /(?:^|\/)b(\d+)(?:-|$)/
|
|
30
|
+
|
|
31
|
+
export const reviewer: Kind = {
|
|
32
|
+
name: "reviewer",
|
|
33
|
+
workload: "reviewer",
|
|
34
|
+
fields: [
|
|
35
|
+
{ name: "identity", type: "string", default: "pr", doc: "what a review is keyed on: pr, closing-issue, or head-ref-issue" },
|
|
36
|
+
{ name: "headRef", type: "string", default: "", doc: "only pull requests whose head branch starts with this; empty for every one" },
|
|
37
|
+
{ name: "rounds", type: "number", default: 0, doc: "advisory cap on review rounds, passed to the brief, 0 for none" },
|
|
38
|
+
{ name: "commentPrefix", type: "string", default: "Review round", doc: "the prefix a round comment starts with" },
|
|
39
|
+
{ name: "mergeMode", type: "string", default: "", doc: "merge, squash, none, or empty for the workspace's mergeMethod" },
|
|
40
|
+
{ name: "passLabel", type: "string", default: "", doc: "a label that releases the slot while the pull request stays open" },
|
|
41
|
+
{ name: "filing", type: "object", doc: "backpressure: queue, maxOpen, perRound, dedupeBy" },
|
|
42
|
+
{ name: "deleteRemote", type: "boolean", default: false, doc: "delete the branch on sweep; a review branch is never pushed" },
|
|
43
|
+
{ name: "copyIntoWorktree", type: "string[]", default: [], doc: "files copied from the repository into a fresh worktree" },
|
|
44
|
+
{ name: "journal", type: "boolean", default: false, doc: "ask the worker to append one line to the journal" },
|
|
45
|
+
{ name: "subagents", type: "boolean", default: false, doc: "allow parallel review subagents" },
|
|
46
|
+
{ name: "screenshots", type: "boolean", default: false, doc: "allow screenshots on an orphan asset branch" },
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
// Three reviewer variants in production differ in six substantive ways, and
|
|
50
|
+
// every one of them is a state-machine switch rather than a cosmetic. A typo
|
|
51
|
+
// in one of these parses clean and changes what the loop does with a merge,
|
|
52
|
+
// so none of them may be validated by shape alone.
|
|
53
|
+
check(spec) {
|
|
54
|
+
const o = spec.options as unknown as Options
|
|
55
|
+
const errs = [...oneOf("identity", String(o.identity ?? ""), IDENTITIES)]
|
|
56
|
+
if (o.mergeMode && !MERGE_MODES.includes(o.mergeMode)) {
|
|
57
|
+
errs.push("options.mergeMode must be one of merge, squash, none, or empty for the workspace default")
|
|
58
|
+
}
|
|
59
|
+
// Nothing merges and nothing releases the claim, so the item is claimed
|
|
60
|
+
// forever and the slot never comes back.
|
|
61
|
+
if (o.mergeMode === "none" && !o.passLabel) {
|
|
62
|
+
errs.push("options.mergeMode none requires a passLabel, which is the only thing left that releases the item")
|
|
63
|
+
}
|
|
64
|
+
if (o.filing) {
|
|
65
|
+
for (const key of Object.keys(o.filing)) {
|
|
66
|
+
if (!FILING_KEYS.includes(key)) errs.push(`options.filing: ${unknownKey(key, FILING_KEYS)}`)
|
|
67
|
+
}
|
|
68
|
+
if (typeof o.filing.queue !== "string" || !o.filing.queue) {
|
|
69
|
+
errs.push("options.filing.queue is required and must name the consumer job")
|
|
70
|
+
}
|
|
71
|
+
for (const key of ["maxOpen", "perRound"]) {
|
|
72
|
+
if (typeof o.filing[key] !== "number") errs.push(`options.filing.${key} is required and must be a number`)
|
|
73
|
+
}
|
|
74
|
+
if (o.filing.dedupeBy !== undefined && typeof o.filing.dedupeBy !== "string") {
|
|
75
|
+
errs.push("options.filing.dedupeBy must be a string")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return errs
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
build(spec) {
|
|
82
|
+
const o = spec.options as unknown as Options
|
|
83
|
+
const optional = [
|
|
84
|
+
o.journal ? "journal" : "",
|
|
85
|
+
o.subagents ? "subagents" : "",
|
|
86
|
+
o.screenshots ? "screenshots" : "",
|
|
87
|
+
].filter(Boolean)
|
|
88
|
+
// The loader does not look inside an object option, so the one filing
|
|
89
|
+
// value with a default gets it here, beside the check that validates it.
|
|
90
|
+
const filing = o.filing
|
|
91
|
+
? ({ dedupeBy: "path", ...o.filing } as unknown as FilingConfig)
|
|
92
|
+
: undefined
|
|
93
|
+
|
|
94
|
+
const numberOf = (rawKey: string): number => Number.parseInt(rawKey.replace(/^r/, ""), 10)
|
|
95
|
+
|
|
96
|
+
// A repository can hold work this job has no business reviewing, and a
|
|
97
|
+
// reviewer that claims one takes a human's pull request hostage behind its
|
|
98
|
+
// claim label. Two reviewers over one repository split it the same way.
|
|
99
|
+
const inScope = (items: WorkItem[]) =>
|
|
100
|
+
o.headRef ? items.filter((i) => (i.headRef ?? "").startsWith(o.headRef)) : items
|
|
101
|
+
|
|
102
|
+
const job: Job = {
|
|
103
|
+
name: spec.name,
|
|
104
|
+
dir: spec.dir,
|
|
105
|
+
repo: spec.repo,
|
|
106
|
+
workload: "reviewer",
|
|
107
|
+
deleteRemote: o.deleteRemote,
|
|
108
|
+
copyIntoWorktree: o.copyIntoWorktree,
|
|
109
|
+
filing,
|
|
110
|
+
|
|
111
|
+
async discover(ctx) {
|
|
112
|
+
const open = unblocked(ctx, inScope(await prs(ctx, job, "open")), o.passLabel ? [o.passLabel] : [])
|
|
113
|
+
// First in, first out. The engine takes the first eligible candidate and
|
|
114
|
+
// never re-sorts, so this ordering is an external contract.
|
|
115
|
+
return [...open].sort((a, b) => a.number - b.number)
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async discoverClaimed(ctx) {
|
|
119
|
+
const claim = ctx.workspace.naming.labels.claim
|
|
120
|
+
return inScope(await prs(ctx, job, "all")).filter((i) => i.labels.includes(claim))
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async key(ctx, item) {
|
|
124
|
+
if (o.identity === "pr") return `r${item.number}`
|
|
125
|
+
if (o.identity === "head-ref-issue") {
|
|
126
|
+
const m = item.headRef?.match(HEAD_ISSUE)
|
|
127
|
+
if (m) return `r${m[1]}`
|
|
128
|
+
ctx.log({ pass: "warn", job: job.name, reason: `no issue in head "${item.headRef ?? ""}" for ${item.id}, keying on the pull request` })
|
|
129
|
+
return `r${item.number}`
|
|
130
|
+
}
|
|
131
|
+
const view = await ctx.cache(`job:closing:${item.id}`, () =>
|
|
132
|
+
ctx.gh.prView(repoOf(item), String(item.number), ["closingIssuesReferences"]))
|
|
133
|
+
const closing = view?.closingIssuesReferences?.[0]?.number
|
|
134
|
+
if (typeof closing === "number") return `r${closing}`
|
|
135
|
+
ctx.log({ pass: "warn", job: job.name, reason: `${item.id} closes no issue, keying on the pull request` })
|
|
136
|
+
return `r${item.number}`
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// Counted from the pull request's own comments, with startsWith and never
|
|
140
|
+
// includes: a transcript tail is posted in a fenced block and contains the
|
|
141
|
+
// prefix, which would make every failure look like another round.
|
|
142
|
+
async attempt(ctx, item) {
|
|
143
|
+
if (!o.rounds) return 1
|
|
144
|
+
const view = await ctx.cache(`job:comments:${item.id}`, () =>
|
|
145
|
+
ctx.gh.prView(repoOf(item), String(item.number), ["comments"]))
|
|
146
|
+
const rounds = (view?.comments ?? []).filter((c: any) =>
|
|
147
|
+
String(c?.body ?? "").startsWith(o.commentPrefix)).length
|
|
148
|
+
return rounds + 1
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// The head branch itself is checked out in the builder's worktree and git
|
|
152
|
+
// forbids a second checkout, so a review works a throwaway local branch
|
|
153
|
+
// based on the remote head.
|
|
154
|
+
base: async (_ctx, item) => `origin/${item.headRef ?? ""}`,
|
|
155
|
+
|
|
156
|
+
// A pass label is a terminal state that releases the slot while the pull
|
|
157
|
+
// request is still open (continuous integration owns the merge). Merged
|
|
158
|
+
// and closed are handled by the monitor's own state check.
|
|
159
|
+
async done(_ctx, item) {
|
|
160
|
+
return o.passLabel ? item.labels.includes(o.passLabel) : false
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// Frequently a different predicate from done(): done releases the slot,
|
|
164
|
+
// sweepOk tears the worktree down, and tearing it down at the pass label
|
|
165
|
+
// would destroy a run that is still reconciling.
|
|
166
|
+
//
|
|
167
|
+
// A lookup that finds nothing falls through to the pull request branch
|
|
168
|
+
// rather than answering false. key() falls back to the pull request's own
|
|
169
|
+
// number whenever the identity cannot be resolved, and sweepOk cannot know
|
|
170
|
+
// that happened: a key matching no issue is exactly what that fallback
|
|
171
|
+
// produces, so reading it as a pull request number is what the fallback
|
|
172
|
+
// means. Answering false instead leaks the worktree, the branch and the
|
|
173
|
+
// tab forever.
|
|
174
|
+
async sweepOk(ctx, rawKey) {
|
|
175
|
+
const number = numberOf(rawKey)
|
|
176
|
+
if (o.identity === "closing-issue") {
|
|
177
|
+
const issue = (await issues(ctx, job, "all")).find((i) => i.number === number)
|
|
178
|
+
if (issue) return issue.state !== "OPEN"
|
|
179
|
+
}
|
|
180
|
+
if (o.identity === "head-ref-issue") {
|
|
181
|
+
const all = await prs(ctx, job, "all")
|
|
182
|
+
const mine = all.filter((p) => p.headRef?.match(HEAD_ISSUE)?.[1] === String(number))
|
|
183
|
+
const newest = mine.length ? mine.reduce((a, b) => (b.number > a.number ? b : a)) : null
|
|
184
|
+
if (newest) return newest.state !== "OPEN"
|
|
185
|
+
}
|
|
186
|
+
const pr = (await prs(ctx, job, "all")).find((p) => p.number === number)
|
|
187
|
+
return pr !== undefined && pr.state !== "OPEN"
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async brief(ctx, item) {
|
|
191
|
+
const budget = await filingBudget(ctx, job)
|
|
192
|
+
// The brief has no conditionals, so the kind writes the whole verdict
|
|
193
|
+
// step. Under "none" the merge is not the worker's to make, and a brief
|
|
194
|
+
// that said "merge with none" would hand an unattended worker the forge
|
|
195
|
+
// default: the merge the operator set this option to prevent.
|
|
196
|
+
const method = o.mergeMode === "none" ? "" : o.mergeMode || ctx.workspace.naming.mergeMethod
|
|
197
|
+
const mergeInstruction = method
|
|
198
|
+
? `Merge with \`${method}\`, then poll the merge state until it is definitive. A state of UNKNOWN is not a merge; keep polling.`
|
|
199
|
+
: `The merge is not yours to make. Apply the \`${o.passLabel}\` label instead, and do not merge. Continuous integration owns the merge from there.`
|
|
200
|
+
return renderBrief(ctx, job, item, await job.key(ctx, item), {
|
|
201
|
+
extends: spec.brief?.extends ?? "default/review",
|
|
202
|
+
optional,
|
|
203
|
+
append: spec.brief?.append,
|
|
204
|
+
}, {
|
|
205
|
+
attemptCap: o.rounds,
|
|
206
|
+
filingBudget: budget.filingBudget,
|
|
207
|
+
openQueue: budget.openQueue,
|
|
208
|
+
mergeInstruction,
|
|
209
|
+
// Never the literal "none": that names an absence of a merge, not a
|
|
210
|
+
// method, and prose elsewhere reads this as a method.
|
|
211
|
+
mergeMethod: method || ctx.workspace.naming.mergeMethod,
|
|
212
|
+
commentPrefix: o.commentPrefix,
|
|
213
|
+
passLabel: o.passLabel,
|
|
214
|
+
dedupeBy: filing?.dedupeBy ?? "path",
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
return job
|
|
219
|
+
},
|
|
220
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { isAbsolute, resolve } from "node:path"
|
|
3
|
+
import type { Ctx, Job, WorkItem } from "../types"
|
|
4
|
+
import type { Kind } from "./validate"
|
|
5
|
+
import { renderBrief } from "../brief"
|
|
6
|
+
import { owns, keyOf } from "../engine/naming"
|
|
7
|
+
import { expandHome } from "../paths"
|
|
8
|
+
import { appendJournal } from "../journal"
|
|
9
|
+
|
|
10
|
+
interface Options {
|
|
11
|
+
at: string[]
|
|
12
|
+
days: string[]
|
|
13
|
+
doneWhen: string
|
|
14
|
+
base: string
|
|
15
|
+
deleteRemote: boolean
|
|
16
|
+
copyIntoWorktree: string[]
|
|
17
|
+
journal: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
21
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
|
|
22
|
+
|
|
23
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
24
|
+
const stamp = (d: Date) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
|
|
25
|
+
|
|
26
|
+
// Deliberately not a cron expression. A real routine is due across a window,
|
|
27
|
+
// from its slot until the next one begins and then never, which is what makes a
|
|
28
|
+
// slot missed to a reboot fire once on the first tick back inside its window and
|
|
29
|
+
// never fire stale afterwards. A cron instant either misses slots after every
|
|
30
|
+
// reboot or double-fires them.
|
|
31
|
+
export function occurrenceKey(now: Date, at: string[]): string | null {
|
|
32
|
+
const slots = [...at].filter((t) => TIME.test(t)).sort()
|
|
33
|
+
if (!slots.length) return null
|
|
34
|
+
const minutes = now.getHours() * 60 + now.getMinutes()
|
|
35
|
+
const asMinutes = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5))
|
|
36
|
+
const today = slots.filter((t) => asMinutes(t) <= minutes)
|
|
37
|
+
if (today.length) {
|
|
38
|
+
const slot = today[today.length - 1]!
|
|
39
|
+
return `${stamp(now)}-${slot.replace(":", "")}`
|
|
40
|
+
}
|
|
41
|
+
// Before the first slot of the day: the occurrence still running is
|
|
42
|
+
// yesterday's last one.
|
|
43
|
+
const yesterday = new Date(now)
|
|
44
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
45
|
+
return `${stamp(yesterday)}-${slots[slots.length - 1]!.replace(":", "")}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The weekday of the occurrence, not of the clock: before the first slot of the
|
|
49
|
+
// day the occurrence still running is yesterday's, and it is yesterday's
|
|
50
|
+
// weekday that decides whether it was ever due.
|
|
51
|
+
function onDay(key: string, days: string[]): boolean {
|
|
52
|
+
if (!days.length) return true
|
|
53
|
+
const [y, m, d] = [key.slice(0, 4), key.slice(4, 6), key.slice(6, 8)].map(Number)
|
|
54
|
+
return days.includes(DAYS[new Date(y!, m! - 1, d!).getDay()]!)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The occurrence's own completion marker: the artifact the run exists to
|
|
58
|
+
// produce. Without one the only signal a routine has is its worktree
|
|
59
|
+
// disappearing, and that does not happen until the occurrence rolls, so a run
|
|
60
|
+
// that finished at 09:38 would be nudged and then failed for hours.
|
|
61
|
+
function donePath(spec: { dir: string }, pattern: string, key: string): string {
|
|
62
|
+
const filled = expandHome(pattern.replaceAll("{{key}}", key))
|
|
63
|
+
return isAbsolute(filled) ? filled : resolve(spec.dir, filled)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const itemFor = (key: string, name: string): WorkItem => ({
|
|
67
|
+
// No url, so nothing labels it and nothing comments on it: the loop's own
|
|
68
|
+
// mark and the worktree on disk are the whole record of this run.
|
|
69
|
+
id: `key:${key}`,
|
|
70
|
+
number: 0,
|
|
71
|
+
title: name,
|
|
72
|
+
state: "OPEN",
|
|
73
|
+
labels: [],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const routine: Kind = {
|
|
77
|
+
name: "routine",
|
|
78
|
+
workload: "routine",
|
|
79
|
+
fields: [
|
|
80
|
+
{ name: "at", type: "string[]", required: true, doc: "slot times, local, like 09:10; due from the slot until the next one" },
|
|
81
|
+
{ name: "days", type: "string[]", default: [], doc: "weekdays the run is due, like mon tue; empty for every day" },
|
|
82
|
+
{ name: "doneWhen", type: "string", default: "", doc: "a file, {{key}} substituted, whose existence ends the occurrence" },
|
|
83
|
+
{ name: "base", type: "string", default: "origin/main", doc: "the ref the run branches from" },
|
|
84
|
+
{ name: "deleteRemote", type: "boolean", default: false, doc: "delete the branch on sweep if the run pushed one" },
|
|
85
|
+
{ name: "copyIntoWorktree", type: "string[]", default: [], doc: "files copied from the repository into a fresh worktree" },
|
|
86
|
+
{ name: "journal", type: "boolean", default: true, doc: "ask the worker to append one line to the journal" },
|
|
87
|
+
],
|
|
88
|
+
|
|
89
|
+
check(spec) {
|
|
90
|
+
const at = (spec.options.at ?? []) as string[]
|
|
91
|
+
const errs = at.filter((t) => !TIME.test(t)).map((t) => `options.at: "${t}" is not a time of day like 09:10`)
|
|
92
|
+
if (!errs.length && at.length === 0) errs.push("options.at must name at least one time of day")
|
|
93
|
+
for (const d of (spec.options.days ?? []) as string[]) {
|
|
94
|
+
if (!DAYS.includes(d)) errs.push(`options.days: "${d}" is not a weekday like mon`)
|
|
95
|
+
}
|
|
96
|
+
return errs
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
build(spec) {
|
|
100
|
+
const o = spec.options as unknown as Options
|
|
101
|
+
const optional = o.journal ? ["journal"] : []
|
|
102
|
+
const keyOfItem = (item: WorkItem) => item.id.replace(/^key:/, "")
|
|
103
|
+
|
|
104
|
+
const job: Job = {
|
|
105
|
+
name: spec.name,
|
|
106
|
+
dir: spec.dir,
|
|
107
|
+
repo: spec.repo,
|
|
108
|
+
workload: "routine",
|
|
109
|
+
deleteRemote: o.deleteRemote,
|
|
110
|
+
copyIntoWorktree: o.copyIntoWorktree,
|
|
111
|
+
|
|
112
|
+
// The spawned mark is authoritative local state here, not derived: there
|
|
113
|
+
// is no label and no pull request to read it back from (spec 7).
|
|
114
|
+
async discover(ctx) {
|
|
115
|
+
const key = occurrenceKey(ctx.now, o.at)
|
|
116
|
+
if (!key || !onDay(key, o.days)) return []
|
|
117
|
+
return ctx.marks.has(job.name, key, "spawned") ? [] : [itemFor(key, job.name)]
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// Derived from the world after all: a worktree this job owns is a run in
|
|
121
|
+
// flight, whatever the marks say.
|
|
122
|
+
async discoverClaimed(ctx) {
|
|
123
|
+
const repo = ctx.workspace.repos[job.repo ?? ""]
|
|
124
|
+
if (!repo) return []
|
|
125
|
+
// The engine's own worktree cache, not a job: prefix - the sweep pass
|
|
126
|
+
// earlier in this same tick already reads this exact key, so sharing
|
|
127
|
+
// its snapshot is the point, not an accident.
|
|
128
|
+
const worktrees = await ctx.cache(`engine:worktrees:${repo}`, () => ctx.git(repo).worktrees())
|
|
129
|
+
return worktrees
|
|
130
|
+
.filter((wt) => owns(job.name, ctx.workspace.worktreeBase, wt))
|
|
131
|
+
.map((wt) => itemFor(keyOf(job.name, wt.branch)!, job.name))
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
key: async (ctx, item) => keyOfItem(item) || occurrenceKey(ctx.now, o.at) || "",
|
|
135
|
+
base: async () => o.base,
|
|
136
|
+
|
|
137
|
+
// Done when the worktree is gone, which is the only external record this
|
|
138
|
+
// job has. While it exists the monitor supervises the worker: busy,
|
|
139
|
+
// nudge, then fail, like every other item.
|
|
140
|
+
async done(ctx, item) {
|
|
141
|
+
if (o.doneWhen && existsSync(donePath(spec, o.doneWhen, keyOfItem(item)))) return true
|
|
142
|
+
const claimed = await job.discoverClaimed(ctx)
|
|
143
|
+
return !claimed.some((c) => c.id === item.id)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// A routine must sweep occurrences that are no longer due, which is a
|
|
147
|
+
// different question from done(): without a completion marker the current
|
|
148
|
+
// occurrence's worktree has to stay until its window closes, because
|
|
149
|
+
// nothing else says the run is over. A doneWhen does say so, and the run
|
|
150
|
+
// that wrote it has no more use for its checkout or its tab: a six-hourly
|
|
151
|
+
// routine that finished at 09:38 would otherwise hold both until 12:10.
|
|
152
|
+
// The sweep checks for a working agent before it asks this, so a marker
|
|
153
|
+
// written mid-run still cannot pull a worktree out from under one.
|
|
154
|
+
async sweepOk(ctx, rawKey) {
|
|
155
|
+
if (o.doneWhen && existsSync(donePath(spec, o.doneWhen, rawKey))) return true
|
|
156
|
+
return rawKey !== occurrenceKey(ctx.now, o.at)
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// The default failure path labels and comments, and there is nothing here
|
|
160
|
+
// to label. The journal is the whole post-mortem, so it gets the tail.
|
|
161
|
+
async onFail(ctx, item, transcriptTail) {
|
|
162
|
+
appendJournal(ctx, `FAIL ${job.name} ${keyOfItem(item)}: ${transcriptTail.split("\n").slice(-5).join(" ").trim()}`)
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
brief: (ctx, item) =>
|
|
166
|
+
renderBrief(ctx, job, item, keyOfItem(item), {
|
|
167
|
+
extends: spec.brief?.extends ?? "default/routine",
|
|
168
|
+
optional,
|
|
169
|
+
append: spec.brief?.append,
|
|
170
|
+
}),
|
|
171
|
+
}
|
|
172
|
+
return job
|
|
173
|
+
},
|
|
174
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Ctx, Job, WorkItem } from "../types"
|
|
2
|
+
|
|
3
|
+
// Every remote read a kind performs is memoized per tick under the job's own
|
|
4
|
+
// namespace. Without this the natural implementation puts a list call inside a
|
|
5
|
+
// guard and pays one network round trip per candidate: on a repository with 139
|
|
6
|
+
// open issues that was roughly 175 seconds in the pick loop alone.
|
|
7
|
+
export async function slugOf(ctx: Ctx, job: Job): Promise<string> {
|
|
8
|
+
const path = ctx.workspace.repos[job.repo ?? ""]
|
|
9
|
+
if (!path) throw new Error(`job "${job.name}" has no resolvable repo`)
|
|
10
|
+
return ctx.cache(`job:slug:${path}`, () => ctx.git(path).remoteSlug())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function issues(ctx: Ctx, job: Job, state: "open" | "all"): Promise<WorkItem[]> {
|
|
14
|
+
const slug = await slugOf(ctx, job)
|
|
15
|
+
return ctx.cache(`job:issues:${slug}:${state}`, () => ctx.gh.issueList({ repo: slug, state }))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function prs(ctx: Ctx, job: Job, state: "open" | "all"): Promise<WorkItem[]> {
|
|
19
|
+
const slug = await slugOf(ctx, job)
|
|
20
|
+
return ctx.cache(`job:prs:${slug}:${state}`, () => ctx.gh.prList({ repo: slug, state }))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Claimed, failed, and parked items are not candidates: the claim belongs to a
|
|
24
|
+
// live worker, the failure is waiting for a human, and the park is a question
|
|
25
|
+
// nobody answered.
|
|
26
|
+
export function unblocked(ctx: Ctx, items: WorkItem[], extra: string[] = []): WorkItem[] {
|
|
27
|
+
const l = ctx.workspace.naming.labels
|
|
28
|
+
const blocked = new Set([l.claim, l.failed, l.park, ...extra].filter(Boolean))
|
|
29
|
+
return items.filter((i) => !i.labels.some((name) => blocked.has(name)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Priority labels first, in the order the workspace lists them, then oldest
|
|
33
|
+
// first. discover() returns candidates in priority order and the engine takes
|
|
34
|
+
// the first eligible without re-sorting, so this ordering is the contract.
|
|
35
|
+
export function byPriority(ctx: Ctx, items: WorkItem[]): WorkItem[] {
|
|
36
|
+
const priority = ctx.workspace.naming.labels.priority
|
|
37
|
+
const rank = (i: WorkItem) => {
|
|
38
|
+
const at = priority.findIndex((p) => i.labels.includes(p))
|
|
39
|
+
return at === -1 ? priority.length : at
|
|
40
|
+
}
|
|
41
|
+
return [...items].sort((a, b) => rank(a) - rank(b) || a.number - b.number)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The newest match, so a retried issue's old closed pull request never decides
|
|
45
|
+
// anything about the live one working the same key (spec 4.4).
|
|
46
|
+
export function newestByHead(items: WorkItem[], head: string): WorkItem | null {
|
|
47
|
+
const mine = items.filter((i) => i.headRef === head)
|
|
48
|
+
return mine.length ? mine.reduce((a, b) => (b.number > a.number ? b : a)) : null
|
|
49
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Job } from "../types"
|
|
2
|
+
|
|
3
|
+
// The field table, the kind contract, and the pure validation helpers around
|
|
4
|
+
// it. Split out of index.ts because every kind needs these while index.ts
|
|
5
|
+
// imports every kind to build the registry: entering the graph at a kind then
|
|
6
|
+
// hit that cycle and threw before the kind's own module body ran, which made
|
|
7
|
+
// its test file unrunnable on its own.
|
|
8
|
+
export type FieldType = "string" | "number" | "boolean" | "string[]" | "object"
|
|
9
|
+
|
|
10
|
+
export interface Field {
|
|
11
|
+
name: string
|
|
12
|
+
type: FieldType
|
|
13
|
+
required?: boolean
|
|
14
|
+
default?: unknown
|
|
15
|
+
doc: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// What the loader hands a kind: everything it validated itself, plus the
|
|
19
|
+
// options it did not look inside.
|
|
20
|
+
export interface JobSpec {
|
|
21
|
+
name: string
|
|
22
|
+
dir: string
|
|
23
|
+
repo?: string
|
|
24
|
+
// Engine-level, so the loader resolves and checks it and the kind only
|
|
25
|
+
// renders it. `append` is already an absolute path by the time a kind sees it.
|
|
26
|
+
brief?: { extends?: string; append?: string }
|
|
27
|
+
options: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Kind {
|
|
31
|
+
name: string
|
|
32
|
+
workload: string
|
|
33
|
+
fields: Field[]
|
|
34
|
+
// Validation a field table cannot express: an enum, or the shape inside an
|
|
35
|
+
// object option. Returns messages in validateOptions' vocabulary, which the
|
|
36
|
+
// loader prefixes with the job file. A typo like "reserved" for "reserve"
|
|
37
|
+
// parses clean and silently disarms whatever it configures, so a kind with an
|
|
38
|
+
// enum or an object option owes the operator this.
|
|
39
|
+
check?(spec: JobSpec): string[]
|
|
40
|
+
build(spec: JobSpec): Job
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function levenshtein(a: string, b: string): number {
|
|
44
|
+
const d: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0))
|
|
45
|
+
for (let i = 0; i <= a.length; i++) d[i]![0] = i
|
|
46
|
+
for (let j = 0; j <= b.length; j++) d[0]![j] = j
|
|
47
|
+
for (let i = 1; i <= a.length; i++) {
|
|
48
|
+
for (let j = 1; j <= b.length; j++) {
|
|
49
|
+
d[i]![j] = Math.min(
|
|
50
|
+
d[i - 1]![j]! + 1,
|
|
51
|
+
d[i]![j - 1]! + 1,
|
|
52
|
+
d[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return d[a.length]![b.length]!
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A typo is worth a suggestion; a different word is not. The threshold keeps
|
|
60
|
+
// "did you mean" from firing on names that merely start with the same letter.
|
|
61
|
+
export function didYouMean(word: string, candidates: string[]): string {
|
|
62
|
+
let best = ""
|
|
63
|
+
let bestD = Infinity
|
|
64
|
+
for (const c of candidates) {
|
|
65
|
+
const d = levenshtein(word, c)
|
|
66
|
+
if (d < bestD) { bestD = d; best = c }
|
|
67
|
+
}
|
|
68
|
+
// Two edits, not one: a transposition costs two, and "wbe" for "web" is the
|
|
69
|
+
// most common typo there is. A floor of one makes the suggester blind to it
|
|
70
|
+
// for every short word, which is most repo keys and kind names.
|
|
71
|
+
return bestD <= Math.max(2, Math.floor(word.length * 0.4)) ? best : ""
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The unknown-key message, in the same shape validateOptions produces for an
|
|
75
|
+
// unknown option. A typo is the whole reason these checks exist, so a message
|
|
76
|
+
// that lists the known keys without pointing at the near miss is half a fix.
|
|
77
|
+
export function unknownKey(key: string, known: string[]): string {
|
|
78
|
+
const near = didYouMean(key, known)
|
|
79
|
+
return near
|
|
80
|
+
? `unknown key "${key}"; did you mean "${near}"?`
|
|
81
|
+
: `unknown key "${key}"; known: ${known.join(", ")}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function oneOf(field: string, value: string, allowed: string[]): string[] {
|
|
85
|
+
if (allowed.includes(value)) return []
|
|
86
|
+
const near = didYouMean(value, allowed)
|
|
87
|
+
return [
|
|
88
|
+
near
|
|
89
|
+
? `options.${field} must be one of ${allowed.join(", ")}; did you mean "${near}"?`
|
|
90
|
+
: `options.${field} must be one of ${allowed.join(", ")}`,
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Names the shape a value has, in the vocabulary the field table uses, so the
|
|
95
|
+
// error an operator reads is in the same words as the documentation.
|
|
96
|
+
function shapeOf(v: unknown): string {
|
|
97
|
+
if (Array.isArray(v)) return "list"
|
|
98
|
+
if (v === null) return "null"
|
|
99
|
+
return typeof v
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function typeOk(v: unknown, t: FieldType): boolean {
|
|
103
|
+
switch (t) {
|
|
104
|
+
case "string": return typeof v === "string"
|
|
105
|
+
case "number": return typeof v === "number" && Number.isFinite(v)
|
|
106
|
+
case "boolean": return typeof v === "boolean"
|
|
107
|
+
case "string[]": return Array.isArray(v) && v.every((x) => typeof x === "string")
|
|
108
|
+
case "object": return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateOptions(
|
|
113
|
+
kind: Kind,
|
|
114
|
+
options: Record<string, unknown>,
|
|
115
|
+
): { errors: string[]; value: Record<string, unknown> } {
|
|
116
|
+
const errors: string[] = []
|
|
117
|
+
const known = kind.fields.map((f) => f.name)
|
|
118
|
+
|
|
119
|
+
for (const key of Object.keys(options)) {
|
|
120
|
+
if (known.includes(key)) continue
|
|
121
|
+
const near = didYouMean(key, known)
|
|
122
|
+
errors.push(
|
|
123
|
+
near
|
|
124
|
+
? `unknown option "${key}"; did you mean "${near}"?`
|
|
125
|
+
: `unknown option "${key}"; known: ${known.join(", ")}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const value: Record<string, unknown> = {}
|
|
130
|
+
for (const f of kind.fields) {
|
|
131
|
+
const given = options[f.name]
|
|
132
|
+
if (given === undefined || given === null) {
|
|
133
|
+
if (f.required) errors.push(`options.${f.name} is required (${f.doc})`)
|
|
134
|
+
else if (f.default !== undefined) value[f.name] = f.default
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (!typeOk(given, f.type)) {
|
|
138
|
+
errors.push(
|
|
139
|
+
f.type === "string[]"
|
|
140
|
+
? `options.${f.name} must be a list of strings`
|
|
141
|
+
: `options.${f.name} must be a ${f.type}, found ${shapeOf(given)}`,
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
value[f.name] = given
|
|
146
|
+
}
|
|
147
|
+
return { errors, value }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function describeKind(k: Kind): string[] {
|
|
151
|
+
const notes = k.fields.map((f) => (f.required ? "required" : `= ${JSON.stringify(f.default)}`))
|
|
152
|
+
const width = Math.max(...k.fields.map((f) => f.name.length))
|
|
153
|
+
const typeWidth = Math.max(...k.fields.map((f) => f.type.length))
|
|
154
|
+
const noteWidth = Math.max(...notes.map((n) => n.length))
|
|
155
|
+
const lines = [`${k.name} (workload: ${k.workload})`]
|
|
156
|
+
k.fields.forEach((f, i) => {
|
|
157
|
+
lines.push(
|
|
158
|
+
` ${f.name.padEnd(width)} ${f.type.padEnd(typeWidth)} ${notes[i]!.padEnd(noteWidth)} ${f.doc}`,
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
return lines
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const JSON_TYPE: Record<FieldType, object> = {
|
|
165
|
+
string: { type: "string" },
|
|
166
|
+
number: { type: "number" },
|
|
167
|
+
boolean: { type: "boolean" },
|
|
168
|
+
"string[]": { type: "array", items: { type: "string" } },
|
|
169
|
+
object: { type: "object" },
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function kindSchema(k: Kind): object {
|
|
173
|
+
const properties: Record<string, object> = {}
|
|
174
|
+
for (const f of k.fields) {
|
|
175
|
+
properties[f.name] = {
|
|
176
|
+
...JSON_TYPE[f.type],
|
|
177
|
+
...(f.default === undefined ? {} : { default: f.default }),
|
|
178
|
+
description: f.doc,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
type: "object",
|
|
183
|
+
properties,
|
|
184
|
+
required: k.fields.filter((f) => f.required).map((f) => f.name),
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
}
|
|
187
|
+
}
|