@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AccountConfig, AccountUsage, UsageReader, Window } from "../../types"
|
|
2
2
|
import { CLAUDE_WINDOW_MINUTES, checkWindows } from "../window"
|
|
3
3
|
import { expandHome } from "../../paths"
|
|
4
|
-
import { renameSync, writeFileSync, readFileSync, unlinkSync, statSync } from "node:fs"
|
|
4
|
+
import { renameSync, writeFileSync, readFileSync, unlinkSync, statSync, mkdirSync } from "node:fs"
|
|
5
|
+
import { dirname } from "node:path"
|
|
5
6
|
|
|
6
7
|
export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
7
8
|
export const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
@@ -11,6 +12,15 @@ export const DEFAULT_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
|
11
12
|
// Access tokens live 8 hours. Refresh inside the last 10 minutes so a tick never
|
|
12
13
|
// starts a read against a token that expires mid-flight.
|
|
13
14
|
export const REFRESH_MARGIN_MS = 10 * 60_000
|
|
15
|
+
// The usage endpoint answers about 5 calls per token per minute, and that
|
|
16
|
+
// budget is not ours alone: an interactive claude spends it on its status
|
|
17
|
+
// line, `c` spends it on its account table, and two workspaces tick in the
|
|
18
|
+
// same minute. So the answer is cached in the account's own config dir, in
|
|
19
|
+
// the format `c` reads and writes, and whoever asks first pays for everyone.
|
|
20
|
+
export const USAGE_TTL_MS = 60_000
|
|
21
|
+
// A 429 is that burst limit, not a quota. Asking again a minute later just
|
|
22
|
+
// draws from the same empty bucket, so hold off longer.
|
|
23
|
+
export const USAGE_429_TTL_MS = 5 * 60_000
|
|
14
24
|
|
|
15
25
|
export interface Creds {
|
|
16
26
|
accessToken: string
|
|
@@ -22,7 +32,46 @@ export interface Creds {
|
|
|
22
32
|
export interface ClaudeDeps {
|
|
23
33
|
readCreds(configDir: string): Promise<Creds | null>
|
|
24
34
|
refresh(c: Creds, clientId: string, configDir: string): Promise<Creds>
|
|
25
|
-
|
|
35
|
+
// configDir only so the live implementation can share its answer through
|
|
36
|
+
// that account's cache. A reader that has its own source ignores it.
|
|
37
|
+
getUsage(token: string, configDir: string): Promise<{ status: number; body: any }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CachedUsage {
|
|
41
|
+
at: number
|
|
42
|
+
status: number
|
|
43
|
+
body: any
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const usageCachePath = (configDir: string): string =>
|
|
47
|
+
`${expandHome(configDir)}/cache/usage.json`
|
|
48
|
+
|
|
49
|
+
export function readUsageCache(configDir: string, now: number): CachedUsage | null {
|
|
50
|
+
let c: CachedUsage
|
|
51
|
+
try {
|
|
52
|
+
c = JSON.parse(readFileSync(usageCachePath(configDir), "utf8"))
|
|
53
|
+
} catch {
|
|
54
|
+
// Absent, or half-written by a reader that lost the race. Both mean ask.
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
if (!c?.at || typeof c.status !== "number") return null
|
|
58
|
+
return now - c.at < (c.status === 429 ? USAGE_429_TTL_MS : USAGE_TTL_MS) ? c : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Body verbatim, because the readers of this file want different parts of it:
|
|
62
|
+
// `limits[]` here, `five_hour` in c. Neither has to know about the other.
|
|
63
|
+
export function writeUsageCache(configDir: string, entry: CachedUsage): void {
|
|
64
|
+
const path = usageCachePath(configDir)
|
|
65
|
+
// Unique per writer, for the reason spelled out in writeCreds.
|
|
66
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
69
|
+
writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 })
|
|
70
|
+
renameSync(tmp, path)
|
|
71
|
+
} catch {
|
|
72
|
+
// A cache we cannot write costs a call, not a tick.
|
|
73
|
+
try { unlinkSync(tmp) } catch {}
|
|
74
|
+
}
|
|
26
75
|
}
|
|
27
76
|
|
|
28
77
|
// Atomic: a torn credentials file locks the account out until a human logs in
|
|
@@ -53,8 +102,21 @@ export function writeCreds(configDir: string, creds: Creds): void {
|
|
|
53
102
|
}
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
const no = (reason: string
|
|
57
|
-
|
|
105
|
+
const no = (reason: string): AccountUsage => ({ readable: false, reason })
|
|
106
|
+
|
|
107
|
+
// The endpoint was busy or briefly broken and the account is fine, so an older
|
|
108
|
+
// reading may still price it. Distinct from every other failure here: missing
|
|
109
|
+
// credentials and a refused refresh are facts about the account, and a worker
|
|
110
|
+
// started on one of those meets "Invalid API key, please run /login" instead of
|
|
111
|
+
// its brief.
|
|
112
|
+
const busy = (reason: string): AccountUsage => ({ readable: false, reason, transient: true })
|
|
113
|
+
|
|
114
|
+
// A window that has never started has nothing to reset, so a brand new login
|
|
115
|
+
// answers 200 with no window carrying a resets_at. That is an account with no
|
|
116
|
+
// usage yet, not a broken one, and the router admits one worker on it rather
|
|
117
|
+
// than skipping it forever: nothing else on the box will record the first
|
|
118
|
+
// window for it.
|
|
119
|
+
const fresh = (reason: string): AccountUsage => ({ readable: false, reason, fresh: true })
|
|
58
120
|
|
|
59
121
|
function resetsAtOf(raw: unknown): Date | null {
|
|
60
122
|
if (raw === null || raw === undefined) return null
|
|
@@ -80,20 +142,23 @@ export function makeClaudeReader(d: ClaudeDeps): UsageReader {
|
|
|
80
142
|
}
|
|
81
143
|
}
|
|
82
144
|
|
|
83
|
-
let res = await d.getUsage(creds.accessToken)
|
|
145
|
+
let res = await d.getUsage(creds.accessToken, a.configDir)
|
|
84
146
|
if (res.status === 401) {
|
|
85
147
|
try {
|
|
86
148
|
creds = await d.refresh(creds, clientId, a.configDir)
|
|
87
149
|
} catch (err) {
|
|
88
150
|
return no(`refresh after 401 failed: ${err}`)
|
|
89
151
|
}
|
|
90
|
-
res = await d.getUsage(creds.accessToken)
|
|
152
|
+
res = await d.getUsage(creds.accessToken, a.configDir)
|
|
91
153
|
if (res.status === 401) return no("401 after refresh")
|
|
92
154
|
}
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
// Unreadable, and nothing stronger. A 429 here is the metering endpoint's
|
|
156
|
+
// own burst budget, which anything else polling this account can spend on
|
|
157
|
+
// our behalf, and it says nothing about quota: an account measured at 3%
|
|
158
|
+
// of its session and 39% of its week answers 429 on the fifth call in a
|
|
159
|
+
// minute. Real exhaustion arrives as a 200 with the windows at 100%.
|
|
160
|
+
if (res.status === 429) return busy("429 from the usage endpoint (burst limit, not quota)")
|
|
161
|
+
if (res.status !== 200) return busy(`usage endpoint ${res.status}`)
|
|
97
162
|
|
|
98
163
|
const windows: Window[] = []
|
|
99
164
|
for (const l of (res.body?.limits ?? []) as any[]) {
|
|
@@ -119,7 +184,7 @@ export function makeClaudeReader(d: ClaudeDeps): UsageReader {
|
|
|
119
184
|
})
|
|
120
185
|
}
|
|
121
186
|
|
|
122
|
-
if (windows.length === 0) return
|
|
187
|
+
if (windows.length === 0) return fresh("no usage windows yet, nothing has run on this account")
|
|
123
188
|
const bad = checkWindows(windows, now)
|
|
124
189
|
return bad ? no(bad) : { readable: true, windows }
|
|
125
190
|
}
|
|
@@ -153,25 +218,47 @@ export function liveClaudeDeps(live = false): ClaudeDeps {
|
|
|
153
218
|
const j: any = await r.json()
|
|
154
219
|
const next = {
|
|
155
220
|
accessToken: String(j.access_token),
|
|
156
|
-
|
|
221
|
+
// The response's own refresh token when it carries one, because the
|
|
222
|
+
// endpoint may rotate: it hands back a new one and invalidates the one
|
|
223
|
+
// that bought it. Keeping the old one and writing it back over the file
|
|
224
|
+
// locked an account out twice in two days, the next refresh answering
|
|
225
|
+
// `invalid_grant, Refresh token not found or invalid` with the stored
|
|
226
|
+
// expiry still 27 days away and only an interactive login able to
|
|
227
|
+
// recover it. Worse on an account a human also uses: their session
|
|
228
|
+
// refreshes, stores the new token, and this wrote the dead one back
|
|
229
|
+
// over it. A response with no refresh_token means the stored one is
|
|
230
|
+
// still live, so it has to survive rather than become undefined.
|
|
231
|
+
refreshToken: j.refresh_token ? String(j.refresh_token) : c.refreshToken,
|
|
157
232
|
expiresAt: Date.now() + Number(j.expires_in ?? 0) * 1000,
|
|
158
233
|
refreshTokenExpiresAt: c.refreshTokenExpiresAt,
|
|
159
234
|
}
|
|
160
|
-
// A dry run keeps the refreshed token in memory
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
235
|
+
// A dry run keeps the refreshed token in memory rather than writing it,
|
|
236
|
+
// so the file stays the live agent's to own. A live run writes it back,
|
|
237
|
+
// because an account nobody refreshes goes blind within a day, and
|
|
238
|
+
// because a rotated token that is never stored is a token thrown away.
|
|
164
239
|
if (live) writeCreds(configDir, next)
|
|
165
240
|
return next
|
|
166
241
|
},
|
|
167
|
-
async getUsage(token) {
|
|
242
|
+
async getUsage(token, configDir) {
|
|
243
|
+
const hit = readUsageCache(configDir, Date.now())
|
|
244
|
+
if (hit) return { status: hit.status, body: hit.body }
|
|
245
|
+
|
|
168
246
|
const r = await fetch(USAGE_URL, {
|
|
169
247
|
headers: {
|
|
170
248
|
authorization: `Bearer ${token}`,
|
|
171
249
|
"anthropic-beta": "oauth-2025-04-20",
|
|
172
250
|
},
|
|
173
251
|
})
|
|
174
|
-
|
|
252
|
+
const entry: CachedUsage = {
|
|
253
|
+
at: Date.now(),
|
|
254
|
+
status: r.status,
|
|
255
|
+
body: r.status === 200 ? await r.json() : null,
|
|
256
|
+
}
|
|
257
|
+
// Never cache a 401: it is a fact about this token, not about the
|
|
258
|
+
// account, and the caller refreshes and retries immediately. A cached
|
|
259
|
+
// one would answer that retry with the answer it just refreshed away.
|
|
260
|
+
if (entry.status !== 401) writeUsageCache(configDir, entry)
|
|
261
|
+
return { status: entry.status, body: entry.body }
|
|
175
262
|
},
|
|
176
263
|
}
|
|
177
264
|
}
|
|
@@ -2,8 +2,7 @@ import type { UsageReader } from "../../types"
|
|
|
2
2
|
|
|
3
3
|
// No usage signal exists: the billing record carries a period boundary and
|
|
4
4
|
// zeroed on-demand credits, and the session logs record spend, not remaining.
|
|
5
|
-
// Unreadable,
|
|
6
|
-
// the account back in.
|
|
5
|
+
// Unreadable, so it runs only where allowWhenUnreadable opts it back in.
|
|
7
6
|
export const grokReader: UsageReader = async () => ({
|
|
8
7
|
readable: false,
|
|
9
8
|
reason: "no usage signal exists for this provider",
|
package/src/router/route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { AccountConfig, AccountUsage, Ctx, Job, WorkItem } from "../types"
|
|
1
|
+
import type { AccountConfig, AccountUsage, Ctx, Job, WorkItem, Window } from "../types"
|
|
2
2
|
import { selects } from "../config"
|
|
3
3
|
import { concurrencyFor } from "./budget"
|
|
4
|
+
import { checkWindows } from "./window"
|
|
4
5
|
import { rateOf, recordAndLearn } from "./rate"
|
|
5
6
|
|
|
6
7
|
export type Route =
|
|
@@ -11,6 +12,51 @@ export type Route =
|
|
|
11
12
|
|
|
12
13
|
export const BUILT_BY = /^built-by:\s*(\S+)\s*$/m
|
|
13
14
|
|
|
15
|
+
// How old a recorded reading may be and still price an account the reader
|
|
16
|
+
// cannot reach. The usage endpoint answers about five calls a minute per token
|
|
17
|
+
// and shares that budget with every consumer of it: each interactive session's
|
|
18
|
+
// status line, each worker the loop starts, and the tick itself. A busy account
|
|
19
|
+
// draws a 429, and the reader holds a 429 for five minutes on purpose, so one
|
|
20
|
+
// unlucky probe costs five minutes and unlucky probes chain. Measured on one
|
|
21
|
+
// box over an evening, on a token held by four sessions and two workers: gaps
|
|
22
|
+
// of 10, 14, 20, 20 and 27 minutes between successful readings, and the account
|
|
23
|
+
// unreadable for 151 of 210 minutes. Ten minutes rode out a single hold and not
|
|
24
|
+
// a run of them, which is what benched an account with 93 percent of its week
|
|
25
|
+
// left. Half an hour covers the runs actually seen. Past it an account is not
|
|
26
|
+
// bursting, it is unreachable, and a stale number would hide that.
|
|
27
|
+
export const STALE_USAGE_MS = 30 * 60_000
|
|
28
|
+
|
|
29
|
+
// The reading to rank on when the live one did not arrive. Nothing is invented
|
|
30
|
+
// here: these are readings this loop took and recorded, replayed against a
|
|
31
|
+
// later clock, and windowSane still refuses one older than its own window.
|
|
32
|
+
// Empty for a fresh account, which has no recorded reading by definition, and
|
|
33
|
+
// for an account whose last reading is older than the bound.
|
|
34
|
+
//
|
|
35
|
+
// Aged forward by what the account could have spent since, because half an hour
|
|
36
|
+
// is long enough for the number to matter near a ceiling, and the ceiling is
|
|
37
|
+
// what stops a worker starting into a window that will refuse it mid-task. At
|
|
38
|
+
// least one consumer even with no worker of ours in flight: an account nothing
|
|
39
|
+
// is using does not drain a metering bucket, so a reading we cannot refresh is
|
|
40
|
+
// itself evidence that something is spending. The rate is the same one the
|
|
41
|
+
// budget prices workers with, so this errs the way that model errs and adds no
|
|
42
|
+
// second opinion of its own.
|
|
43
|
+
function staleWindows(ctx: Ctx, a: AccountConfig, usage: AccountUsage, workers: number): Window[] {
|
|
44
|
+
// Only when the metering endpoint failed, never when the account did. An
|
|
45
|
+
// account whose credentials are missing or whose refresh was refused cannot
|
|
46
|
+
// authenticate a worker, and no usage number changes that: on 2026-09-01 half
|
|
47
|
+
// an hour of a missing credentials file spawned six builders that each met
|
|
48
|
+
// "Invalid API key, please run /login", failed, and labelled a sound issue
|
|
49
|
+
// agent-failed on the way out.
|
|
50
|
+
if (usage.readable || usage.fresh || !usage.transient) return []
|
|
51
|
+
const windows = ctx.global.lastWindows(a.id, ctx.now.getTime() - STALE_USAGE_MS)
|
|
52
|
+
if (windows.length === 0 || checkWindows(windows, ctx.now)) return []
|
|
53
|
+
return windows.map((w) => {
|
|
54
|
+
const minutes = (ctx.now.getTime() - w.observedAt.getTime()) / 60000
|
|
55
|
+
const rate = rateOf(ctx.global, a.provider, w.kind, ctx.config.workerRateSeed, w.windowMinutes)
|
|
56
|
+
return { ...w, percent: Math.min(100, w.percent + rate * Math.max(workers, 1) * minutes) }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
14
60
|
function startOfUtcDay(now: Date): number {
|
|
15
61
|
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
16
62
|
}
|
|
@@ -41,7 +87,16 @@ async function inFlightByAccount(ctx: Ctx): Promise<Map<string, number>> {
|
|
|
41
87
|
const agents = await ctx.cache("engine:agents", () => ctx.herdr.agents())
|
|
42
88
|
const out = new Map<string, number>()
|
|
43
89
|
for (const a of agents) {
|
|
44
|
-
|
|
90
|
+
// "missing" is no information, and "done" is an agent that has finished:
|
|
91
|
+
// neither is spending quota, and counting either holds an account's budget
|
|
92
|
+
// behind a worker that has gone home. On 2026-08-31 a maplista reviewer
|
|
93
|
+
// finished its round and left three polling shells behind, which kept
|
|
94
|
+
// herdr reporting "working" and starved the only eligible account with the
|
|
95
|
+
// merge one step away. The shells were the bug and a Stop hook now kills
|
|
96
|
+
// them, but the same starvation follows from any finished agent whose
|
|
97
|
+
// worktree is legitimately held open, which for a reviewer is every round
|
|
98
|
+
// until its pull request closes.
|
|
99
|
+
if (a.status === "missing" || a.status === "done") continue
|
|
45
100
|
const account = attribute(ctx, a.cwd)
|
|
46
101
|
if (account) out.set(account, (out.get(account) ?? 0) + 1)
|
|
47
102
|
}
|
|
@@ -74,8 +129,13 @@ interface Ranked {
|
|
|
74
129
|
export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<Route> {
|
|
75
130
|
const cfg = ctx.config
|
|
76
131
|
|
|
132
|
+
// A job the cap does not apply to still spends the account's quota and still
|
|
133
|
+
// waits for a worker slot, so usageMax, the reserves and maxConcurrent are
|
|
134
|
+
// what pace it. Only the box-wide day counter is lifted.
|
|
135
|
+
const cap = p.ignoresSpawnCap ? Infinity : cfg.maxSpawnsPerDay
|
|
136
|
+
|
|
77
137
|
const spawned = ctx.global.spawnsSince(startOfUtcDay(ctx.now))
|
|
78
|
-
if (spawned >=
|
|
138
|
+
if (spawned >= cap) {
|
|
79
139
|
return { ok: false, global: true, reason: `CAP ${spawned}/${cfg.maxSpawnsPerDay} spawns today` }
|
|
80
140
|
}
|
|
81
141
|
|
|
@@ -111,24 +171,43 @@ export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<R
|
|
|
111
171
|
// it gets turned into the same unreadable shape a reader would return on
|
|
112
172
|
// purpose.
|
|
113
173
|
const usage = await ctx.usage(a).catch(
|
|
114
|
-
|
|
174
|
+
// Transient: a reader that threw is a reader that could not complete its
|
|
175
|
+
// read, which says nothing about the account behind it.
|
|
176
|
+
(err): AccountUsage => ({ readable: false, reason: `read failed: ${err}`, transient: true }),
|
|
115
177
|
)
|
|
116
178
|
const max = a.maxConcurrent ?? cfg.maxConcurrentPerAccount
|
|
117
179
|
const have = inFlight.get(a.id) ?? 0
|
|
180
|
+
const stale = staleWindows(ctx, a, usage, have)
|
|
118
181
|
let concurrency: number
|
|
119
182
|
let why: string
|
|
183
|
+
let paused = false
|
|
120
184
|
|
|
121
|
-
if (!usage.readable) {
|
|
185
|
+
if (!usage.readable && stale.length === 0) {
|
|
122
186
|
// Unreadable is ineligible, not "usable but ranked last": the last-resort
|
|
123
187
|
// reading sends work to the account most likely already exhausted,
|
|
124
|
-
// precisely when every other account is out.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
188
|
+
// precisely when every other account is out. allowWhenUnreadable is the
|
|
189
|
+
// deliberate way back in, and it covers every unreadable reason: no
|
|
190
|
+
// reader can prove an account spent without a reading to prove it with.
|
|
191
|
+
if (!usage.fresh && !a.allowWhenUnreadable) continue
|
|
192
|
+
// A fresh account gets one worker and not its clamp. It has no readings,
|
|
193
|
+
// so there is no evidence of headroom to spend, and one worker is all it
|
|
194
|
+
// takes to record the first window: the tick after it can rank the
|
|
195
|
+
// account on measurements like any other. One is also what stops a
|
|
196
|
+
// payload that never gains windows from spawning every tick, because the
|
|
197
|
+
// worker already in flight fails the concurrency <= have test below.
|
|
198
|
+
concurrency = usage.fresh ? Math.min(1, max) : max
|
|
199
|
+
why = usage.fresh
|
|
200
|
+
? `fresh, one worker to record the first window (${usage.reason})`
|
|
201
|
+
: `unreadable but allowed (${usage.reason})`
|
|
128
202
|
} else {
|
|
129
|
-
|
|
203
|
+
// A stale reading prices the account and teaches nothing. The rate EWMA
|
|
204
|
+
// is the delta between two live readings, and a reading replayed against
|
|
205
|
+
// a later clock is the same number at a different time: it would report
|
|
206
|
+
// an account that spent nothing while its workers ran.
|
|
207
|
+
const windows = usage.readable ? usage.windows : stale
|
|
208
|
+
if (usage.readable) recordAndLearn(ctx.global, a, usage.windows, have)
|
|
130
209
|
const b = concurrencyFor({
|
|
131
|
-
windows
|
|
210
|
+
windows,
|
|
132
211
|
now: ctx.now,
|
|
133
212
|
reserve: a.reserve,
|
|
134
213
|
reservePerWeekday: a.reservePerWeekday,
|
|
@@ -136,12 +215,23 @@ export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<R
|
|
|
136
215
|
usageMax: cfg.usageMax,
|
|
137
216
|
releaseBefore: cfg.releaseBefore,
|
|
138
217
|
maxConcurrent: max,
|
|
218
|
+
workerRunMin: cfg.workerRunMin,
|
|
139
219
|
rateFor: (w) => rateOf(ctx.global, a.provider, w.kind, cfg.workerRateSeed, w.windowMinutes),
|
|
140
220
|
})
|
|
141
221
|
concurrency = b.concurrency
|
|
142
|
-
|
|
222
|
+
// The clock is what refuses here, not the quota, and it clears within the
|
|
223
|
+
// hour. A job the rest of the queue waits on takes one worker through it.
|
|
224
|
+
if (b.paused && p.ignoresPace && have < 1) paused = true
|
|
225
|
+
const age = Math.round((ctx.now.getTime() - windows[0]!.observedAt.getTime()) / 60000)
|
|
226
|
+
// The reason line is what an operator reads after a STARVED run turns
|
|
227
|
+
// into a SPAWN, so a decision taken on an old number says so and says
|
|
228
|
+
// which refusal made it old.
|
|
229
|
+
const from = usage.readable ? "" : ` (stale by ${age}m, ${usage.reason})`
|
|
230
|
+
why = `${b.limiting} ${b.detail} -> ${concurrency} workers, ${have} in flight${from}`
|
|
231
|
+
if (paused) why = `${why}, one worker through the pause`
|
|
143
232
|
}
|
|
144
233
|
|
|
234
|
+
if (paused) concurrency = 1
|
|
145
235
|
if (concurrency <= have) continue
|
|
146
236
|
const preferIdx = p.prefer?.findIndex((s) => selects(a, s)) ?? -1
|
|
147
237
|
ranked.push({
|
|
@@ -153,6 +243,39 @@ export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<R
|
|
|
153
243
|
})
|
|
154
244
|
}
|
|
155
245
|
|
|
246
|
+
// Last resort for a job that must run. Every account is out of headroom under
|
|
247
|
+
// usageMax and its own reserve, which is the pacing model saying "wait for the
|
|
248
|
+
// window to roll". A scheduled job cannot wait: its occurrence is a slot, and a
|
|
249
|
+
// slot missed is work that never happens. email-digest was starved on 2263
|
|
250
|
+
// ticks across five consecutive days in August, 19 mailings to real customers
|
|
251
|
+
// that simply never went out, while the loop ticked normally and said so once
|
|
252
|
+
// every two minutes. So a flagged job spends the reserve rather than skip: the
|
|
253
|
+
// account with the most room left of what is left, still bounded by that
|
|
254
|
+
// account's own worker ceiling, because two workers where one fits is not a
|
|
255
|
+
// quota decision but an out-of-memory one.
|
|
256
|
+
if (ranked.length === 0 && p.ignoresReserve) {
|
|
257
|
+
for (const a of pool) {
|
|
258
|
+
const max = a.maxConcurrent ?? cfg.maxConcurrentPerAccount
|
|
259
|
+
const have = inFlight.get(a.id) ?? 0
|
|
260
|
+
if (have >= max) continue
|
|
261
|
+
const usage = await ctx.usage(a).catch((): AccountUsage => ({ readable: false, reason: "read failed" }))
|
|
262
|
+
// The worst window is what would refuse the spawn, so it is what ranks the
|
|
263
|
+
// account here too. An unreadable account sorts last and is still eligible:
|
|
264
|
+
// this is the pass that runs when the alternative is not running at all.
|
|
265
|
+
const spent = usage.readable ? Math.max(...usage.windows.map((w) => w.percent), 0) : 100
|
|
266
|
+
ranked.push({
|
|
267
|
+
account: a,
|
|
268
|
+
demoted: builder !== null && builder === a.id,
|
|
269
|
+
prefer: p.prefer?.findIndex((sel) => selects(a, sel)) ?? -1,
|
|
270
|
+
headroom: 100 - spent,
|
|
271
|
+
why: `no account had headroom, spending the reserve for a job that must run (${a.id} at ${spent}% of its worst window, ${have} in flight)`,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
// prefer: -1 means "not preferred" in the loop above, but the sort reads a
|
|
275
|
+
// lower number as better, so normalize before it decides.
|
|
276
|
+
for (const r of ranked) if (r.prefer === -1) r.prefer = Number.MAX_SAFE_INTEGER
|
|
277
|
+
}
|
|
278
|
+
|
|
156
279
|
if (ranked.length === 0) return { ok: false, global: false, reason: "STARVED no eligible account" }
|
|
157
280
|
|
|
158
281
|
ranked.sort(
|
|
@@ -170,7 +293,7 @@ export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<R
|
|
|
170
293
|
// Choose-and-reserve is one step: counting first and inserting afterwards
|
|
171
294
|
// is the race the daily cap exists to survive.
|
|
172
295
|
const key = await p.key(ctx, item)
|
|
173
|
-
if (!ctx.global.reserve(won.account.id, ctx.workspace.name, p.name, key, ctx.now,
|
|
296
|
+
if (!ctx.global.reserve(won.account.id, ctx.workspace.name, p.name, key, ctx.now, cap, startOfUtcDay(ctx.now))) {
|
|
174
297
|
return { ok: false, global: true, reason: `CAP ${cfg.maxSpawnsPerDay} spawns today, reservation refused` }
|
|
175
298
|
}
|
|
176
299
|
}
|
package/src/runtime/worker.ts
CHANGED
|
@@ -20,20 +20,67 @@ export interface WorkerSpec {
|
|
|
20
20
|
brief: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Enough of the screen to carry a dialog's options and the cursor on one of them.
|
|
24
|
+
export const DIALOG_LINES = 40
|
|
25
|
+
|
|
26
|
+
// Both startup dialogs list their refusing answer first and highlight it, so a
|
|
27
|
+
// bare Enter answers neither: on the folder-trust one it picks "No, exit", the
|
|
28
|
+
// agent quits, herdr deregisters it, and every later read of that pane fails
|
|
29
|
+
// with agent_not_found. Read which option the cursor is on instead and walk it
|
|
30
|
+
// to the one that starts with "Yes", which is the answer these workers have
|
|
31
|
+
// always meant to give. A screen with no such options keeps the old bare Enter,
|
|
32
|
+
// because a dialog nobody has seen yet is still more likely to want confirming
|
|
33
|
+
// than cancelling.
|
|
34
|
+
export function dialogKeys(screen: string): string[] {
|
|
35
|
+
const options = screen
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((l) => ({ selected: /^\s*[\u276f>]/.test(l), text: l.replace(/^\s*[\u276f>]?\s*/, "") }))
|
|
38
|
+
.filter((o) => /^(Yes|No),/.test(o.text))
|
|
39
|
+
const at = options.findIndex((o) => o.selected)
|
|
40
|
+
const yes = options.findIndex((o) => o.text.startsWith("Yes"))
|
|
41
|
+
if (at < 0 || yes < 0) return ["Enter"]
|
|
42
|
+
const step = yes > at ? "Down" : "Up"
|
|
43
|
+
return [...Array(Math.abs(yes - at)).fill(step), "Enter"]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// An agent that comes up on a startup dialog is running, not broken: it is
|
|
47
|
+
// waiting on a keypress, and every worktree is a directory the agent has never
|
|
48
|
+
// seen, so the folder-trust and external-CLAUDE.md-import questions are the
|
|
49
|
+
// normal case rather than the exception. Stacked dialogs get one answer per
|
|
50
|
+
// start attempt, which is what the retry loop is for.
|
|
51
|
+
async function answerStartupDialog(ctx: Ctx, pane: string): Promise<boolean> {
|
|
52
|
+
if ((await ctx.herdr.agentStatus(pane)) !== "blocked") return false
|
|
53
|
+
await ctx.herdr.agentSendKeys(pane, dialogKeys(await ctx.herdr.agentRead(pane, DIALOG_LINES)))
|
|
54
|
+
await ctx.sleep(RECOVER_WAIT_MS)
|
|
55
|
+
return (await ctx.herdr.agentStatus(pane)) !== "blocked"
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
export async function startWorker(ctx: Ctx, w: WorkerSpec): Promise<void> {
|
|
24
|
-
|
|
59
|
+
// The first error, not the last: once an attempt has registered the name,
|
|
60
|
+
// every retry after it fails with agent_name_taken, and reporting that hides
|
|
61
|
+
// the only error that says why the start failed in the first place.
|
|
62
|
+
let firstErr: unknown = null
|
|
63
|
+
let started = false
|
|
25
64
|
for (let attempt = 1; attempt <= START_RETRIES; attempt++) {
|
|
26
65
|
try {
|
|
27
66
|
await ctx.herdr.agentStart({ pane: w.pane, kind: w.kind, name: w.name, args: w.args })
|
|
28
|
-
|
|
67
|
+
started = true
|
|
29
68
|
break
|
|
30
69
|
} catch (err) {
|
|
31
|
-
|
|
70
|
+
if (firstErr === null) firstErr = err
|
|
71
|
+
// Answering can fail in its own right: a dialog answered with the wrong
|
|
72
|
+
// key quits the agent, and the reads here then throw agent_not_found.
|
|
73
|
+
// Letting that escape replaces the start error with a symptom of the
|
|
74
|
+
// recovery and skips every attempt that was left.
|
|
75
|
+
if (await answerStartupDialog(ctx, w.pane).catch(() => false)) {
|
|
76
|
+
started = true
|
|
77
|
+
break
|
|
78
|
+
}
|
|
32
79
|
if (attempt < START_RETRIES) await ctx.sleep(START_DELAY_MS)
|
|
33
80
|
}
|
|
34
81
|
}
|
|
35
|
-
if (
|
|
36
|
-
throw new Error(`agent start failed after ${START_RETRIES} attempts: ${
|
|
82
|
+
if (!started) {
|
|
83
|
+
throw new Error(`agent start failed after ${START_RETRIES} attempts: ${firstErr}`)
|
|
37
84
|
}
|
|
38
85
|
await sendBrief(ctx, w.pane, w.brief)
|
|
39
86
|
}
|
package/src/types.ts
CHANGED
|
@@ -66,6 +66,14 @@ export interface Config {
|
|
|
66
66
|
releaseBefore: number
|
|
67
67
|
maxSpawnsPerDay: number
|
|
68
68
|
blockedTimeoutMin: number
|
|
69
|
+
// A hold this old has one notification sent about it. 0 turns it off.
|
|
70
|
+
holdTimeoutMin: number
|
|
71
|
+
// An agent sitting in a held worktree with no work left is closed after this
|
|
72
|
+
// many minutes, freeing the account slot it counts against. 0 turns it off.
|
|
73
|
+
staleAgentMin: number
|
|
74
|
+
// How long one worker runs, in minutes. Prices a burst while a window is
|
|
75
|
+
// behind its line: a worker only starts if the points left can pay for it.
|
|
76
|
+
workerRunMin: number
|
|
69
77
|
// Percentage points per minute per worker, used for a provider/window pair
|
|
70
78
|
// with no measured EWMA yet. Must be > 0.
|
|
71
79
|
workerRateSeed: number
|
|
@@ -86,7 +94,11 @@ export interface WorkItem {
|
|
|
86
94
|
createdAt?: string
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
// herdr's own enum is idle, working, blocked, done, unknown. "done" is herdr
|
|
98
|
+
// saying the agent finished and is not coming back on its own, which is a fact
|
|
99
|
+
// worth acting on; "unknown" and anything herdr adds later map to "missing",
|
|
100
|
+
// which every consumer here treats as no information and holds on.
|
|
101
|
+
export type AgentStatus = "working" | "blocked" | "idle" | "done" | "missing"
|
|
90
102
|
|
|
91
103
|
export interface AgentView {
|
|
92
104
|
cwd: string
|
|
@@ -157,6 +169,29 @@ export interface Job {
|
|
|
157
169
|
// account by headroom: the same job must run the same model wherever it lands.
|
|
158
170
|
model?: string
|
|
159
171
|
sweepIgnoresWorking?: boolean
|
|
172
|
+
// Exempt from maxSpawnsPerDay. The cap is a runaway-loop breaker sized for
|
|
173
|
+
// whichever workspace on the box actually loops, and a scheduled job that
|
|
174
|
+
// spawns a handful of times a day is not what it is aimed at: on 2026-09-02 a
|
|
175
|
+
// builder re-picking one issue every tick spent all 60 by 03:52 and the
|
|
176
|
+
// 04:15 content run never started. Spawns still count, so an exempt job is
|
|
177
|
+
// visible in the day's total and shortens the capped jobs' budget.
|
|
178
|
+
ignoresSpawnCap?: boolean
|
|
179
|
+
// Spend the account reserve rather than skip. Only when no account has
|
|
180
|
+
// headroom at all, so the reserve still holds whenever the pacing model has
|
|
181
|
+
// any room to give: this is the difference between a job that waits for the
|
|
182
|
+
// window to roll and one whose slot is simply lost. For scheduled work with
|
|
183
|
+
// an outside consumer (a mailing, a daily run), where skipping is not a
|
|
184
|
+
// delay, it is a cancellation.
|
|
185
|
+
ignoresReserve?: boolean
|
|
186
|
+
// Run through a pacing pause, but never through a full account. Pacing
|
|
187
|
+
// spreads a window's ceiling evenly and waits whenever the account is ahead
|
|
188
|
+
// of that line, which is right for work that can be done later and wrong for
|
|
189
|
+
// the one job the rest of the queue is waiting on: a reviewer that cannot run
|
|
190
|
+
// is what makes a builder's review debt permanent, so a 0.4-point overshoot
|
|
191
|
+
// idled the whole maplista lane for 45 minutes on 2026-09-08. It takes one
|
|
192
|
+
// worker, not the account's clamp, and only while the points left still pay
|
|
193
|
+
// for a run, so usageMax and the reserve both still hold.
|
|
194
|
+
ignoresPace?: boolean
|
|
160
195
|
deleteRemote?: boolean
|
|
161
196
|
// Selectors: each matches an account by id or by provider.
|
|
162
197
|
requires?: string[]
|
|
@@ -187,7 +222,7 @@ export interface Job {
|
|
|
187
222
|
|
|
188
223
|
export type Decision =
|
|
189
224
|
| { pass: "gc"; removed: number }
|
|
190
|
-
| { pass: "sweep"; job: string; worktree: string; branch: string; action: "clean" | "hold"; reason: string }
|
|
225
|
+
| { pass: "sweep"; job: string; worktree: string; branch: string; action: "clean" | "hold" | "overdue" | "reap"; reason: string }
|
|
191
226
|
| { pass: "monitor"; job: string; key: string; action: MonitorAction; reason: string }
|
|
192
227
|
| { pass: "spawn"; job: string; key: string; action: "spawn" | "skip"; account?: string; reason: string }
|
|
193
228
|
// The workspace this tick pass covered, or "total" for the whole process:
|
|
@@ -204,7 +239,7 @@ export type Decision =
|
|
|
204
239
|
|
|
205
240
|
export type MonitorAction =
|
|
206
241
|
| "done" | "external" | "busy" | "blocked" | "escalate"
|
|
207
|
-
| "restart" | "nudge" | "fail" | "hold"
|
|
242
|
+
| "restart" | "nudge" | "fail" | "hold" | "overdue"
|
|
208
243
|
|
|
209
244
|
export interface Window {
|
|
210
245
|
kind: string // 'session' | 'weekly_all' | 'w300' | ...
|
|
@@ -216,10 +251,21 @@ export interface Window {
|
|
|
216
251
|
observedAt: Date
|
|
217
252
|
}
|
|
218
253
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
254
|
+
// Unreadable is one state, not two. There was once an `exhausted` variant for
|
|
255
|
+
// a 429, on the theory that it proved the account was spent; it proves only
|
|
256
|
+
// that the usage endpoint's own burst budget is spent, which anything else
|
|
257
|
+
// polling the same account can do on our behalf. An account with no budget
|
|
258
|
+
// left says so in a 200, with its windows at 100%. `fresh` is the other
|
|
259
|
+
// direction: no window has started, so there is no usage to read rather than
|
|
260
|
+
// too much of it, and the account needs one worker before it can be read.
|
|
221
261
|
export type AccountUsage =
|
|
222
262
|
| { readable: true; windows: Window[] }
|
|
223
|
-
|
|
263
|
+
// `transient` marks a failure of the metering endpoint rather than of the
|
|
264
|
+
// account: the endpoint was busy or briefly broken, and the account itself is
|
|
265
|
+
// fine. Only those may be priced on an older reading, because a stale usage
|
|
266
|
+
// number cannot rescue an account a worker cannot authenticate to. Opt-in on
|
|
267
|
+
// purpose: a new failure mode that forgets to set it costs a skipped tick,
|
|
268
|
+
// and the other default costs a spawned worker that cannot work.
|
|
269
|
+
| { readable: false; reason: string; fresh?: boolean; transient?: boolean }
|
|
224
270
|
|
|
225
271
|
export type UsageReader = (a: AccountConfig, now: Date) => Promise<AccountUsage>
|