@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/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
 
@@ -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
  }
@@ -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
- // By tab id from pane list: a workspace id would close the whole loop
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 () => {
@@ -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.push(mk("busy", "agent working"))
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
 
@@ -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
- out.push(mk("hold", "agent working"))
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
- out.push(mk("hold", `${predicate}(${rawKey}) false`))
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})`))
@@ -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
  },
@@ -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
- // A closed, unmerged pull request is a legitimate retry.
101
- return pr === null || pr.state === "CLOSED"
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
- return (await prFor(ctx, keyFor(item))) !== null
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
- return pr !== null && pr.state !== "OPEN"
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) =>
@@ -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
- if (issue) return issue.state !== "OPEN"
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")
@@ -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
- return d.action === "clean"
18
- ? `${would("sweep")} ${d.job} ${d.worktree} (${d.reason})`
19
- : `HOLD ${d.job} ${d.worktree} (${d.reason})`
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})`
@@ -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
- const workers = rate > 0 ? budgetRate / rate : 0
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
  }