@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,25 @@
|
|
|
1
|
+
import type { Ctx, Job, Decision } from "../types"
|
|
2
|
+
import { sweepAll } from "./sweep"
|
|
3
|
+
import { monitorAll } from "./monitor"
|
|
4
|
+
import { spawnOne } from "./spawn"
|
|
5
|
+
|
|
6
|
+
export interface TickOpts { paused: boolean; pausedJobs?: string[]; gcDays?: number }
|
|
7
|
+
|
|
8
|
+
export async function runTick(ctx: Ctx, jobs: Job[], opts: TickOpts): Promise<Decision[]> {
|
|
9
|
+
const started = Date.now()
|
|
10
|
+
const out: Decision[] = []
|
|
11
|
+
const emit = (ds: Decision[]) => {
|
|
12
|
+
for (const d of ds) {
|
|
13
|
+
out.push(d)
|
|
14
|
+
ctx.log(d)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
emit([{ pass: "gc", removed: ctx.marks.gc(opts.gcDays ?? 14) }])
|
|
19
|
+
emit(await sweepAll(ctx, jobs))
|
|
20
|
+
emit(await monitorAll(ctx, jobs))
|
|
21
|
+
if (!opts.paused) emit(await spawnOne(ctx, jobs, opts.pausedJobs ?? []))
|
|
22
|
+
|
|
23
|
+
emit([{ pass: "tick", workspace: ctx.workspace.name, ms: Date.now() - started }])
|
|
24
|
+
return out
|
|
25
|
+
}
|
package/src/filing.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Ctx, Job } from "./types"
|
|
2
|
+
import { issues } from "./kinds/shared"
|
|
3
|
+
|
|
4
|
+
export type { FilingConfig } from "./types"
|
|
5
|
+
|
|
6
|
+
// openQueue is the consumer's own discover() result: unclaimed and unparked by
|
|
7
|
+
// construction, because that is what a job offers the spawn walk. Cached under
|
|
8
|
+
// the engine's key so the consumer's own spawn pass in this same tick pays for
|
|
9
|
+
// the read once.
|
|
10
|
+
export async function filingBudget(
|
|
11
|
+
ctx: Ctx,
|
|
12
|
+
job: Job,
|
|
13
|
+
): Promise<{ openQueue: number; filingBudget: number }> {
|
|
14
|
+
const f = job.filing
|
|
15
|
+
if (!f) return { openQueue: 0, filingBudget: 0 }
|
|
16
|
+
const consumer = ctx.workspace.jobs.find((j) => j.name === f.queue)
|
|
17
|
+
if (!consumer) throw new Error(`job "${job.name}": filing.queue "${f.queue}" names no job in this workspace`)
|
|
18
|
+
const items = await ctx.cache(`engine:discover:${consumer.name}`, () => consumer.discover(ctx))
|
|
19
|
+
const openQueue = items.length
|
|
20
|
+
return {
|
|
21
|
+
openQueue,
|
|
22
|
+
filingBudget: openQueue >= f.maxOpen ? 0 : Math.min(f.perRound, f.maxOpen - openQueue),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Enforcement stays out of the engine (spec 5.2): a job that overfiles has a
|
|
27
|
+
// brief problem, and a loop that blocked `gh issue create` would break the one
|
|
28
|
+
// write the fences deliberately allow. So this counts and reports.
|
|
29
|
+
//
|
|
30
|
+
// tradeoff: attribution is by time window, not by author, so two workers of the
|
|
31
|
+
// same job filing in the same window share the count. Per-worker attribution
|
|
32
|
+
// needs the forge to record who filed what, which is a query this does not have.
|
|
33
|
+
export async function auditFiling(
|
|
34
|
+
ctx: Ctx,
|
|
35
|
+
job: Job,
|
|
36
|
+
key: string,
|
|
37
|
+
): Promise<{ filed: number; budget: number } | null> {
|
|
38
|
+
const f = job.filing
|
|
39
|
+
if (!f) return null
|
|
40
|
+
const consumer = ctx.workspace.jobs.find((j) => j.name === f.queue)
|
|
41
|
+
if (!consumer) return null
|
|
42
|
+
// How long ago this worker started. No mark means the loop never spawned this
|
|
43
|
+
// key (a reboot, a hand-run), so there is no run to audit.
|
|
44
|
+
const age = ctx.marks.age(job.name, key, "spawned")
|
|
45
|
+
if (age === null) return null
|
|
46
|
+
const since = ctx.now.getTime() - age * 60000
|
|
47
|
+
// The shared helper, so this shares the consumer's own cached list rather
|
|
48
|
+
// than issuing the same --state all read again under a second key.
|
|
49
|
+
const items = await issues(ctx, consumer, "all")
|
|
50
|
+
const filed = items.filter((i) => i.createdAt && Date.parse(i.createdAt) >= since).length
|
|
51
|
+
// perRound rather than the budget the brief carried: the budget is recomputed
|
|
52
|
+
// from a queue depth that has moved since, and perRound is the half of it that
|
|
53
|
+
// does not move. A run that filed within perRound but above the room left is
|
|
54
|
+
// therefore not flagged.
|
|
55
|
+
return { filed, budget: f.perRound }
|
|
56
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// src/globalstate.ts
|
|
2
|
+
import { Database } from "bun:sqlite"
|
|
3
|
+
import type { Window } from "./types"
|
|
4
|
+
|
|
5
|
+
export interface UsageSample {
|
|
6
|
+
percent: number
|
|
7
|
+
at: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GlobalStore {
|
|
11
|
+
recordUsage(accountId: string, w: Window): void
|
|
12
|
+
lastUsage(accountId: string, kind: string, beforeMs: number): UsageSample | null
|
|
13
|
+
rate(provider: string, kind: string): number | null
|
|
14
|
+
observeRate(provider: string, kind: string, sample: number): number
|
|
15
|
+
spawnAdd(accountId: string, workspace: string, job: string, key: string, at: Date): void
|
|
16
|
+
reserve(accountId: string, workspace: string, job: string, key: string, at: Date, cap: number, sinceMs: number): boolean
|
|
17
|
+
confirm(job: string, key: string, at: Date): void
|
|
18
|
+
release(job: string, key: string, at: Date): void
|
|
19
|
+
// Scoped by workspace: job names and item-derived keys collide freely across
|
|
20
|
+
// repos, so two services each running a job called "build" keyed on issue
|
|
21
|
+
// numbers both hold a "b7". Unscoped, a restart in one looks up the other's
|
|
22
|
+
// account and starts the wrong agent kind against the wrong config dir.
|
|
23
|
+
accountFor(workspace: string, job: string, pathKey: string): string | null
|
|
24
|
+
spawnsSince(sinceMs: number): number
|
|
25
|
+
close(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One sample moves the estimate a third of the way. High enough to follow a
|
|
29
|
+
// model or plan change within a few ticks, low enough that a single noisy
|
|
30
|
+
// interval does not swing the whole fleet's concurrency.
|
|
31
|
+
export const EWMA_ALPHA = 0.3
|
|
32
|
+
|
|
33
|
+
export function openGlobalState(path: string): GlobalStore {
|
|
34
|
+
const db = new Database(path)
|
|
35
|
+
migrate(db)
|
|
36
|
+
db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
38
|
+
account TEXT NOT NULL,
|
|
39
|
+
kind TEXT NOT NULL,
|
|
40
|
+
percent REAL NOT NULL,
|
|
41
|
+
resets_at INTEGER NOT NULL,
|
|
42
|
+
window_minutes INTEGER NOT NULL,
|
|
43
|
+
at INTEGER NOT NULL,
|
|
44
|
+
PRIMARY KEY (account, kind, at)
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS rates (
|
|
47
|
+
provider TEXT NOT NULL,
|
|
48
|
+
kind TEXT NOT NULL,
|
|
49
|
+
ewma REAL NOT NULL,
|
|
50
|
+
samples INTEGER NOT NULL,
|
|
51
|
+
PRIMARY KEY (provider, kind)
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS spawns (
|
|
54
|
+
account TEXT NOT NULL,
|
|
55
|
+
workspace TEXT NOT NULL DEFAULT '',
|
|
56
|
+
job TEXT NOT NULL,
|
|
57
|
+
key TEXT NOT NULL,
|
|
58
|
+
at INTEGER NOT NULL,
|
|
59
|
+
pending INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
PRIMARY KEY (job, key, at)
|
|
61
|
+
);
|
|
62
|
+
`)
|
|
63
|
+
// This database is shared across workspaces (spec section 7), and the
|
|
64
|
+
// router now writes a usage row and a rate EWMA update on every tick, so
|
|
65
|
+
// two workspaces ticking in the same minute will collide on a write lock.
|
|
66
|
+
// Without a busy timeout that is SQLITE_BUSY on a normal day; WAL lets
|
|
67
|
+
// reads and writes overlap and the timeout gives a blocked writer room to
|
|
68
|
+
// wait its turn instead of failing immediately.
|
|
69
|
+
db.exec(`
|
|
70
|
+
PRAGMA journal_mode = WAL;
|
|
71
|
+
PRAGMA busy_timeout = 5000;
|
|
72
|
+
`)
|
|
73
|
+
|
|
74
|
+
const insUsage = db.query(
|
|
75
|
+
`INSERT OR IGNORE INTO usage (account, kind, percent, resets_at, window_minutes, at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
77
|
+
)
|
|
78
|
+
const prevUsage = db.query<UsageSample, [string, string, number]>(
|
|
79
|
+
`SELECT percent, at FROM usage
|
|
80
|
+
WHERE account = ? AND kind = ? AND at < ?
|
|
81
|
+
ORDER BY at DESC LIMIT 1`,
|
|
82
|
+
)
|
|
83
|
+
const getRate = db.query<{ ewma: number }, [string, string]>(
|
|
84
|
+
"SELECT ewma FROM rates WHERE provider = ? AND kind = ?",
|
|
85
|
+
)
|
|
86
|
+
const putRate = db.query(
|
|
87
|
+
`INSERT INTO rates (provider, kind, ewma, samples) VALUES (?, ?, ?, 1)
|
|
88
|
+
ON CONFLICT (provider, kind) DO UPDATE SET ewma = ?, samples = samples + 1`,
|
|
89
|
+
)
|
|
90
|
+
const insSpawn = db.query(
|
|
91
|
+
"INSERT OR IGNORE INTO spawns (account, workspace, job, key, at) VALUES (?, ?, ?, ?, ?)",
|
|
92
|
+
)
|
|
93
|
+
const insPending = db.query(
|
|
94
|
+
"INSERT OR IGNORE INTO spawns (account, workspace, job, key, at, pending) VALUES (?, ?, ?, ?, ?, 1)",
|
|
95
|
+
)
|
|
96
|
+
// Scoped by `at`, not just job/key: job names and item-derived keys collide
|
|
97
|
+
// freely across repos, so two workspaces can each hold a distinct pending
|
|
98
|
+
// row for the same (job, key) at different `at` values. Each workspace gets
|
|
99
|
+
// its own `now`, so the instant is what separates them; without the `at`
|
|
100
|
+
// predicate one workspace's confirm or release would match the other's live
|
|
101
|
+
// reservation.
|
|
102
|
+
const setConfirmed = db.query(
|
|
103
|
+
"UPDATE spawns SET pending = 0 WHERE job = ? AND key = ? AND at = ? AND pending = 1",
|
|
104
|
+
)
|
|
105
|
+
const delPending = db.query(
|
|
106
|
+
"DELETE FROM spawns WHERE job = ? AND key = ? AND at = ? AND pending = 1",
|
|
107
|
+
)
|
|
108
|
+
// The exact key, or a directory that carries a title slug after it.
|
|
109
|
+
// A legacy row (no workspace, from before the column existed) never
|
|
110
|
+
// matches here: it carries no workspace to disambiguate, so resolving it
|
|
111
|
+
// for any workspace's query is the cross-workspace collision this column
|
|
112
|
+
// exists to prevent. It still counts toward the daily cap (spawnsSince is
|
|
113
|
+
// workspace-agnostic); it just never hands back an account.
|
|
114
|
+
const findAccount = db.query<{ account: string }, [string, string, string, string]>(
|
|
115
|
+
`SELECT account FROM spawns
|
|
116
|
+
WHERE workspace = ? AND job = ? AND (key = ? OR ? LIKE key || '-%')
|
|
117
|
+
ORDER BY at DESC LIMIT 1`,
|
|
118
|
+
)
|
|
119
|
+
const countSince = db.query<{ n: number }, [number]>(
|
|
120
|
+
"SELECT COUNT(*) AS n FROM spawns WHERE at >= ?",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
recordUsage(accountId, w) {
|
|
125
|
+
insUsage.run(
|
|
126
|
+
accountId,
|
|
127
|
+
w.kind,
|
|
128
|
+
w.percent,
|
|
129
|
+
w.resetsAt.getTime(),
|
|
130
|
+
w.windowMinutes,
|
|
131
|
+
w.observedAt.getTime(),
|
|
132
|
+
)
|
|
133
|
+
},
|
|
134
|
+
lastUsage(accountId, kind, beforeMs) {
|
|
135
|
+
return prevUsage.get(accountId, kind, beforeMs)
|
|
136
|
+
},
|
|
137
|
+
rate(provider, kind) {
|
|
138
|
+
return getRate.get(provider, kind)?.ewma ?? null
|
|
139
|
+
},
|
|
140
|
+
observeRate(provider, kind, sample) {
|
|
141
|
+
const prev = getRate.get(provider, kind)?.ewma
|
|
142
|
+
const next = prev === undefined ? sample : EWMA_ALPHA * sample + (1 - EWMA_ALPHA) * prev
|
|
143
|
+
putRate.run(provider, kind, next, next)
|
|
144
|
+
return next
|
|
145
|
+
},
|
|
146
|
+
spawnAdd(accountId, workspace, job, key, at) {
|
|
147
|
+
insSpawn.run(accountId, workspace, job, key, at.getTime())
|
|
148
|
+
},
|
|
149
|
+
// tradeoff: nothing reaps an orphaned pending row (one left by a hard
|
|
150
|
+
// kill between reserve and confirm/release). It sits consuming a cap
|
|
151
|
+
// slot until spawnsSince's window rolls past UTC midnight, at which
|
|
152
|
+
// point it stops counting on its own. Add a reaper if that daily
|
|
153
|
+
// self-heal turns out not to be good enough in practice.
|
|
154
|
+
reserve(accountId, workspace, job, key, at, cap, sinceMs) {
|
|
155
|
+
// BEGIN IMMEDIATE takes the write lock before the count, so two
|
|
156
|
+
// workspaces ticking in the same minute cannot both read "one slot left"
|
|
157
|
+
// and both take it.
|
|
158
|
+
db.exec("BEGIN IMMEDIATE")
|
|
159
|
+
try {
|
|
160
|
+
const used = countSince.get(sinceMs)?.n ?? 0
|
|
161
|
+
if (used >= cap) {
|
|
162
|
+
db.exec("ROLLBACK")
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
// OR IGNORE means the insert can silently do nothing (an exact
|
|
166
|
+
// account/job/key/at row already exists), and a caller must not be
|
|
167
|
+
// told it holds a reservation with no row backing it.
|
|
168
|
+
const { changes } = insPending.run(accountId, workspace, job, key, at.getTime())
|
|
169
|
+
db.exec("COMMIT")
|
|
170
|
+
return changes === 1
|
|
171
|
+
} catch (err) {
|
|
172
|
+
try {
|
|
173
|
+
db.exec("ROLLBACK")
|
|
174
|
+
} catch {
|
|
175
|
+
// SQLite may have already rolled back on its own; that must not
|
|
176
|
+
// replace the original error, which is the one that explains what
|
|
177
|
+
// actually went wrong.
|
|
178
|
+
}
|
|
179
|
+
throw err
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
confirm(job, key, at) {
|
|
183
|
+
setConfirmed.run(job, key, at.getTime())
|
|
184
|
+
},
|
|
185
|
+
release(job, key, at) {
|
|
186
|
+
// Only a pending row is deletable: a confirmed spawn stays on the record
|
|
187
|
+
// even if a later step decides to tidy up.
|
|
188
|
+
delPending.run(job, key, at.getTime())
|
|
189
|
+
},
|
|
190
|
+
accountFor(workspace, job, pathKey) {
|
|
191
|
+
return findAccount.get(workspace, job, pathKey, pathKey)?.account ?? null
|
|
192
|
+
},
|
|
193
|
+
spawnsSince(sinceMs) {
|
|
194
|
+
return countSince.get(sinceMs)?.n ?? 0
|
|
195
|
+
},
|
|
196
|
+
close() {
|
|
197
|
+
db.close()
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A database written before the ledger-to-spawns rename keeps a populated
|
|
203
|
+
// `ledger` table, and CREATE TABLE IF NOT EXISTS happily creates an empty
|
|
204
|
+
// `spawns` beside it: the daily cap resets to zero, every account reads zero
|
|
205
|
+
// in flight and over-admits, and accountFor returns null for every worker
|
|
206
|
+
// already running, which throws in applyRestart and tombstones the item on the
|
|
207
|
+
// next tick. So the rename happens first, before any CREATE runs.
|
|
208
|
+
function migrate(db: Database): void {
|
|
209
|
+
const has = (name: string) =>
|
|
210
|
+
db.query<{ n: number }, [string]>(
|
|
211
|
+
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
212
|
+
).get(name)!.n > 0
|
|
213
|
+
|
|
214
|
+
if (has("ledger") && !has("spawns")) {
|
|
215
|
+
db.exec("ALTER TABLE ledger RENAME TO spawns")
|
|
216
|
+
db.exec("ALTER TABLE spawns RENAME COLUMN plugin TO job")
|
|
217
|
+
}
|
|
218
|
+
if (!has("spawns")) return
|
|
219
|
+
const columns = db.query<{ name: string }, []>("PRAGMA table_info(spawns)").all()
|
|
220
|
+
if (!columns.some((c) => c.name === "workspace")) {
|
|
221
|
+
// Pre-existing rows carry no workspace, and the empty string is the
|
|
222
|
+
// honest value for them: accountFor never matches it, so a legacy row
|
|
223
|
+
// still counts toward the daily cap but resolves no account. A worker
|
|
224
|
+
// that was in flight across the upgrade then fails once, restartable by
|
|
225
|
+
// hand, rather than risk resolving the wrong workspace's account.
|
|
226
|
+
db.exec("ALTER TABLE spawns ADD COLUMN workspace TEXT NOT NULL DEFAULT ''")
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/journal.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import type { Ctx } from "./types"
|
|
4
|
+
|
|
5
|
+
// The journal is markdown at ~/.agent-loop/<workspace>/journal.md, the one file
|
|
6
|
+
// the fences let a worker write outside its worktree (spec 8). The engine
|
|
7
|
+
// appends to the same file, so the operator reads one story.
|
|
8
|
+
export function appendJournal(ctx: Ctx, line: string): void {
|
|
9
|
+
if (!ctx.live) return
|
|
10
|
+
const path = ctx.workspace.journalPath
|
|
11
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
12
|
+
appendFileSync(path, `${ctx.now.toISOString()} ${line}\n`)
|
|
13
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Job, WorkItem, Ctx } from "../types"
|
|
2
|
+
import type { Kind } from "./validate"
|
|
3
|
+
import { issues, prs, unblocked, byPriority, newestByHead } from "./shared"
|
|
4
|
+
import { renderBrief } from "../brief"
|
|
5
|
+
import { branchName } from "../engine/naming"
|
|
6
|
+
|
|
7
|
+
interface Options {
|
|
8
|
+
base: string
|
|
9
|
+
reviewDebt: number
|
|
10
|
+
debtIgnoreLabels: string[]
|
|
11
|
+
issueLabel: string
|
|
12
|
+
deleteRemote: boolean
|
|
13
|
+
sweepIgnoresWorking: boolean
|
|
14
|
+
copyIntoWorktree: string[]
|
|
15
|
+
journal: boolean
|
|
16
|
+
screenshots: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const builder: Kind = {
|
|
20
|
+
name: "builder",
|
|
21
|
+
workload: "builder",
|
|
22
|
+
fields: [
|
|
23
|
+
{ name: "base", type: "string", default: "origin/main", doc: "the ref new work branches from" },
|
|
24
|
+
{ name: "reviewDebt", type: "number", default: 0, doc: "stop taking issues at this many open pull requests, 0 to never stop" },
|
|
25
|
+
{ name: "debtIgnoreLabels", type: "string[]", default: [], doc: "labels that mean a pull request is no longer the reviewer's debt, typically a reviewer's passLabel" },
|
|
26
|
+
{ name: "issueLabel", type: "string", default: "", doc: "only take issues carrying this label, empty for all of them" },
|
|
27
|
+
{ name: "deleteRemote", type: "boolean", default: true, doc: "delete the pushed branch when the work is swept" },
|
|
28
|
+
{ name: "sweepIgnoresWorking", type: "boolean", default: true, doc: "sweep even while an agent works, because a merge is definitive here" },
|
|
29
|
+
{ name: "copyIntoWorktree", type: "string[]", default: [], doc: "files copied from the repository into a fresh worktree" },
|
|
30
|
+
{ name: "journal", type: "boolean", default: false, doc: "ask the worker to append one line to the journal" },
|
|
31
|
+
{ name: "screenshots", type: "boolean", default: false, doc: "allow screenshots on an orphan asset branch" },
|
|
32
|
+
],
|
|
33
|
+
build(spec) {
|
|
34
|
+
const o = spec.options as unknown as Options
|
|
35
|
+
const optional = [o.journal ? "journal" : "", o.screenshots ? "screenshots" : ""].filter(Boolean)
|
|
36
|
+
|
|
37
|
+
// One place the key format is written. It names the branch, the worktree
|
|
38
|
+
// and the pull request lookup, so a second spelling of it is a bug that
|
|
39
|
+
// only shows up as work rebuilt from scratch.
|
|
40
|
+
const keyFor = (item: WorkItem): string => `b${item.number}`
|
|
41
|
+
|
|
42
|
+
// A pull request whose head is this job's branch for that key, newest
|
|
43
|
+
// first: a retried issue's old closed pull request must not answer for the
|
|
44
|
+
// live one.
|
|
45
|
+
const prFor = async (ctx: Ctx, key: string): Promise<WorkItem | null> =>
|
|
46
|
+
newestByHead(await prs(ctx, job, "all"), branchName(job.name, key))
|
|
47
|
+
|
|
48
|
+
const job: Job = {
|
|
49
|
+
name: spec.name,
|
|
50
|
+
dir: spec.dir,
|
|
51
|
+
repo: spec.repo,
|
|
52
|
+
workload: "builder",
|
|
53
|
+
deleteRemote: o.deleteRemote,
|
|
54
|
+
sweepIgnoresWorking: o.sweepIgnoresWorking,
|
|
55
|
+
copyIntoWorktree: o.copyIntoWorktree,
|
|
56
|
+
|
|
57
|
+
// The throttle reads the reviewer's queue rather than this job's own
|
|
58
|
+
// item, which is why it is admit() and not guard(). Spec 5.1: the queue
|
|
59
|
+
// is every open pull request a reviewer still owes work on, claimed ones
|
|
60
|
+
// very much included, minus the two states a human owns. Excluding
|
|
61
|
+
// claimed items would count the queue as empty exactly when it is
|
|
62
|
+
// busiest, and two parked items would otherwise deadlock the pipeline
|
|
63
|
+
// forever, the builder throttled and the reviewer idle.
|
|
64
|
+
async admit(ctx) {
|
|
65
|
+
if (!o.reviewDebt) return null
|
|
66
|
+
const l = ctx.workspace.naming.labels
|
|
67
|
+
// debtIgnoreLabels alongside the two the spec names: a pass label is
|
|
68
|
+
// terminal for the reviewer, and under mergeMode none such a pull
|
|
69
|
+
// request stays open until continuous integration merges it, so
|
|
70
|
+
// counting it would throttle the builder on finished work.
|
|
71
|
+
const done = new Set([l.park, l.failed, ...o.debtIgnoreLabels].filter(Boolean))
|
|
72
|
+
const open = (await prs(ctx, job, "open")).filter(
|
|
73
|
+
(p) => !p.labels.some((name) => done.has(name)),
|
|
74
|
+
)
|
|
75
|
+
return open.length >= o.reviewDebt ? `review debt ${open.length}/${o.reviewDebt}` : null
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async discover(ctx) {
|
|
79
|
+
const open = await issues(ctx, job, "open")
|
|
80
|
+
const mine = o.issueLabel ? open.filter((i) => i.labels.includes(o.issueLabel)) : open
|
|
81
|
+
return byPriority(ctx, unblocked(ctx, mine))
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// --state all, per spec 4.5: a claim label left on a closed issue would
|
|
85
|
+
// otherwise never be seen, and its slot would be held forever.
|
|
86
|
+
async discoverClaimed(ctx) {
|
|
87
|
+
const claim = ctx.workspace.naming.labels.claim
|
|
88
|
+
return (await issues(ctx, job, "all")).filter((i) => i.labels.includes(claim))
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
key: async (_ctx, item) => keyFor(item),
|
|
92
|
+
base: async () => o.base,
|
|
93
|
+
|
|
94
|
+
// done() releases the claim as soon as the pull request exists, and the
|
|
95
|
+
// issue stays open until the merge closes it, so without this the next
|
|
96
|
+
// tick re-picks the issue and the spawn's pre-clean destroys the
|
|
97
|
+
// worktree the reviewer's rounds are still working in.
|
|
98
|
+
guard: async (ctx, item) => {
|
|
99
|
+
const pr = await prFor(ctx, keyFor(item))
|
|
100
|
+
// A closed, unmerged pull request is a legitimate retry.
|
|
101
|
+
return pr === null || pr.state === "CLOSED"
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// The work is over when the pull request exists: what happens to it after
|
|
105
|
+
// that belongs to the reviewer, and holding the claim would count this
|
|
106
|
+
// issue against the builder's slots through every review round.
|
|
107
|
+
async done(ctx, item) {
|
|
108
|
+
return (await prFor(ctx, keyFor(item))) !== null
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// Not done: the worktree has to survive until the pull request is
|
|
112
|
+
// finished, because the reviewer's rounds ask the builder's worker for
|
|
113
|
+
// changes in it.
|
|
114
|
+
async sweepOk(ctx, rawKey) {
|
|
115
|
+
const pr = await prFor(ctx, rawKey)
|
|
116
|
+
return pr !== null && pr.state !== "OPEN"
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
brief: (ctx, item) =>
|
|
120
|
+
renderBrief(ctx, job, item, keyFor(item), {
|
|
121
|
+
extends: spec.brief?.extends ?? "default/build",
|
|
122
|
+
optional,
|
|
123
|
+
append: spec.brief?.append,
|
|
124
|
+
}),
|
|
125
|
+
}
|
|
126
|
+
return job
|
|
127
|
+
},
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Kind } from "./validate"
|
|
2
|
+
import { builder } from "./builder"
|
|
3
|
+
import { reviewer } from "./reviewer"
|
|
4
|
+
import { routine } from "./routine"
|
|
5
|
+
|
|
6
|
+
// The shipped kinds, and the whole registry. Nothing under a project folder is
|
|
7
|
+
// imported, so this is a contract between the engine and these three rather
|
|
8
|
+
// than an extension point (spec 5).
|
|
9
|
+
export const KINDS: Record<string, Kind> = { builder, reviewer, routine }
|
|
10
|
+
|
|
11
|
+
// The public surface of "./kinds" is unchanged: every existing importer, the
|
|
12
|
+
// loader included, still reads the field table and the helpers from here.
|
|
13
|
+
export * from "./validate"
|