@tonoid/agent-loop 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -111
- package/briefs/default/core.md +19 -0
- package/package.json +1 -1
- package/src/adapters/herdr.ts +20 -5
- package/src/cli.ts +4 -4
- package/src/config.ts +19 -1
- package/src/discover.ts +13 -1
- package/src/effects/monitor.ts +14 -0
- package/src/effects/sweep.ts +10 -6
- package/src/engine/monitor.ts +6 -1
- package/src/engine/sweep.ts +63 -5
- package/src/globalstate.ts +25 -0
- package/src/kinds/builder.ts +66 -5
- package/src/kinds/reviewer.ts +16 -2
- package/src/kinds/shared.ts +11 -0
- package/src/overdue.ts +45 -0
- package/src/render.ts +7 -3
- package/src/router/budget.ts +37 -4
- package/src/router/providers/claude.ts +105 -18
- package/src/router/providers/grok.ts +1 -2
- package/src/router/route.ts +136 -13
- package/src/runtime/worker.ts +52 -5
- package/src/types.ts +52 -6
package/src/discover.ts
CHANGED
|
@@ -17,7 +17,7 @@ const NAMING_KEYS = ["labels", "mergeMethod"]
|
|
|
17
17
|
const LABEL_KEYS = ["claim", "failed", "park", "priority"]
|
|
18
18
|
// `options` is opaque to the loader and validated by the kind; everything else
|
|
19
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"]
|
|
20
|
+
const JOB_KEYS = ["kind", "repo", "slots", "order", "model", "requires", "prefer", "distinctFrom", "ignoresSpawnCap", "ignoresReserve", "ignoresPace", "brief", "options"]
|
|
21
21
|
|
|
22
22
|
export interface LoadOpts {
|
|
23
23
|
kinds: Record<string, Kind>
|
|
@@ -182,6 +182,15 @@ function loadJob(
|
|
|
182
182
|
if (raw.distinctFrom !== undefined && typeof raw.distinctFrom !== "boolean") {
|
|
183
183
|
errs.push(`${at}: distinctFrom must be true or false`)
|
|
184
184
|
}
|
|
185
|
+
if (raw.ignoresSpawnCap !== undefined && typeof raw.ignoresSpawnCap !== "boolean") {
|
|
186
|
+
errs.push(`${at}: ignoresSpawnCap must be true or false`)
|
|
187
|
+
}
|
|
188
|
+
if (raw.ignoresPace !== undefined && typeof raw.ignoresPace !== "boolean") {
|
|
189
|
+
errs.push(`${at}: ignoresPace must be true or false`)
|
|
190
|
+
}
|
|
191
|
+
if (raw.ignoresReserve !== undefined && typeof raw.ignoresReserve !== "boolean") {
|
|
192
|
+
errs.push(`${at}: ignoresReserve must be true or false`)
|
|
193
|
+
}
|
|
185
194
|
if (raw.model !== undefined && (typeof raw.model !== "string" || !raw.model)) {
|
|
186
195
|
errs.push(`${at}: model must be the agent's own model name, like opus or sonnet`)
|
|
187
196
|
}
|
|
@@ -217,6 +226,9 @@ function loadJob(
|
|
|
217
226
|
requires: requires.length ? requires : undefined,
|
|
218
227
|
prefer: prefer.length ? prefer : undefined,
|
|
219
228
|
distinctFrom: raw.distinctFrom === true ? true : built.distinctFrom,
|
|
229
|
+
ignoresSpawnCap: raw.ignoresSpawnCap === true ? true : built.ignoresSpawnCap,
|
|
230
|
+
ignoresReserve: raw.ignoresReserve === true ? true : built.ignoresReserve,
|
|
231
|
+
ignoresPace: raw.ignoresPace === true ? true : built.ignoresPace,
|
|
220
232
|
}
|
|
221
233
|
}
|
|
222
234
|
|
package/src/effects/monitor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { worktreePath, matchesCwd } from "../engine/naming"
|
|
|
3
3
|
import { itemKind, repoOf, trackerless } from "../engine/item"
|
|
4
4
|
import { startWorker } from "../runtime/worker"
|
|
5
5
|
import { preClean } from "./spawn"
|
|
6
|
+
import { appendJournal } from "../journal"
|
|
6
7
|
|
|
7
8
|
// The same write as the spawn rollback's. A finished item keeps its claim
|
|
8
9
|
// label otherwise, and discoverClaimed reads --state all, so the label would
|
|
@@ -145,4 +146,17 @@ export async function applyFail(ctx: Ctx, p: Job, item: WorkItem, key: string):
|
|
|
145
146
|
item.number,
|
|
146
147
|
`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
|
)
|
|
149
|
+
// Last, because the comment is the post-mortem a human actually answers and
|
|
150
|
+
// must not be lost to a journal that cannot be written. The label above
|
|
151
|
+
// already stops the item being re-picked, so there is nothing to race.
|
|
152
|
+
//
|
|
153
|
+
// Every other outcome writes a journal line and this one did not, so when six
|
|
154
|
+
// builders failed inside seventeen minutes on 2026-09-01 they left nothing
|
|
155
|
+
// behind but a label each, and reconstructing it took the tick log and six
|
|
156
|
+
// workers' transcripts. One line: the tail belongs on the item, where the
|
|
157
|
+
// human answering it needs it, and the journal is read as a story of the day.
|
|
158
|
+
appendJournal(
|
|
159
|
+
ctx,
|
|
160
|
+
`FAIL ${p.name} ${key}: gave up and labelled ${labels.failed} on ${repo}#${item.number}, transcript tail on the item`,
|
|
161
|
+
)
|
|
148
162
|
}
|
package/src/effects/sweep.ts
CHANGED
|
@@ -3,16 +3,20 @@ import type { Worktree } from "../adapters/git"
|
|
|
3
3
|
import { matchesCwd } from "../engine/naming"
|
|
4
4
|
import { withRepoLock } from "../lock"
|
|
5
5
|
|
|
6
|
+
// By tab id from pane list: a workspace id would close the whole loop
|
|
7
|
+
// workspace and every other worker in it, and a worker whose agent already
|
|
8
|
+
// exited still has a tab, so pane list rather than agent list.
|
|
9
|
+
export async function applyReap(ctx: Ctx, wt: Worktree): Promise<void> {
|
|
10
|
+
const panes = await ctx.cache("engine:panes", () => ctx.herdr.panes())
|
|
11
|
+
const pane = panes.find((x) => matchesCwd(x.cwd, wt.path))
|
|
12
|
+
if (pane) await ctx.herdr.tabClose(pane.tabId)
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export async function applySweep(ctx: Ctx, p: Job, wt: Worktree): Promise<void> {
|
|
7
16
|
const repo = ctx.workspace.repos[p.repo ?? ""]
|
|
8
17
|
if (!repo) return
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
// workspace and every other worker in it, and a worker whose agent already
|
|
12
|
-
// exited still has a tab, so pane list rather than agent list.
|
|
13
|
-
const panes = await ctx.cache("engine:panes", () => ctx.herdr.panes())
|
|
14
|
-
const pane = panes.find((x) => matchesCwd(x.cwd, wt.path))
|
|
15
|
-
if (pane) await ctx.herdr.tabClose(pane.tabId)
|
|
19
|
+
await applyReap(ctx, wt)
|
|
16
20
|
|
|
17
21
|
const git = ctx.git(repo)
|
|
18
22
|
await withRepoLock(repo, ctx.lock, async () => {
|
package/src/engine/monitor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Ctx, Job, Decision, MonitorAction } from "../types"
|
|
2
2
|
import { worktreePath, matchesCwd } from "./naming"
|
|
3
|
+
import { notifyOverdue } from "../overdue"
|
|
3
4
|
import { applyDone, applyNudge, applyEscalate, applyRestart, applyFail, applyNotifyBlocked } from "../effects/monitor"
|
|
4
5
|
|
|
5
6
|
export async function monitorJob(ctx: Ctx, p: Job): Promise<Decision[]> {
|
|
@@ -47,7 +48,11 @@ export async function monitorJob(ctx: Ctx, p: Job): Promise<Decision[]> {
|
|
|
47
48
|
const agent = agents.find((a) => matchesCwd(a.cwd, wt))
|
|
48
49
|
|
|
49
50
|
if (agent?.status === "working") {
|
|
50
|
-
out
|
|
51
|
+
// Working is the one agent state with no way out of it: an agent that
|
|
52
|
+
// never stops reporting it keeps its claim and its job's slot for good.
|
|
53
|
+
// Say so once and go on holding, since the agent may be genuinely busy.
|
|
54
|
+
const overdue = await notifyOverdue(ctx, p.name, key, "still working")
|
|
55
|
+
out.push(overdue ? mk("overdue", overdue) : mk("busy", "agent working"))
|
|
51
56
|
continue
|
|
52
57
|
}
|
|
53
58
|
|
package/src/engine/sweep.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { Ctx, Job, Decision, WorkItem } from "../types"
|
|
1
|
+
import type { Ctx, Job, Decision, WorkItem, AgentStatus } from "../types"
|
|
2
|
+
import type { Worktree } from "../adapters/git"
|
|
2
3
|
import { owns, keyOf, matchesCwd } from "./naming"
|
|
3
|
-
import { applySweep } from "../effects/sweep"
|
|
4
|
+
import { applySweep, applyReap } from "../effects/sweep"
|
|
5
|
+
import { notifyOverdue } from "../overdue"
|
|
4
6
|
import { auditFiling } from "../filing"
|
|
5
7
|
import { appendJournal } from "../journal"
|
|
6
8
|
import { renderDecision } from "../render"
|
|
@@ -22,6 +24,41 @@ async function isFinished(ctx: Ctx, p: Job, rawKey: string): Promise<boolean> {
|
|
|
22
24
|
return p.done(ctx, synthetic)
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
// An agent in one of these has nothing left to run: "idle" is a session
|
|
28
|
+
// sitting at its prompt and "done" is one herdr has seen finish. "working" is
|
|
29
|
+
// still going and "blocked" wants a human, so neither is ever cut off here.
|
|
30
|
+
const REAPABLE: AgentStatus[] = ["idle", "done"]
|
|
31
|
+
|
|
32
|
+
// A worker that finishes its round and never exits keeps counting against its
|
|
33
|
+
// account in inFlightByAccount, for as long as its worktree waits on a
|
|
34
|
+
// sweepOk somebody outside the loop owns, which for a reviewer is until the
|
|
35
|
+
// pull request closes. The monitor cannot help: done() has already turned
|
|
36
|
+
// true, so the item lost its claim and monitor stopped looking at it. On
|
|
37
|
+
// 2026-09-07 two of them held the only account maplista may use, one for nine
|
|
38
|
+
// hours and one for eight, and every tick in between read as a healthy
|
|
39
|
+
// STARVED. Close the tab and leave the worktree, which is the half of a sweep
|
|
40
|
+
// that is safe while sweepOk is still false.
|
|
41
|
+
async function reapable(ctx: Ctx, p: Job, wt: Worktree, rawKey: string): Promise<string | null> {
|
|
42
|
+
const stale = ctx.config.staleAgentMin
|
|
43
|
+
if (!stale) return null
|
|
44
|
+
const agents = await ctx.cache("engine:agents", () => ctx.herdr.agents())
|
|
45
|
+
const agent = agents.find((a) => matchesCwd(a.cwd, wt.path) && REAPABLE.includes(a.status))
|
|
46
|
+
if (!agent) {
|
|
47
|
+
// It went away on its own, or went back to working. Either way the clock
|
|
48
|
+
// this run started should not carry into the next idle spell.
|
|
49
|
+
ctx.marks.clear(p.name, rawKey, "stale")
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
const age = ctx.marks.age(p.name, rawKey, "stale")
|
|
53
|
+
if (age === null) {
|
|
54
|
+
ctx.marks.set(p.name, rawKey, "stale")
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
if (age < stale) return null
|
|
58
|
+
ctx.marks.clear(p.name, rawKey, "stale")
|
|
59
|
+
return `agent ${agent.status} ${age}m >= ${stale}m, closing the tab`
|
|
60
|
+
}
|
|
61
|
+
|
|
25
62
|
export async function sweepJob(ctx: Ctx, p: Job): Promise<Decision[]> {
|
|
26
63
|
const repo = ctx.workspace.repos[p.repo ?? ""]
|
|
27
64
|
if (!repo) return []
|
|
@@ -36,18 +73,39 @@ export async function sweepJob(ctx: Ctx, p: Job): Promise<Decision[]> {
|
|
|
36
73
|
for (const wt of worktrees) {
|
|
37
74
|
if (!owns(p.name, base, wt)) continue
|
|
38
75
|
const rawKey = keyOf(p.name, wt.branch)!
|
|
39
|
-
const mk = (action: "clean" | "hold", reason: string): Decision => ({
|
|
76
|
+
const mk = (action: "clean" | "hold" | "overdue" | "reap", reason: string): Decision => ({
|
|
40
77
|
pass: "sweep", job: p.name, worktree: wt.path, branch: wt.branch!, action, reason,
|
|
41
78
|
})
|
|
79
|
+
// Both holds below are unbounded by design, and this pass is the only one
|
|
80
|
+
// that sees the second of them: an item whose done() has turned true has
|
|
81
|
+
// already lost its claim, so monitor stops looking at it while its worktree
|
|
82
|
+
// waits here on a sweepOk that something outside the loop owns.
|
|
83
|
+
const held = async (reason: string, what: string) => {
|
|
84
|
+
const overdue = await notifyOverdue(ctx, p.name, rawKey, what)
|
|
85
|
+
out.push(overdue ? mk("overdue", overdue) : mk("hold", reason))
|
|
86
|
+
}
|
|
42
87
|
|
|
43
88
|
const live = agents.some((a) => a.status === "working" && matchesCwd(a.cwd, wt.path))
|
|
44
89
|
if (live && !p.sweepIgnoresWorking) {
|
|
45
|
-
|
|
90
|
+
await held("agent working", "holding a worktree open for a working agent")
|
|
46
91
|
continue
|
|
47
92
|
}
|
|
48
93
|
const predicate = p.sweepOk ? "sweepOk" : "done"
|
|
49
94
|
if (!(await isFinished(ctx, p, rawKey))) {
|
|
50
|
-
|
|
95
|
+
const reason = await reapable(ctx, p, wt, rawKey)
|
|
96
|
+
if (reason) {
|
|
97
|
+
out.push(mk("reap", reason))
|
|
98
|
+
if (ctx.live) {
|
|
99
|
+
try {
|
|
100
|
+
await applyReap(ctx, wt)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// The worktree still holds below either way: a tab that will not
|
|
103
|
+
// close is a slot left occupied, not a reason to skip the hold.
|
|
104
|
+
out.push({ pass: "error", job: p.name, where: "sweep", reason: String(err) })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await held(`${predicate}(${rawKey}) false`, `waiting on ${predicate}(${rawKey})`)
|
|
51
109
|
continue
|
|
52
110
|
}
|
|
53
111
|
out.push(mk("clean", `${predicate}(${rawKey})`))
|
package/src/globalstate.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface UsageSample {
|
|
|
10
10
|
export interface GlobalStore {
|
|
11
11
|
recordUsage(accountId: string, w: Window): void
|
|
12
12
|
lastUsage(accountId: string, kind: string, beforeMs: number): UsageSample | null
|
|
13
|
+
// The newest row per kind, for an account the reader cannot reach right now.
|
|
14
|
+
// `group` and `scope` are not stored, so what comes back prices a window and
|
|
15
|
+
// does not describe one: group falls back to the kind, and a model scope is
|
|
16
|
+
// gone. Both are read before a window is recorded, never after.
|
|
17
|
+
lastWindows(accountId: string, notBeforeMs: number): Window[]
|
|
13
18
|
rate(provider: string, kind: string): number | null
|
|
14
19
|
observeRate(provider: string, kind: string, sample: number): number
|
|
15
20
|
spawnAdd(accountId: string, workspace: string, job: string, key: string, at: Date): void
|
|
@@ -80,6 +85,16 @@ export function openGlobalState(path: string): GlobalStore {
|
|
|
80
85
|
WHERE account = ? AND kind = ? AND at < ?
|
|
81
86
|
ORDER BY at DESC LIMIT 1`,
|
|
82
87
|
)
|
|
88
|
+
// Bare columns beside MAX(at): SQLite defines them as coming from the row
|
|
89
|
+
// the aggregate chose, which is the whole point here. One row per kind.
|
|
90
|
+
const recentUsage = db.query<
|
|
91
|
+
{ kind: string; percent: number; resets_at: number; window_minutes: number; at: number },
|
|
92
|
+
[string, number]
|
|
93
|
+
>(
|
|
94
|
+
`SELECT kind, percent, resets_at, window_minutes, MAX(at) AS at FROM usage
|
|
95
|
+
WHERE account = ? AND at >= ?
|
|
96
|
+
GROUP BY kind`,
|
|
97
|
+
)
|
|
83
98
|
const getRate = db.query<{ ewma: number }, [string, string]>(
|
|
84
99
|
"SELECT ewma FROM rates WHERE provider = ? AND kind = ?",
|
|
85
100
|
)
|
|
@@ -134,6 +149,16 @@ export function openGlobalState(path: string): GlobalStore {
|
|
|
134
149
|
lastUsage(accountId, kind, beforeMs) {
|
|
135
150
|
return prevUsage.get(accountId, kind, beforeMs)
|
|
136
151
|
},
|
|
152
|
+
lastWindows(accountId, notBeforeMs) {
|
|
153
|
+
return recentUsage.all(accountId, notBeforeMs).map((r) => ({
|
|
154
|
+
kind: r.kind,
|
|
155
|
+
group: r.kind,
|
|
156
|
+
percent: r.percent,
|
|
157
|
+
resetsAt: new Date(r.resets_at),
|
|
158
|
+
windowMinutes: r.window_minutes,
|
|
159
|
+
observedAt: new Date(r.at),
|
|
160
|
+
}))
|
|
161
|
+
},
|
|
137
162
|
rate(provider, kind) {
|
|
138
163
|
return getRate.get(provider, kind)?.ewma ?? null
|
|
139
164
|
},
|
package/src/kinds/builder.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { Job, WorkItem, Ctx } from "../types"
|
|
2
2
|
import type { Kind } from "./validate"
|
|
3
|
-
import { issues, prs, unblocked, byPriority, newestByHead } from "./shared"
|
|
3
|
+
import { issues, prs, unblocked, byPriority, newestByHead, humanOwned } from "./shared"
|
|
4
4
|
import { renderBrief } from "../brief"
|
|
5
5
|
import { branchName } from "../engine/naming"
|
|
6
|
+
import { repoOf, itemKind } from "../engine/item"
|
|
7
|
+
|
|
8
|
+
// The one reading of a closed pull request, shared by guard(), done() and
|
|
9
|
+
// sweepOk() because the three have to agree about it: whichever of them reads
|
|
10
|
+
// it differently is the one that makes the loop spawn, release and sweep the
|
|
11
|
+
// same issue every tick. Human-owned is not a retry: `agent-failed` on a pull
|
|
12
|
+
// request is the monitor's tombstone and `needs-human` is a question nobody
|
|
13
|
+
// answered, and re-picking either is the loop overruling the human it asked.
|
|
14
|
+
function retriable(ctx: Ctx, pr: WorkItem): boolean {
|
|
15
|
+
return pr.state === "CLOSED" && !humanOwned(ctx, pr)
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
interface Options {
|
|
8
19
|
base: string
|
|
@@ -95,25 +106,75 @@ export const builder: Kind = {
|
|
|
95
106
|
// issue stays open until the merge closes it, so without this the next
|
|
96
107
|
// tick re-picks the issue and the spawn's pre-clean destroys the
|
|
97
108
|
// worktree the reviewer's rounds are still working in.
|
|
109
|
+
//
|
|
110
|
+
// A human-owned closed pull request is the other answer, and until
|
|
111
|
+
// 2026-09-06 it was a silent one: the park or failed label sat on a
|
|
112
|
+
// closed pull request, which no backlog view shows, while the issue kept
|
|
113
|
+
// its plain labels and looked pickable. Six issues sat that way (#219,
|
|
114
|
+
// #292, #415, #447, #551, #555), skipped here on every tick with nothing
|
|
115
|
+
// in the log and nothing on the issue. Mirroring the label onto the issue
|
|
116
|
+
// makes the backlog say what the loop knows: unblocked() then drops it
|
|
117
|
+
// from discover(), sweepOk() lets its worktrees go, and a human sees it
|
|
118
|
+
// in the same list as every other parked item.
|
|
98
119
|
guard: async (ctx, item) => {
|
|
99
120
|
const pr = await prFor(ctx, keyFor(item))
|
|
100
|
-
|
|
101
|
-
|
|
121
|
+
if (pr === null || retriable(ctx, pr)) return true
|
|
122
|
+
const l = ctx.workspace.naming.labels
|
|
123
|
+
const add = [l.park, l.failed].filter((x) => pr.labels.includes(x) && !item.labels.includes(x))
|
|
124
|
+
if (add.length && ctx.live) await ctx.gh.label(repoOf(item), itemKind(item), item.number, { add })
|
|
125
|
+
return false
|
|
102
126
|
},
|
|
103
127
|
|
|
104
128
|
// The work is over when the pull request exists: what happens to it after
|
|
105
129
|
// that belongs to the reviewer, and holding the claim would count this
|
|
106
130
|
// issue against the builder's slots through every review round.
|
|
131
|
+
//
|
|
132
|
+
// Except for the one pull request guard() re-picks. This read "a pull
|
|
133
|
+
// request exists" until 2026-09-02, when a closed one made the two
|
|
134
|
+
// disagree: the retry was declared finished before its worker had done
|
|
135
|
+
// anything, the claim came off, and the next tick picked the same issue
|
|
136
|
+
// again. 41 workers on one issue in 82 minutes, the box's whole daily
|
|
137
|
+
// spawn budget, and the scheduled routines sharing the box missed their
|
|
138
|
+
// slots behind a cap they had not spent.
|
|
107
139
|
async done(ctx, item) {
|
|
108
|
-
|
|
140
|
+
const pr = await prFor(ctx, keyFor(item))
|
|
141
|
+
return pr !== null && !retriable(ctx, pr)
|
|
109
142
|
},
|
|
110
143
|
|
|
111
144
|
// Not done: the worktree has to survive until the pull request is
|
|
112
145
|
// finished, because the reviewer's rounds ask the builder's worker for
|
|
113
146
|
// changes in it.
|
|
147
|
+
//
|
|
148
|
+
// With no pull request at all there is nothing to wait for, and a builder
|
|
149
|
+
// that fails opens none: it labels its issue for a human and exits. Read
|
|
150
|
+
// as "no pull request yet" that answered false forever, and a hold never
|
|
151
|
+
// kills, so six dead worktrees and their tabs accumulated on one box in a
|
|
152
|
+
// day while the log carried a HOLD line for each every two minutes. The
|
|
153
|
+
// park and failed labels are the two states a human owns, and a human
|
|
154
|
+
// owning the item is when the machine should let go. The pull request
|
|
155
|
+
// still decides whenever there is one: a failed label on an issue whose
|
|
156
|
+
// pull request is open is a review round that found something, and its
|
|
157
|
+
// worktree is where the next round works.
|
|
114
158
|
async sweepOk(ctx, rawKey) {
|
|
115
159
|
const pr = await prFor(ctx, rawKey)
|
|
116
|
-
|
|
160
|
+
// The label on the pull request itself, which is the monitor
|
|
161
|
+
// tombstoning it rather than a review round asking for changes. A
|
|
162
|
+
// failed pull request is out of the reviewer's discovery, so nothing
|
|
163
|
+
// will ever move it and both worktrees wait on a state change only a
|
|
164
|
+
// human can cause: two pairs sat 19 and 21 hours that way, holding
|
|
165
|
+
// 1.4GB of live worker processes between them.
|
|
166
|
+
if (pr !== null && !retriable(ctx, pr)) return pr.state !== "OPEN" || humanOwned(ctx, pr)
|
|
167
|
+
// No pull request, or the one guard() is going to try again: either way
|
|
168
|
+
// the worktree belongs to a run that is not finished. sweepIgnoresWorking
|
|
169
|
+
// is on for builders, so nothing else holds it, and a retry whose fresh
|
|
170
|
+
// worktree is deleted ninety seconds in works in a directory that no
|
|
171
|
+
// longer exists. The issue decides instead: a closed one is out of
|
|
172
|
+
// discover() and a human-owned one is a state only a human can change,
|
|
173
|
+
// so neither is ever picked up again and neither worktree is waiting
|
|
174
|
+
// for anything.
|
|
175
|
+
const number = Number(rawKey.replace(/^\D+/, ""))
|
|
176
|
+
const issue = (await issues(ctx, job, "all")).find((i) => i.number === number)
|
|
177
|
+
return issue !== undefined && (issue.state !== "OPEN" || humanOwned(ctx, issue))
|
|
117
178
|
},
|
|
118
179
|
|
|
119
180
|
brief: (ctx, item) =>
|
package/src/kinds/reviewer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Ctx, FilingConfig, Job, WorkItem } from "../types"
|
|
2
2
|
import { type Kind, oneOf, unknownKey } from "./validate"
|
|
3
|
-
import { issues, prs, unblocked } from "./shared"
|
|
3
|
+
import { issues, prs, unblocked, humanOwned } from "./shared"
|
|
4
4
|
import { renderBrief } from "../brief"
|
|
5
5
|
import { filingBudget } from "../filing"
|
|
6
6
|
import { repoOf } from "../engine/item"
|
|
@@ -173,9 +173,23 @@ export const reviewer: Kind = {
|
|
|
173
173
|
// tab forever.
|
|
174
174
|
async sweepOk(ctx, rawKey) {
|
|
175
175
|
const number = numberOf(rawKey)
|
|
176
|
+
// Before the identity lookups, because those answer on the issue and an
|
|
177
|
+
// issue stays open while its own pull request is tombstoned: the
|
|
178
|
+
// monitor labels the pull request on failure, a labelled one is out of
|
|
179
|
+
// discovery, and the worktree then waits for a verdict nobody will ever
|
|
180
|
+
// give. Keyed through job.key so it holds under every identity, and
|
|
181
|
+
// only for pull requests actually carrying the label, which is a short
|
|
182
|
+
// list on any healthy day and empty on most.
|
|
183
|
+
for (const p of inScope(await prs(ctx, job, "all"))) {
|
|
184
|
+
if (p.state !== "OPEN" || !humanOwned(ctx, p)) continue
|
|
185
|
+
if ((await job.key(ctx, p)) === rawKey) return true
|
|
186
|
+
}
|
|
176
187
|
if (o.identity === "closing-issue") {
|
|
177
188
|
const issue = (await issues(ctx, job, "all")).find((i) => i.number === number)
|
|
178
|
-
|
|
189
|
+
// Human-owned is the builder's rule too: a parked issue is a state
|
|
190
|
+
// only a human can change, and r447 held its worktree 41 hours
|
|
191
|
+
// behind one on 2026-09-06 waiting for a close that never comes.
|
|
192
|
+
if (issue) return issue.state !== "OPEN" || humanOwned(ctx, issue)
|
|
179
193
|
}
|
|
180
194
|
if (o.identity === "head-ref-issue") {
|
|
181
195
|
const all = await prs(ctx, job, "all")
|
package/src/kinds/shared.ts
CHANGED
|
@@ -47,3 +47,14 @@ export function newestByHead(items: WorkItem[], head: string): WorkItem | null {
|
|
|
47
47
|
const mine = items.filter((i) => i.headRef === head)
|
|
48
48
|
return mine.length ? mine.reduce((a, b) => (b.number > a.number ? b : a)) : null
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
// The two labels a human owns: the park is a question nobody answered, the
|
|
52
|
+
// failed label is the monitor's tombstone. Either means no job will pick the
|
|
53
|
+
// item up again, so nothing the loop does will ever change its state, and a
|
|
54
|
+
// sweep predicate waiting on one waits forever. A human owning the item is
|
|
55
|
+
// exactly when the machine should let go of the worktree.
|
|
56
|
+
export function humanOwned(ctx: Ctx, item: WorkItem): boolean {
|
|
57
|
+
const l = ctx.workspace.naming.labels
|
|
58
|
+
const owned = [l.park, l.failed].filter(Boolean)
|
|
59
|
+
return item.labels.some((name) => owned.includes(name))
|
|
60
|
+
}
|
package/src/overdue.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Ctx } from "./types"
|
|
2
|
+
|
|
3
|
+
// Every hold in the loop is deliberate and fail-safe: it cannot kill a live
|
|
4
|
+
// agent and cannot tombstone an item, so whatever it fails to resolve it simply
|
|
5
|
+
// keeps. What it has no way to say is that it has been keeping it too long.
|
|
6
|
+
// `blocked` is the one state carrying a clock (blockedTimeoutMin, then
|
|
7
|
+
// escalate). A worker wedged in `working` and a worktree whose sweepOk never
|
|
8
|
+
// turns true both hold with nothing counting the minutes, and both did: a
|
|
9
|
+
// content run held its job's only slot for two hours behind a leftover shell,
|
|
10
|
+
// and a review worktree waited eight for a pull request CI had quietly declined
|
|
11
|
+
// to merge. Neither is visible in a tick log that says HOLD every two minutes
|
|
12
|
+
// and has said HOLD every two minutes for a week.
|
|
13
|
+
//
|
|
14
|
+
// So: the same clock, and nothing else. No escalation and no kill, because a
|
|
15
|
+
// hold is the right response to not knowing and a timer is a bad reason to
|
|
16
|
+
// interrupt a run that might be mid-write. Silence was the defect, not holding.
|
|
17
|
+
//
|
|
18
|
+
// The age comes from the `spawned` mark, which covers the whole occurrence
|
|
19
|
+
// rather than time since this pass first noticed. An occurrence with no such
|
|
20
|
+
// mark has no age to read and is left alone, so an adopted or imported worktree
|
|
21
|
+
// never pings on a number nobody set.
|
|
22
|
+
export async function notifyOverdue(
|
|
23
|
+
ctx: Ctx,
|
|
24
|
+
job: string,
|
|
25
|
+
key: string,
|
|
26
|
+
what: string,
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const timeout = ctx.config.holdTimeoutMin
|
|
29
|
+
if (!timeout) return null
|
|
30
|
+
const age = ctx.marks.age(job, key, "spawned")
|
|
31
|
+
if (age === null || age < timeout) return null
|
|
32
|
+
// Once per occurrence. The mark is what makes it once: a hold that lasts a
|
|
33
|
+
// day is one notification, not seven hundred.
|
|
34
|
+
if (ctx.marks.has(job, key, "overdue")) return null
|
|
35
|
+
ctx.marks.set(job, key, "overdue")
|
|
36
|
+
if (ctx.live) {
|
|
37
|
+
await ctx.herdr
|
|
38
|
+
.notify(
|
|
39
|
+
`${job} has been held ${age}m`,
|
|
40
|
+
`${job} ${key} is ${what}, and has been in flight ${age}m against a ${timeout}m timeout. Nothing is failing here, and nothing is finishing either.`,
|
|
41
|
+
)
|
|
42
|
+
.catch(() => {})
|
|
43
|
+
}
|
|
44
|
+
return `${what}, ${age}m >= ${timeout}m`
|
|
45
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -14,13 +14,17 @@ export function renderDecision(d: Decision, live = false): string {
|
|
|
14
14
|
case "tick":
|
|
15
15
|
return `TICK ${d.workspace} ${d.ms}ms`
|
|
16
16
|
case "sweep":
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
if (d.action === "clean") return `${would("sweep")} ${d.job} ${d.worktree} (${d.reason})`
|
|
18
|
+
// OVERDUE is a held worktree with a notification sent about it, so it
|
|
19
|
+
// reads as its own verb and appears once, not every tick like HOLD.
|
|
20
|
+
if (d.action === "overdue") return `OVERDUE ${d.job} ${d.worktree} (${d.reason})`
|
|
21
|
+
if (d.action === "reap") return `${would("reap")} ${d.job} ${d.worktree} (${d.reason})`
|
|
22
|
+
return `HOLD ${d.job} ${d.worktree} (${d.reason})`
|
|
20
23
|
case "monitor":
|
|
21
24
|
if (d.action === "busy") return `BUSY ${d.job} ${d.key} (${d.reason})`
|
|
22
25
|
if (d.action === "hold") return `HOLD ${d.job} ${d.key} (${d.reason})`
|
|
23
26
|
if (d.action === "blocked") return `BLOCKED ${d.job} ${d.key} (${d.reason})`
|
|
27
|
+
if (d.action === "overdue") return `OVERDUE ${d.job} ${d.key} (${d.reason})`
|
|
24
28
|
return WOULD.has(d.action)
|
|
25
29
|
? `${would(d.action)} ${d.job} ${d.key} (${d.reason})`
|
|
26
30
|
: `${d.action.toUpperCase()} ${d.job} ${d.key} (${d.reason})`
|
package/src/router/budget.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface BudgetIn {
|
|
|
12
12
|
usageMax: number
|
|
13
13
|
releaseBefore: number
|
|
14
14
|
maxConcurrent: number
|
|
15
|
+
// How long one worker runs, in minutes. Used to price a burst while the
|
|
16
|
+
// window is behind its line: a worker is only started when the points left
|
|
17
|
+
// can pay for a run of this length.
|
|
18
|
+
workerRunMin: number
|
|
15
19
|
// Percentage points per minute per worker for this window.
|
|
16
20
|
rateFor(w: Window): number
|
|
17
21
|
}
|
|
@@ -20,6 +24,11 @@ export interface BudgetOut {
|
|
|
20
24
|
concurrency: number
|
|
21
25
|
limiting: string
|
|
22
26
|
detail: string
|
|
27
|
+
// A zero that is the clock talking rather than the quota: the account is
|
|
28
|
+
// ahead of its line, but the points left still pay for a run. It clears on
|
|
29
|
+
// its own within the hour, so a job that unblocks others can be let through
|
|
30
|
+
// it, while a zero without this flag means there is nothing left to spend.
|
|
31
|
+
paused: boolean
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
// How much working time a human still has inside this window, in weekday
|
|
@@ -63,19 +72,43 @@ export function concurrencyFor(i: BudgetIn): BudgetOut {
|
|
|
63
72
|
const reserveNow =
|
|
64
73
|
minutesToReset <= i.releaseBefore ? 0 : Math.min(100, Math.max(i.reserve, perWeekday))
|
|
65
74
|
const ceiling = Math.min(i.usageMax, 100 - reserveNow)
|
|
66
|
-
const budgetRate = (ceiling - w.percent) / minutesToReset
|
|
67
75
|
const rate = i.rateFor(w)
|
|
68
|
-
|
|
76
|
+
// What one run costs, which is the unit everything below is counted in: a
|
|
77
|
+
// worker is started or it is not, and half a run is not a thing to allow.
|
|
78
|
+
const runCost = rate * Math.max(1, i.workerRunMin)
|
|
79
|
+
// The budget spent evenly across the window, which is what the account has
|
|
80
|
+
// earned the right to spend by now. Being under it is credit, being over
|
|
81
|
+
// it is a debt the clock pays off.
|
|
82
|
+
const elapsed = Math.min(1, Math.max(0, 1 - minutesToReset / Math.max(1, w.windowMinutes)))
|
|
83
|
+
const line = ceiling * elapsed
|
|
84
|
+
const credit = line - w.percent
|
|
85
|
+
// Never start a run the remaining points cannot pay for: usageMax exists so
|
|
86
|
+
// a worker does not meet a 429 mid-task, and that holds however far behind
|
|
87
|
+
// the line the account is.
|
|
88
|
+
const affordable = runCost > 0 ? (ceiling - w.percent) / runCost : 0
|
|
89
|
+
// One worker at the line, more the further behind it the account is, none
|
|
90
|
+
// while it is ahead. The previous rule priced a worker as running without a
|
|
91
|
+
// break until the window resets, which no worker here does: a lane waits on
|
|
92
|
+
// continuous integration, on a review round, on a pull request closing. On
|
|
93
|
+
// a seven-day window that arithmetic asked for 171 points of headroom
|
|
94
|
+
// before it would allow a second worker against a ceiling of 75, so it
|
|
95
|
+
// could only ever allow one, and it dropped that one to zero at 19 percent
|
|
96
|
+
// spent. It also inverts near a reset, because the divisor shrinks: the
|
|
97
|
+
// maplista lane ran 102 and 100 spawns on the two days before its weekly
|
|
98
|
+
// reset and 22 on the day after, and each of those weeks still ended with
|
|
99
|
+
// 35 to 73 points expiring unspent.
|
|
100
|
+
const workers = w.percent > line ? 0 : Math.min(affordable, Math.max(1, credit / runCost))
|
|
69
101
|
const concurrency = Math.max(0, Math.min(i.maxConcurrent, Math.round(workers)))
|
|
70
102
|
|
|
71
103
|
if (!best || concurrency < best.concurrency) {
|
|
72
104
|
best = {
|
|
73
105
|
concurrency,
|
|
74
106
|
limiting: w.kind,
|
|
75
|
-
detail: `${w.percent.toFixed(1)}% of ${ceiling.toFixed(1)} with ${Math.round(minutesToReset)}m left`,
|
|
107
|
+
detail: `${w.percent.toFixed(1)}% of ${ceiling.toFixed(1)}, line ${line.toFixed(1)}, with ${Math.round(minutesToReset)}m left`,
|
|
108
|
+
paused: concurrency === 0 && w.percent > line && affordable >= 1,
|
|
76
109
|
}
|
|
77
110
|
}
|
|
78
111
|
}
|
|
79
112
|
|
|
80
|
-
return best ?? { concurrency: 0, limiting: "none", detail: "no windows" }
|
|
113
|
+
return best ?? { concurrency: 0, limiting: "none", detail: "no windows", paused: false }
|
|
81
114
|
}
|