@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.
@@ -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
- getUsage(token: string): Promise<{ status: number; body: any }>
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, exhausted?: boolean): AccountUsage =>
57
- exhausted ? { readable: false, reason, exhausted } : { readable: false, reason }
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
- // Exhausted is strictly more information than unknown, and unlike every
94
- // other unreadable state allowWhenUnreadable must not resurrect it.
95
- if (res.status === 429) return no("429 from the usage endpoint", true)
96
- if (res.status !== 200) return no(`usage endpoint ${res.status}`)
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 no("no usable limit windows in the payload")
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
- refreshToken: c.refreshToken,
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: refresh tokens are not
161
- // rotated, so the stored one keeps working and the file stays the live
162
- // agent's to own. A live run writes it back, because an account nobody
163
- // refreshes goes blind within a day.
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
- return { status: r.status, body: r.status === 200 ? await r.json() : null }
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, and deliberately not exhausted, so allowWhenUnreadable can opt
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",
@@ -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
- if (a.status === "missing") continue
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 >= cfg.maxSpawnsPerDay) {
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
- (err): AccountUsage => ({ readable: false, reason: `read failed: ${err}` }),
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. A 429 is never opted back in.
125
- if (usage.exhausted || !a.allowWhenUnreadable) continue
126
- concurrency = max
127
- why = `unreadable but allowed (${usage.reason})`
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
- recordAndLearn(ctx.global, a, usage.windows, have)
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: usage.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
- why = `${b.limiting} ${b.detail} -> ${concurrency} workers, ${have} in flight`
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, cfg.maxSpawnsPerDay, startOfUtcDay(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
  }
@@ -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
- let lastErr: unknown = null
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
- lastErr = null
67
+ started = true
29
68
  break
30
69
  } catch (err) {
31
- lastErr = err
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 (lastErr) {
36
- throw new Error(`agent start failed after ${START_RETRIES} attempts: ${lastErr}`)
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
- export type AgentStatus = "working" | "blocked" | "idle" | "missing"
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
- // `exhausted` is a 429: strictly more information than "unknown", and the one
220
- // unreadable state that allowWhenUnreadable must not resurrect.
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
- | { readable: false; reason: string; exhausted?: boolean }
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>