@tonoid/agent-loop 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +378 -0
- package/briefs/default/build.md +35 -0
- package/briefs/default/core.md +52 -0
- package/briefs/default/journal.optional.md +7 -0
- package/briefs/default/review.md +65 -0
- package/briefs/default/routine.md +18 -0
- package/briefs/default/screenshots.optional.md +10 -0
- package/briefs/default/subagents.optional.md +7 -0
- package/docs/cutover.md +121 -0
- package/package.json +46 -0
- package/src/adapters/gh.ts +77 -0
- package/src/adapters/git.ts +82 -0
- package/src/adapters/herdr.ts +121 -0
- package/src/adapters/run.ts +64 -0
- package/src/adopt.ts +47 -0
- package/src/brief.ts +124 -0
- package/src/check.ts +126 -0
- package/src/cli-pause.ts +16 -0
- package/src/cli.ts +290 -0
- package/src/config.ts +211 -0
- package/src/ctx.ts +64 -0
- package/src/discover.ts +344 -0
- package/src/effects/monitor.ts +148 -0
- package/src/effects/spawn.ts +246 -0
- package/src/effects/sweep.ts +27 -0
- package/src/engine/item.ts +32 -0
- package/src/engine/monitor.ts +117 -0
- package/src/engine/naming.ts +45 -0
- package/src/engine/spawn.ts +101 -0
- package/src/engine/sweep.ts +91 -0
- package/src/engine/tick.ts +25 -0
- package/src/filing.ts +56 -0
- package/src/globalstate.ts +228 -0
- package/src/journal.ts +13 -0
- package/src/kinds/builder.ts +128 -0
- package/src/kinds/index.ts +13 -0
- package/src/kinds/reviewer.ts +220 -0
- package/src/kinds/routine.ts +174 -0
- package/src/kinds/shared.ts +49 -0
- package/src/kinds/validate.ts +187 -0
- package/src/lock.ts +63 -0
- package/src/paths.ts +21 -0
- package/src/render.ts +42 -0
- package/src/router/budget.ts +81 -0
- package/src/router/providers/claude.ts +177 -0
- package/src/router/providers/codex.ts +120 -0
- package/src/router/providers/grok.ts +10 -0
- package/src/router/rate.ts +62 -0
- package/src/router/route.ts +179 -0
- package/src/router/window.ts +39 -0
- package/src/runtime/worker.ts +75 -0
- package/src/state.ts +128 -0
- package/src/status.ts +41 -0
- package/src/types.ts +225 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { AccountConfig, AccountUsage, Ctx, Job, WorkItem } from "../types"
|
|
2
|
+
import { selects } from "../config"
|
|
3
|
+
import { concurrencyFor } from "./budget"
|
|
4
|
+
import { rateOf, recordAndLearn } from "./rate"
|
|
5
|
+
|
|
6
|
+
export type Route =
|
|
7
|
+
| { ok: true; account: string; reason: string }
|
|
8
|
+
// `global` marks a refusal that describes the box or the day rather than this
|
|
9
|
+
// job, so the spawn walk stops instead of asking the next job.
|
|
10
|
+
| { ok: false; global: boolean; reason: string }
|
|
11
|
+
|
|
12
|
+
export const BUILT_BY = /^built-by:\s*(\S+)\s*$/m
|
|
13
|
+
|
|
14
|
+
function startOfUtcDay(now: Date): number {
|
|
15
|
+
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Which account a live worker belongs to, from its cwd. Every workspace on the
|
|
19
|
+
// box is walked, not just this one: concurrency is an account-scoped fact, and
|
|
20
|
+
// counting only this workspace's workers lets a second workspace spawn against
|
|
21
|
+
// an account whose slots are already full.
|
|
22
|
+
function attribute(ctx: Ctx, cwd: string): string | null {
|
|
23
|
+
for (const ws of ctx.workspaces) {
|
|
24
|
+
for (const p of ws.jobs) {
|
|
25
|
+
const prefix = `${ws.worktreeBase}/wt-${p.name}-`
|
|
26
|
+
if (!cwd.startsWith(prefix)) continue
|
|
27
|
+
// The worktree directory may carry a title slug after the key, so only
|
|
28
|
+
// the first path segment under the base is used and the spawns table
|
|
29
|
+
// does the matching.
|
|
30
|
+
const dir = cwd.slice(prefix.length).split("/")[0]!
|
|
31
|
+
return ctx.global.accountFor(ws.name, p.name, dir)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Live workers, attributed to the account that spawned them through the spawns
|
|
38
|
+
// table.
|
|
39
|
+
async function inFlightByAccount(ctx: Ctx): Promise<Map<string, number>> {
|
|
40
|
+
return ctx.cache("engine:inflight-by-account", async () => {
|
|
41
|
+
const agents = await ctx.cache("engine:agents", () => ctx.herdr.agents())
|
|
42
|
+
const out = new Map<string, number>()
|
|
43
|
+
for (const a of agents) {
|
|
44
|
+
if (a.status === "missing") continue
|
|
45
|
+
const account = attribute(ctx, a.cwd)
|
|
46
|
+
if (account) out.set(account, (out.get(account) ?? 0) + 1)
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The account that built an item is recorded in the PR body, so the constraint
|
|
53
|
+
// derives from the outside world like every other claim and the database stays
|
|
54
|
+
// a pure cache.
|
|
55
|
+
async function builtBy(ctx: Ctx, item: WorkItem): Promise<string | null> {
|
|
56
|
+
// Anchored at the end so "<owner>/<name>/pull/<n>" is unambiguous, and host
|
|
57
|
+
// agnostic so a self-hosted forge resolves the same way.
|
|
58
|
+
const m = item.url?.match(/\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/)
|
|
59
|
+
if (!m) return null
|
|
60
|
+
const body = await ctx.cache(`engine:body:${item.id}`, () =>
|
|
61
|
+
ctx.gh.prView(m[1]!, m[2]!, ["body"]),
|
|
62
|
+
)
|
|
63
|
+
return String(body?.body ?? "").match(BUILT_BY)?.[1] ?? null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Ranked {
|
|
67
|
+
account: AccountConfig
|
|
68
|
+
demoted: boolean
|
|
69
|
+
prefer: number
|
|
70
|
+
headroom: number
|
|
71
|
+
why: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<Route> {
|
|
75
|
+
const cfg = ctx.config
|
|
76
|
+
|
|
77
|
+
const spawned = ctx.global.spawnsSince(startOfUtcDay(ctx.now))
|
|
78
|
+
if (spawned >= cfg.maxSpawnsPerDay) {
|
|
79
|
+
return { ok: false, global: true, reason: `CAP ${spawned}/${cfg.maxSpawnsPerDay} spawns today` }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inFlight = await inFlightByAccount(ctx)
|
|
83
|
+
const busy = [...inFlight.values()].reduce((a, b) => a + b, 0)
|
|
84
|
+
if (busy > 0) {
|
|
85
|
+
// Quota headroom says nothing about RAM, and workers each run installs, dev
|
|
86
|
+
// servers and typecheckers. With nothing in flight the box is as free as it
|
|
87
|
+
// will ever be, so refusing then would deadlock the loop.
|
|
88
|
+
const free = await ctx.memAvailableMb()
|
|
89
|
+
if (free < cfg.minFreeMb) {
|
|
90
|
+
return { ok: false, global: true, reason: `LOWMEM ${Math.round(free)}MB < ${cfg.minFreeMb}MB` }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pool = cfg.accounts.filter((a) => !p.requires || p.requires.some((s) => selects(a, s)))
|
|
95
|
+
if (pool.length === 0) {
|
|
96
|
+
return { ok: false, global: false, reason: `STARVED no account matches requires` }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const builder = p.distinctFrom ? await builtBy(ctx, item) : null
|
|
100
|
+
const unsatisfiable = p.distinctFrom && builder === null ? ", built-by missing so distinctFrom ignored" : ""
|
|
101
|
+
|
|
102
|
+
const ranked: Ranked[] = []
|
|
103
|
+
for (const a of pool) {
|
|
104
|
+
// A reader can throw (missing dir, unrecognized payload shape, a bare
|
|
105
|
+
// fetch, a database that won't open) and readers are meant to: the spec
|
|
106
|
+
// wants that loud. But ctx.usage is memoized per account by ctx.cache, so
|
|
107
|
+
// an unguarded throw here would reject the *cached* promise, and every
|
|
108
|
+
// later job that ranks this same account in this tick would re-throw
|
|
109
|
+
// it too, starving the whole pool over one account's bad day. Only this
|
|
110
|
+
// boundary knows the failure is scoped to one account, so this is where
|
|
111
|
+
// it gets turned into the same unreadable shape a reader would return on
|
|
112
|
+
// purpose.
|
|
113
|
+
const usage = await ctx.usage(a).catch(
|
|
114
|
+
(err): AccountUsage => ({ readable: false, reason: `read failed: ${err}` }),
|
|
115
|
+
)
|
|
116
|
+
const max = a.maxConcurrent ?? cfg.maxConcurrentPerAccount
|
|
117
|
+
const have = inFlight.get(a.id) ?? 0
|
|
118
|
+
let concurrency: number
|
|
119
|
+
let why: string
|
|
120
|
+
|
|
121
|
+
if (!usage.readable) {
|
|
122
|
+
// Unreadable is ineligible, not "usable but ranked last": the last-resort
|
|
123
|
+
// 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})`
|
|
128
|
+
} else {
|
|
129
|
+
recordAndLearn(ctx.global, a, usage.windows, have)
|
|
130
|
+
const b = concurrencyFor({
|
|
131
|
+
windows: usage.windows,
|
|
132
|
+
now: ctx.now,
|
|
133
|
+
reserve: a.reserve,
|
|
134
|
+
reservePerWeekday: a.reservePerWeekday,
|
|
135
|
+
weekendWeight: a.weekendWeight,
|
|
136
|
+
usageMax: cfg.usageMax,
|
|
137
|
+
releaseBefore: cfg.releaseBefore,
|
|
138
|
+
maxConcurrent: max,
|
|
139
|
+
rateFor: (w) => rateOf(ctx.global, a.provider, w.kind, cfg.workerRateSeed, w.windowMinutes),
|
|
140
|
+
})
|
|
141
|
+
concurrency = b.concurrency
|
|
142
|
+
why = `${b.limiting} ${b.detail} -> ${concurrency} workers, ${have} in flight`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (concurrency <= have) continue
|
|
146
|
+
const preferIdx = p.prefer?.findIndex((s) => selects(a, s)) ?? -1
|
|
147
|
+
ranked.push({
|
|
148
|
+
account: a,
|
|
149
|
+
demoted: builder !== null && builder === a.id,
|
|
150
|
+
prefer: preferIdx === -1 ? Number.MAX_SAFE_INTEGER : preferIdx,
|
|
151
|
+
headroom: concurrency - have,
|
|
152
|
+
why,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (ranked.length === 0) return { ok: false, global: false, reason: "STARVED no eligible account" }
|
|
157
|
+
|
|
158
|
+
ranked.sort(
|
|
159
|
+
(x, y) =>
|
|
160
|
+
Number(x.demoted) - Number(y.demoted) ||
|
|
161
|
+
x.prefer - y.prefer ||
|
|
162
|
+
y.headroom - x.headroom ||
|
|
163
|
+
x.account.id.localeCompare(y.account.id),
|
|
164
|
+
)
|
|
165
|
+
const won = ranked[0]!
|
|
166
|
+
const demoted = won.demoted ? ", demoted by distinctFrom and last standing" : ""
|
|
167
|
+
const reason = `${won.why}${demoted}${unsatisfiable}`
|
|
168
|
+
|
|
169
|
+
if (ctx.live) {
|
|
170
|
+
// Choose-and-reserve is one step: counting first and inserting afterwards
|
|
171
|
+
// is the race the daily cap exists to survive.
|
|
172
|
+
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))) {
|
|
174
|
+
return { ok: false, global: true, reason: `CAP ${cfg.maxSpawnsPerDay} spawns today, reservation refused` }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { ok: true, account: won.account.id, reason }
|
|
179
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Window } from "../types"
|
|
2
|
+
|
|
3
|
+
// The payload does not carry a window length, so it is inferred from the kind.
|
|
4
|
+
// Documented assumption: session is 5 hours, both weekly kinds are 7 days.
|
|
5
|
+
export const CLAUDE_WINDOW_MINUTES: Record<string, number> = {
|
|
6
|
+
session: 300,
|
|
7
|
+
weekly_all: 10080,
|
|
8
|
+
weekly_scoped: 10080,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A window only describes its own span. A reset time further than one span away
|
|
12
|
+
// in either direction is a unit error or gross clock skew, and a snapshot taken
|
|
13
|
+
// more than one span ago says nothing about the window it names. Never infer
|
|
14
|
+
// "percent is 0" from a reset time in the past.
|
|
15
|
+
export function windowSane(w: Window, now: Date): boolean {
|
|
16
|
+
const span = w.windowMinutes * 60000
|
|
17
|
+
const t = w.resetsAt.getTime()
|
|
18
|
+
if (Number.isNaN(t) || !(w.windowMinutes > 0)) return false
|
|
19
|
+
if (t < now.getTime() - span || t > now.getTime() + span) return false
|
|
20
|
+
// Both readers do Number(...) on a payload field that may be absent, so a
|
|
21
|
+
// missing percent flows through as NaN rather than throwing. Left
|
|
22
|
+
// unchecked, NaN <= have is false (so the account is never skipped as
|
|
23
|
+
// exhausted) and y.headroom - x.headroom is NaN in the ranking sort
|
|
24
|
+
// (falsy, so it decides nothing), letting a poisoned account outrank a
|
|
25
|
+
// healthy one on alphabetical id alone. Same NaN-poisoning class as the
|
|
26
|
+
// claude reader's throw-on-unknown-kind, guarded here in the opposite
|
|
27
|
+
// direction: reject the bad value instead of throwing on it.
|
|
28
|
+
if (!Number.isFinite(w.percent)) return false
|
|
29
|
+
return now.getTime() - w.observedAt.getTime() <= span
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function checkWindows(ws: Window[], now: Date): string | null {
|
|
33
|
+
for (const w of ws) {
|
|
34
|
+
if (!windowSane(w, now)) {
|
|
35
|
+
return `window "${w.kind}" is not sane: resetsAt ${w.resetsAt.toISOString()}, observedAt ${w.observedAt.toISOString()}, span ${w.windowMinutes}m`
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AgentStatus, Ctx } from "../types"
|
|
2
|
+
|
|
3
|
+
// The pane's shell is not ready the instant the tab exists, and this is the
|
|
4
|
+
// failure that shows up most often in production start logs.
|
|
5
|
+
export const START_RETRIES = 5
|
|
6
|
+
export const START_DELAY_MS = 3000
|
|
7
|
+
export const PROMPT_TIMEOUT_MS = 15000
|
|
8
|
+
export const RECOVER_WAIT_MS = 5000
|
|
9
|
+
// A session whose SessionStart hooks are still running reports idle and moves
|
|
10
|
+
// no state, so herdr answers agent_prompt_stalled and the brief is dropped on
|
|
11
|
+
// the floor. That is a slow start, not a refusal, and the only recovery is to
|
|
12
|
+
// send it again once the session has settled.
|
|
13
|
+
export const PROMPT_ATTEMPTS = 5
|
|
14
|
+
|
|
15
|
+
export interface WorkerSpec {
|
|
16
|
+
pane: string
|
|
17
|
+
kind: string
|
|
18
|
+
name: string
|
|
19
|
+
args: string[]
|
|
20
|
+
brief: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function startWorker(ctx: Ctx, w: WorkerSpec): Promise<void> {
|
|
24
|
+
let lastErr: unknown = null
|
|
25
|
+
for (let attempt = 1; attempt <= START_RETRIES; attempt++) {
|
|
26
|
+
try {
|
|
27
|
+
await ctx.herdr.agentStart({ pane: w.pane, kind: w.kind, name: w.name, args: w.args })
|
|
28
|
+
lastErr = null
|
|
29
|
+
break
|
|
30
|
+
} catch (err) {
|
|
31
|
+
lastErr = err
|
|
32
|
+
if (attempt < START_RETRIES) await ctx.sleep(START_DELAY_MS)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (lastErr) {
|
|
36
|
+
throw new Error(`agent start failed after ${START_RETRIES} attempts: ${lastErr}`)
|
|
37
|
+
}
|
|
38
|
+
await sendBrief(ctx, w.pane, w.brief)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function sendBrief(ctx: Ctx, pane: string, brief: string): Promise<void> {
|
|
42
|
+
let status: AgentStatus = "idle"
|
|
43
|
+
for (let attempt = 1; attempt <= PROMPT_ATTEMPTS; attempt++) {
|
|
44
|
+
// From the second attempt on only: never re-prompt an agent that is
|
|
45
|
+
// already working, because the previous send may have landed a moment
|
|
46
|
+
// after its own status check and a second one would queue the brief twice.
|
|
47
|
+
// On the first pass there is nothing to double, and checking first would
|
|
48
|
+
// skip the send entirely for an agent herdr already calls working.
|
|
49
|
+
if (attempt > 1) {
|
|
50
|
+
status = await ctx.herdr.agentStatus(pane)
|
|
51
|
+
if (status === "working") return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await ctx.herdr.agentPrompt(pane, brief, { until: "working", timeoutMs: PROMPT_TIMEOUT_MS })
|
|
56
|
+
} catch {
|
|
57
|
+
// A timeout waiting for "working", or the stall herdr reports when the
|
|
58
|
+
// session moved no state at all. Neither is fatal here; the assertions
|
|
59
|
+
// below are what decide, and the throw at the end carries the status
|
|
60
|
+
// actually seen.
|
|
61
|
+
}
|
|
62
|
+
status = await ctx.herdr.agentStatus(pane)
|
|
63
|
+
if (status === "working") return
|
|
64
|
+
|
|
65
|
+
// A stuck composer holds the brief as unsent text and presents as idle.
|
|
66
|
+
// One Enter, one more look, then round again.
|
|
67
|
+
await ctx.herdr.agentSendKeys(pane, ["Enter"])
|
|
68
|
+
await ctx.sleep(RECOVER_WAIT_MS)
|
|
69
|
+
status = await ctx.herdr.agentStatus(pane)
|
|
70
|
+
if (status === "working") return
|
|
71
|
+
}
|
|
72
|
+
throw new Error(
|
|
73
|
+
`agent did not start working after ${PROMPT_ATTEMPTS} briefs (status ${status})`,
|
|
74
|
+
)
|
|
75
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import type { Marks } from "./types"
|
|
3
|
+
|
|
4
|
+
export interface MarkRow { job: string; key: string; mark: string; at: number }
|
|
5
|
+
|
|
6
|
+
export interface State extends Marks {
|
|
7
|
+
// Every mark, for `agent-loop adopt --list`. The loop itself never needs the
|
|
8
|
+
// whole table: it asks about one job, key and mark at a time.
|
|
9
|
+
all(): MarkRow[]
|
|
10
|
+
// Test seam: move a mark back in time so gc() can be tested without waiting.
|
|
11
|
+
backdate(job: string, key: string, mark: string, minutes: number): void
|
|
12
|
+
close(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function openState(path: string): State {
|
|
16
|
+
const db = new Database(path)
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS marks (
|
|
19
|
+
job TEXT NOT NULL,
|
|
20
|
+
key TEXT NOT NULL,
|
|
21
|
+
mark TEXT NOT NULL,
|
|
22
|
+
at INTEGER NOT NULL,
|
|
23
|
+
PRIMARY KEY (job, key, mark)
|
|
24
|
+
)
|
|
25
|
+
`)
|
|
26
|
+
// A database created before the rename has a `plugin` column, and CREATE
|
|
27
|
+
// TABLE IF NOT EXISTS is a no-op against it, so every statement below would
|
|
28
|
+
// reference a column that does not exist. Marks are a cache the spec says is
|
|
29
|
+
// recoverable, but a loop that throws on every tick is not recovery.
|
|
30
|
+
const columns = db.query<{ name: string }, []>("PRAGMA table_info(marks)").all()
|
|
31
|
+
if (columns.some((c) => c.name === "plugin")) {
|
|
32
|
+
db.exec("ALTER TABLE marks RENAME COLUMN plugin TO job")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const get = db.query<{ at: number }, [string, string, string]>(
|
|
36
|
+
"SELECT at FROM marks WHERE job = ? AND key = ? AND mark = ?",
|
|
37
|
+
)
|
|
38
|
+
const ins = db.query(
|
|
39
|
+
"INSERT OR IGNORE INTO marks (job, key, mark, at) VALUES (?, ?, ?, ?)",
|
|
40
|
+
)
|
|
41
|
+
const del = db.query("DELETE FROM marks WHERE job = ? AND key = ? AND mark = ?")
|
|
42
|
+
const move = db.query(
|
|
43
|
+
"UPDATE marks SET at = ? WHERE job = ? AND key = ? AND mark = ?",
|
|
44
|
+
)
|
|
45
|
+
const sweep = db.query("DELETE FROM marks WHERE at < ?")
|
|
46
|
+
const list = db.query<MarkRow, []>(
|
|
47
|
+
"SELECT job, key, mark, at FROM marks ORDER BY job, key, mark",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
has(job, key, mark) {
|
|
52
|
+
return get.get(job, key, mark) !== null
|
|
53
|
+
},
|
|
54
|
+
age(job, key, mark) {
|
|
55
|
+
const row = get.get(job, key, mark)
|
|
56
|
+
if (row === null) return null
|
|
57
|
+
return Math.max(0, Math.round((Date.now() - row.at) / 60000))
|
|
58
|
+
},
|
|
59
|
+
set(job, key, mark) {
|
|
60
|
+
ins.run(job, key, mark, Date.now())
|
|
61
|
+
},
|
|
62
|
+
clear(job, key, mark) {
|
|
63
|
+
del.run(job, key, mark)
|
|
64
|
+
},
|
|
65
|
+
backdate(job, key, mark, minutes) {
|
|
66
|
+
move.run(Date.now() - minutes * 60000, job, key, mark)
|
|
67
|
+
},
|
|
68
|
+
gc(olderThanDays) {
|
|
69
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60000
|
|
70
|
+
return sweep.run(cutoff).changes
|
|
71
|
+
},
|
|
72
|
+
all() {
|
|
73
|
+
return list.all()
|
|
74
|
+
},
|
|
75
|
+
close() {
|
|
76
|
+
db.close()
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A mark records that the loop did something: nudged a worker, restarted one,
|
|
82
|
+
// spawned an item. A tick without --live does none of those, so persisting the
|
|
83
|
+
// mark writes a claim about the world that never happened, and the next tick
|
|
84
|
+
// reads it back as history: the second dry tick fails an item the first only
|
|
85
|
+
// intended to nudge. It also means the shadow week of the cutover (docs/cutover.md
|
|
86
|
+
// phase 1) hands the first live tick a state directory full of stamps nothing
|
|
87
|
+
// earned. Writes stay in this process instead, so one tick is still internally
|
|
88
|
+
// consistent (the monitor's second visit to an item sees its own first) and
|
|
89
|
+
// leaves nothing behind. Reads fall through, so a stamp an operator imported
|
|
90
|
+
// still counts.
|
|
91
|
+
export function dryMarks<T extends Marks>(inner: T): T {
|
|
92
|
+
const pending = new Map<string, number | null>()
|
|
93
|
+
const id = (job: string, key: string, mark: string) => `${job} ${key} ${mark}`
|
|
94
|
+
const has = (job: string, key: string, mark: string): boolean => {
|
|
95
|
+
const at = pending.get(id(job, key, mark))
|
|
96
|
+
return at === undefined ? inner.has(job, key, mark) : at !== null
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
...inner,
|
|
100
|
+
has,
|
|
101
|
+
age(job: string, key: string, mark: string) {
|
|
102
|
+
const at = pending.get(id(job, key, mark))
|
|
103
|
+
if (at === undefined) return inner.age(job, key, mark)
|
|
104
|
+
return at === null ? null : Math.max(0, Math.round((Date.now() - at) / 60000))
|
|
105
|
+
},
|
|
106
|
+
set(job: string, key: string, mark: string) {
|
|
107
|
+
// INSERT OR IGNORE in the database above, and the same rule here: a mark
|
|
108
|
+
// that is already there keeps its first timestamp, so the age the monitor
|
|
109
|
+
// reads against the blocked timeout never resets.
|
|
110
|
+
if (has(job, key, mark)) return
|
|
111
|
+
pending.set(id(job, key, mark), Date.now())
|
|
112
|
+
},
|
|
113
|
+
clear(job: string, key: string, mark: string) {
|
|
114
|
+
pending.set(id(job, key, mark), null)
|
|
115
|
+
},
|
|
116
|
+
// The test seam, and the one write here that still reaches the database,
|
|
117
|
+
// for a mark that is only there. Nothing in the loop calls it.
|
|
118
|
+
backdate(job: string, key: string, mark: string, minutes: number) {
|
|
119
|
+
const at = pending.get(id(job, key, mark))
|
|
120
|
+
if (at === undefined || at === null) (inner as Partial<State>).backdate?.(job, key, mark, minutes)
|
|
121
|
+
else pending.set(id(job, key, mark), Date.now() - minutes * 60000)
|
|
122
|
+
},
|
|
123
|
+
// tradeoff: a dry tick collects nothing, so the count is honestly zero.
|
|
124
|
+
// Collection is the one write here that would be harmless, and skipping it
|
|
125
|
+
// keeps the rule one sentence: without --live, this database is read-only.
|
|
126
|
+
gc: () => 0,
|
|
127
|
+
} as T
|
|
128
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AccountConfig, AccountUsage } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface StatusInput {
|
|
4
|
+
now: Date
|
|
5
|
+
accounts: AccountConfig[]
|
|
6
|
+
usageFor: (a: AccountConfig) => Promise<AccountUsage>
|
|
7
|
+
// Null for a provider with no refresh token to expire.
|
|
8
|
+
refreshExpiryFor: (a: AccountConfig) => Promise<Date | null>
|
|
9
|
+
spawnsToday: number
|
|
10
|
+
workspaces: { name: string; paused: string[] }[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function renderStatus(o: StatusInput): Promise<string[]> {
|
|
14
|
+
const lines: string[] = []
|
|
15
|
+
for (const a of o.accounts) {
|
|
16
|
+
const usage = await o.usageFor(a).catch((err): AccountUsage => ({ readable: false, reason: String(err) }))
|
|
17
|
+
if (!usage.readable) {
|
|
18
|
+
lines.push(`${a.id}: unreadable (${usage.reason})`)
|
|
19
|
+
} else {
|
|
20
|
+
const windows = usage.windows
|
|
21
|
+
.map((w) => {
|
|
22
|
+
const minutes = Math.max(0, Math.round((w.resetsAt.getTime() - o.now.getTime()) / 60000))
|
|
23
|
+
return `${w.kind} ${w.percent.toFixed(1)}% resets in ${minutes}m`
|
|
24
|
+
})
|
|
25
|
+
.join(", ")
|
|
26
|
+
lines.push(`${a.id}: ${windows}`)
|
|
27
|
+
}
|
|
28
|
+
const expiry = await o.refreshExpiryFor(a).catch(() => null)
|
|
29
|
+
if (expiry) {
|
|
30
|
+
// Refresh tokens are not rotated on refresh, so this ceiling is hard:
|
|
31
|
+
// past it only an interactive login recovers the account.
|
|
32
|
+
const days = Math.floor((expiry.getTime() - o.now.getTime()) / 86400_000)
|
|
33
|
+
lines.push(`${a.id}: refresh token expires in ${days}d`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
lines.push(`spawns today: ${o.spawnsToday}`)
|
|
37
|
+
for (const w of o.workspaces) {
|
|
38
|
+
lines.push(`${w.name}: paused ${w.paused.length ? w.paused.join(", ") : "nothing"}`)
|
|
39
|
+
}
|
|
40
|
+
return lines
|
|
41
|
+
}
|