@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
package/src/check.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { parseConfig } from "./config"
|
|
4
|
+
import { discover, loadWorkspace } from "./discover"
|
|
5
|
+
import { TESTED_PROTOCOL } from "./adapters/herdr"
|
|
6
|
+
import type { Kind } from "./kinds"
|
|
7
|
+
import type { AccountConfig } from "./types"
|
|
8
|
+
|
|
9
|
+
// Injected so the suite can check every branch without a real gh or herdr.
|
|
10
|
+
export interface CheckDeps {
|
|
11
|
+
which(bin: string): string | null
|
|
12
|
+
ghAuth(): Promise<boolean>
|
|
13
|
+
protocol(): Promise<number>
|
|
14
|
+
herdrWorkspaces(): Promise<string[]>
|
|
15
|
+
readConfig(path: string): Promise<string | null>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A worker started in a path its agent has never seen opens on a trust prompt,
|
|
19
|
+
// reports itself idle behind it, and swallows the brief it is then sent, which
|
|
20
|
+
// looks from the log like an agent that simply did not start. Claude records
|
|
21
|
+
// trust per project directory and inherits it downwards, so one trusted
|
|
22
|
+
// ancestor of worktreeBase covers every worktree ever made under it, and a
|
|
23
|
+
// missing one costs every spawn on that account until somebody opens a session
|
|
24
|
+
// by hand. A warning rather than a failure: the file belongs to another tool
|
|
25
|
+
// and may yet record this differently.
|
|
26
|
+
async function trusts(deps: CheckDeps, account: AccountConfig, base: string): Promise<boolean> {
|
|
27
|
+
if (account.provider !== "claude") return true
|
|
28
|
+
const text = await deps.readConfig(`${account.configDir}/.claude.json`)
|
|
29
|
+
if (text === null) return false
|
|
30
|
+
let projects: Record<string, { hasTrustDialogAccepted?: boolean }>
|
|
31
|
+
try {
|
|
32
|
+
projects = JSON.parse(text).projects ?? {}
|
|
33
|
+
} catch {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
for (let dir = base; ; dir = dirname(dir)) {
|
|
37
|
+
if (projects[dir]?.hasTrustDialogAccepted) return true
|
|
38
|
+
if (dirname(dir) === dir) return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runCheck(o: {
|
|
43
|
+
configPath: string
|
|
44
|
+
workspaceDir?: string
|
|
45
|
+
kinds: Record<string, Kind>
|
|
46
|
+
deps: CheckDeps
|
|
47
|
+
}): Promise<{ lines: string[]; ok: boolean }> {
|
|
48
|
+
const lines: string[] = []
|
|
49
|
+
let ok = true
|
|
50
|
+
const fail = (line: string) => { ok = false; lines.push(line) }
|
|
51
|
+
|
|
52
|
+
// Cron's environment is nearly empty, and a setup that works in a login
|
|
53
|
+
// shell failing at 2am on PATH is the classic way this breaks.
|
|
54
|
+
for (const bin of ["gh", "git", "herdr"]) {
|
|
55
|
+
if (o.deps.which(bin)) lines.push(`${bin}: ok`)
|
|
56
|
+
else fail(`${bin}: not on PATH`)
|
|
57
|
+
}
|
|
58
|
+
if (await o.deps.ghAuth()) lines.push("gh: authenticated")
|
|
59
|
+
else fail("gh: not authenticated")
|
|
60
|
+
|
|
61
|
+
const protocol = await o.deps.protocol().catch(() => -1)
|
|
62
|
+
if (protocol !== TESTED_PROTOCOL) {
|
|
63
|
+
lines.push(`WARN herdr protocol ${protocol}, tested ${TESTED_PROTOCOL}`)
|
|
64
|
+
} else {
|
|
65
|
+
lines.push(`herdr protocol ${protocol}: ok`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (o.workspaceDir) {
|
|
69
|
+
// The one-folder form runs in a service repository's own CI, where there
|
|
70
|
+
// is no machine config and no accounts to check selectors against.
|
|
71
|
+
const { ws, errors } = loadWorkspace(o.workspaceDir, {
|
|
72
|
+
kinds: o.kinds,
|
|
73
|
+
accounts: [],
|
|
74
|
+
checkSelectors: false,
|
|
75
|
+
})
|
|
76
|
+
for (const e of errors) fail(e)
|
|
77
|
+
if (ws) lines.push(`${ws.name}: ${ws.jobs.length} job${ws.jobs.length === 1 ? "" : "s"} (${ws.jobs.map((j) => j.name).join(", ")})`)
|
|
78
|
+
lines.push("skipped: account selectors (no config.yml in this form)")
|
|
79
|
+
return { lines, ok }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = await o.deps.readConfig(o.configPath)
|
|
83
|
+
if (text === null) {
|
|
84
|
+
fail(`no config at ${o.configPath}`)
|
|
85
|
+
return { lines, ok }
|
|
86
|
+
}
|
|
87
|
+
const { config, errors: configErrors } = parseConfig(text)
|
|
88
|
+
for (const e of configErrors) fail(e)
|
|
89
|
+
if (configErrors.length) return { lines, ok }
|
|
90
|
+
|
|
91
|
+
// Every spawn resolves the herdr workspace by label, so a label that names
|
|
92
|
+
// nothing throws once per due item forever and never once at configure time.
|
|
93
|
+
// Unreadable is not the same as absent: a herdr that is not running fails
|
|
94
|
+
// every job below anyway, and guessing here would report the wrong one.
|
|
95
|
+
const labels = await o.deps.herdrWorkspaces().then((l) => l, () => null)
|
|
96
|
+
if (labels === null) lines.push("WARN could not ask herdr for its workspaces")
|
|
97
|
+
|
|
98
|
+
lines.push(`${config.accounts.length} account${config.accounts.length === 1 ? "" : "s"}: ${config.accounts.map((a) => a.id).join(", ")}`)
|
|
99
|
+
const { workspaces, errors } = discover(config, {
|
|
100
|
+
kinds: o.kinds,
|
|
101
|
+
accounts: config.accounts,
|
|
102
|
+
checkSelectors: true,
|
|
103
|
+
})
|
|
104
|
+
for (const e of errors) fail(e)
|
|
105
|
+
for (const ws of workspaces) {
|
|
106
|
+
lines.push(`${ws.name}: ${ws.jobs.length} job${ws.jobs.length === 1 ? "" : "s"} (${ws.jobs.map((j) => j.name).join(", ")})`)
|
|
107
|
+
// Spec 3.5: check resolves every path. A repos: entry pointing at a folder
|
|
108
|
+
// that was never cloned otherwise passes with exit 0 and then produces an
|
|
109
|
+
// ERROR ... sweep line every two minutes. Only in this form: the one-folder
|
|
110
|
+
// form runs in a service repository's CI, where the sibling trees may
|
|
111
|
+
// legitimately not be checked out.
|
|
112
|
+
for (const [key, path] of Object.entries(ws.repos)) {
|
|
113
|
+
if (!existsSync(path)) fail(`${ws.name}: repo "${key}" at ${path} does not exist`)
|
|
114
|
+
else if (!existsSync(`${path}/.git`)) fail(`${ws.name}: repo "${key}" at ${path} is not a git repository`)
|
|
115
|
+
}
|
|
116
|
+
if (labels !== null && !labels.includes(ws.herdrWorkspace)) {
|
|
117
|
+
fail(`${ws.name}: no herdr workspace labelled "${ws.herdrWorkspace}"${labels.length ? `; herdr has: ${labels.join(", ")}` : "; herdr has none"}`)
|
|
118
|
+
}
|
|
119
|
+
for (const account of config.accounts) {
|
|
120
|
+
if (!(await trusts(o.deps, account, ws.worktreeBase))) {
|
|
121
|
+
lines.push(`WARN ${ws.name}: account ${account.id} has not trusted ${ws.worktreeBase}; a worker there waits on the trust prompt and never reads its brief`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { lines, ok }
|
|
126
|
+
}
|
package/src/cli-pause.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
|
|
3
|
+
// Pause blocks spawn only. Sweep and monitor always run: the monitor is what
|
|
4
|
+
// removes a claim label from a merged PR, so a pause that froze it would
|
|
5
|
+
// manufacture lying claims.
|
|
6
|
+
export function pausedJobs(stateDir: string, jobs: string[]): string[] {
|
|
7
|
+
// Only `pause`, which is what pauseMarker writes for the workspace-wide
|
|
8
|
+
// form. A `pause-all` alias would make a job named "all", a legal job name,
|
|
9
|
+
// pause the whole workspace.
|
|
10
|
+
if (existsSync(`${stateDir}/pause`)) return [...jobs]
|
|
11
|
+
return jobs.filter((name) => existsSync(`${stateDir}/pause-${name}`))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function pauseMarker(stateDir: string, job?: string): string {
|
|
15
|
+
return job ? `${stateDir}/pause-${job}` : `${stateDir}/pause`
|
|
16
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { loadConfig } from "./config"
|
|
3
|
+
import { discover } from "./discover"
|
|
4
|
+
import { KINDS, describeKind, kindSchema } from "./kinds"
|
|
5
|
+
import { runCheck } from "./check"
|
|
6
|
+
import { TESTED_PROTOCOL } from "./adapters/herdr"
|
|
7
|
+
import { agentLoopHome } from "./paths"
|
|
8
|
+
import { pausedJobs, pauseMarker } from "./cli-pause"
|
|
9
|
+
import { openState } from "./state"
|
|
10
|
+
import { makeCtx } from "./ctx"
|
|
11
|
+
import { fileLock } from "./lock"
|
|
12
|
+
import { makeGh } from "./adapters/gh"
|
|
13
|
+
import { makeGit } from "./adapters/git"
|
|
14
|
+
import { makeHerdr } from "./adapters/herdr"
|
|
15
|
+
import { makeRunners } from "./adapters/run"
|
|
16
|
+
import { runTick } from "./engine/tick"
|
|
17
|
+
import { renderDecision } from "./render"
|
|
18
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"
|
|
19
|
+
import { openGlobalState } from "./globalstate"
|
|
20
|
+
import { makeClaudeReader, liveClaudeDeps } from "./router/providers/claude"
|
|
21
|
+
import { makeCodexReader, liveCodexDeps } from "./router/providers/codex"
|
|
22
|
+
import { grokReader } from "./router/providers/grok"
|
|
23
|
+
import type { Provider, UsageReader, WorkspaceConfig } from "./types"
|
|
24
|
+
import { dirname } from "node:path"
|
|
25
|
+
import { renderStatus } from "./status"
|
|
26
|
+
import { adopt, renderMarks } from "./adopt"
|
|
27
|
+
|
|
28
|
+
function arg(name: string): string | undefined {
|
|
29
|
+
const i = process.argv.indexOf(`--${name}`)
|
|
30
|
+
return i === -1 ? undefined : process.argv[i + 1]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The one positional argument (a job name), which can appear anywhere
|
|
34
|
+
// after the command, not just right after it: skip every flag and, for the
|
|
35
|
+
// flags that take one, its value too.
|
|
36
|
+
const FLAGS_WITH_VALUE = ["--workspace", "--config", "--state-dir", "--global-state"]
|
|
37
|
+
function positionals(): string[] {
|
|
38
|
+
const out: string[] = []
|
|
39
|
+
const rest = process.argv.slice(3)
|
|
40
|
+
for (let i = 0; i < rest.length; i++) {
|
|
41
|
+
const a = rest[i]!
|
|
42
|
+
if (FLAGS_WITH_VALUE.includes(a)) { i++; continue }
|
|
43
|
+
if (a.startsWith("--")) continue
|
|
44
|
+
out.push(a)
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
const jobArg = (): string | undefined => positionals()[0]
|
|
49
|
+
|
|
50
|
+
const cmd = process.argv[2]
|
|
51
|
+
const live = process.argv.includes("--live")
|
|
52
|
+
|
|
53
|
+
if (!["tick", "check", "kinds", "pause", "resume", "status", "adopt"].includes(cmd ?? "")) {
|
|
54
|
+
console.error(
|
|
55
|
+
"usage: agent-loop <tick|check|kinds|status|pause|resume|adopt> [--workspace <name>]\n" +
|
|
56
|
+
" [--config <path>] [--state-dir <path>] [--global-state <path>]\n" +
|
|
57
|
+
" [--live] [<job>] [<key>]\n" +
|
|
58
|
+
" agent-loop adopt <job> [<key>] --workspace <name> record a spawned mark\n" +
|
|
59
|
+
" agent-loop adopt --list --workspace <name> print this workspace's marks",
|
|
60
|
+
)
|
|
61
|
+
process.exit(2)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stamp = () => new Date().toISOString().replace(/\.\d+Z$/, "Z")
|
|
65
|
+
// The whole process, config load and discovery included. One cron line covers
|
|
66
|
+
// every workspace on the box, so the interval has to cover the sum: 4.2's
|
|
67
|
+
// cadence rule read against one workspace's share under-sizes it in
|
|
68
|
+
// proportion to how many workspaces are on the box.
|
|
69
|
+
const processStarted = Date.now()
|
|
70
|
+
|
|
71
|
+
if (cmd === "kinds") {
|
|
72
|
+
const names = Object.keys(KINDS)
|
|
73
|
+
const want = jobArg()
|
|
74
|
+
const chosen = want ? [KINDS[want]].filter(Boolean) : names.map((n) => KINDS[n]!)
|
|
75
|
+
if (want && !chosen.length) {
|
|
76
|
+
console.error(`unknown kind "${want}"; known: ${names.join(", ")}`)
|
|
77
|
+
process.exit(2)
|
|
78
|
+
}
|
|
79
|
+
if (process.argv.includes("--json")) {
|
|
80
|
+
console.log(JSON.stringify(Object.fromEntries(chosen.map((k) => [k!.name, kindSchema(k!)])), null, 2))
|
|
81
|
+
} else {
|
|
82
|
+
for (const k of chosen) for (const line of describeKind(k!)) console.log(line)
|
|
83
|
+
}
|
|
84
|
+
process.exit(0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (cmd === "check") {
|
|
88
|
+
const positional = jobArg()
|
|
89
|
+
const { lines, ok } = await runCheck({
|
|
90
|
+
configPath: arg("config") ?? `${agentLoopHome()}/config.yml`,
|
|
91
|
+
workspaceDir: positional,
|
|
92
|
+
kinds: KINDS,
|
|
93
|
+
deps: {
|
|
94
|
+
which: (b) => Bun.which(b),
|
|
95
|
+
ghAuth: async () => makeRunners(false).runText(["gh", "auth", "status"]).then(() => true, () => false),
|
|
96
|
+
protocol: () => makeHerdr(makeRunners(false).runJson).protocol(),
|
|
97
|
+
herdrWorkspaces: async () =>
|
|
98
|
+
(await makeHerdr(makeRunners(false).runJson).workspaces()).map((w) => w.label),
|
|
99
|
+
readConfig: async (p) => (await Bun.file(p).exists()) ? Bun.file(p).text() : null,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
for (const line of lines) console.log(line)
|
|
103
|
+
process.exit(ok ? 0 : 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Below the read-only commands on purpose. `kinds` and `check` perform no
|
|
107
|
+
// writes (spec 3.5), and the README sells `agent-loop check .` as a service
|
|
108
|
+
// repository's CI check, where a writable home is not a given: opening the
|
|
109
|
+
// store above would create ~/.agent-loop/state.db for a command that only
|
|
110
|
+
// reads, and die with a raw EACCES stack trace before validating anything.
|
|
111
|
+
const globalPath = arg("global-state") ?? `${agentLoopHome()}/state.db`
|
|
112
|
+
mkdirSync(dirname(globalPath), { recursive: true })
|
|
113
|
+
const global = openGlobalState(globalPath)
|
|
114
|
+
|
|
115
|
+
const configPath = arg("config") ?? `${agentLoopHome()}/config.yml`
|
|
116
|
+
const config = await loadConfig(configPath)
|
|
117
|
+
|
|
118
|
+
const { workspaces: found, errors } = discover(config, {
|
|
119
|
+
kinds: KINDS,
|
|
120
|
+
accounts: config.accounts,
|
|
121
|
+
checkSelectors: true,
|
|
122
|
+
})
|
|
123
|
+
// Reported every tick, never once: a workspace that stops ticking silently is
|
|
124
|
+
// the failure nobody notices.
|
|
125
|
+
for (const reason of errors) console.log(`${stamp()} ${renderDecision({ pass: "error", where: "workspace", workspace: "-", reason })}`)
|
|
126
|
+
|
|
127
|
+
const wsName = arg("workspace")
|
|
128
|
+
if (wsName && !found.some((w) => w.name === wsName)) {
|
|
129
|
+
console.error(`unknown workspace "${wsName}"`)
|
|
130
|
+
process.exit(2)
|
|
131
|
+
}
|
|
132
|
+
const selected = wsName ? found.filter((w) => w.name === wsName) : found
|
|
133
|
+
|
|
134
|
+
if (arg("state-dir") && selected.length !== 1) {
|
|
135
|
+
console.error("--state-dir applies to one workspace; pass --workspace too")
|
|
136
|
+
process.exit(2)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stateDirFor = (name: string) => arg("state-dir") ?? `${agentLoopHome()}/${name}`
|
|
140
|
+
|
|
141
|
+
if (cmd === "pause" || cmd === "resume") {
|
|
142
|
+
if (selected.length !== 1) {
|
|
143
|
+
console.error("pause and resume apply to one workspace; pass --workspace")
|
|
144
|
+
process.exit(2)
|
|
145
|
+
}
|
|
146
|
+
const ws = selected[0]!
|
|
147
|
+
const job = jobArg()
|
|
148
|
+
// Pause is the cutover and maintenance control: a typo'd job name must
|
|
149
|
+
// not report success while leaving the loop free to keep spawning it.
|
|
150
|
+
if (job && !ws.jobs.some((j) => j.name === job)) {
|
|
151
|
+
console.error(`unknown job "${job}" in workspace "${ws.name}"`)
|
|
152
|
+
process.exit(2)
|
|
153
|
+
}
|
|
154
|
+
const dir = stateDirFor(ws.name)
|
|
155
|
+
mkdirSync(dir, { recursive: true })
|
|
156
|
+
const marker = pauseMarker(dir, job)
|
|
157
|
+
if (cmd === "pause") writeFileSync(marker, "")
|
|
158
|
+
else if (existsSync(marker)) unlinkSync(marker)
|
|
159
|
+
console.log(`${cmd}d ${job ?? "the whole workspace"}`)
|
|
160
|
+
process.exit(0)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// status is inspection-only regardless of --live: only a tick's own spawn may
|
|
164
|
+
// cause a token write-back.
|
|
165
|
+
const readers: Record<Provider, UsageReader> = {
|
|
166
|
+
claude: makeClaudeReader(liveClaudeDeps(cmd === "tick" && live)),
|
|
167
|
+
codex: makeCodexReader(liveCodexDeps()),
|
|
168
|
+
grok: grokReader,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { runText: rt, runJson: rj } = makeRunners(live)
|
|
172
|
+
|
|
173
|
+
// Shared by the tick loop and by adopt, which needs a context only to ask a
|
|
174
|
+
// routine which occurrence is due right now.
|
|
175
|
+
const ctxFor = (ws: WorkspaceConfig, marks: ReturnType<typeof openState>) =>
|
|
176
|
+
makeCtx({
|
|
177
|
+
workspace: ws,
|
|
178
|
+
// The full discovered list, not `selected`: concurrency is an
|
|
179
|
+
// account-scoped fact across the whole box (spec 6.3 and 7), so a
|
|
180
|
+
// `--workspace` run ticking one workspace must still see live workers
|
|
181
|
+
// under every other one, or it over-admits against the account's ceiling.
|
|
182
|
+
workspaces: found,
|
|
183
|
+
config,
|
|
184
|
+
now: new Date(),
|
|
185
|
+
live,
|
|
186
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
187
|
+
lock: fileLock(),
|
|
188
|
+
gh: makeGh(rj, rt),
|
|
189
|
+
gitFor: (repo) => makeGit(rt, repo),
|
|
190
|
+
herdr: makeHerdr(rj),
|
|
191
|
+
marks,
|
|
192
|
+
global,
|
|
193
|
+
usageFor: (a, at) => readers[a.provider](a, at),
|
|
194
|
+
memAvailableMb,
|
|
195
|
+
sink: (d) => console.log(`${stamp()} ${renderDecision(d, live)}`),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if (cmd === "status") {
|
|
199
|
+
// One instant for the whole render: "resets in Xm" must be relative to the
|
|
200
|
+
// same now as the usage read that produced it.
|
|
201
|
+
const now = new Date()
|
|
202
|
+
const lines = await renderStatus({
|
|
203
|
+
now,
|
|
204
|
+
accounts: config.accounts,
|
|
205
|
+
usageFor: (a) => readers[a.provider](a, now),
|
|
206
|
+
refreshExpiryFor: async (a) => {
|
|
207
|
+
if (a.provider !== "claude") return null
|
|
208
|
+
const creds = await liveClaudeDeps(false).readCreds(a.configDir)
|
|
209
|
+
return creds?.refreshTokenExpiresAt ? new Date(creds.refreshTokenExpiresAt) : null
|
|
210
|
+
},
|
|
211
|
+
spawnsToday: global.spawnsSince(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())),
|
|
212
|
+
workspaces: selected.map((ws) => ({
|
|
213
|
+
name: ws.name,
|
|
214
|
+
paused: pausedJobs(stateDirFor(ws.name), ws.jobs.map((j) => j.name)),
|
|
215
|
+
})),
|
|
216
|
+
})
|
|
217
|
+
for (const line of lines) console.log(line)
|
|
218
|
+
global.close()
|
|
219
|
+
process.exit(0)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Below status and above the tick: adopt is the cutover's import path
|
|
223
|
+
// (docs/cutover.md phase 2), so it writes the marks database on purpose and without
|
|
224
|
+
// --live. It touches nothing else, and no process at all.
|
|
225
|
+
if (cmd === "adopt") {
|
|
226
|
+
if (selected.length !== 1) {
|
|
227
|
+
console.error("adopt applies to one workspace; pass --workspace")
|
|
228
|
+
process.exit(2)
|
|
229
|
+
}
|
|
230
|
+
const ws = selected[0]!
|
|
231
|
+
const dir = stateDirFor(ws.name)
|
|
232
|
+
mkdirSync(dir, { recursive: true })
|
|
233
|
+
const marks = openState(`${dir}/state.db`)
|
|
234
|
+
try {
|
|
235
|
+
if (process.argv.includes("--list")) {
|
|
236
|
+
for (const line of renderMarks(marks.all(), new Date())) console.log(line)
|
|
237
|
+
} else {
|
|
238
|
+
const [name, wanted] = positionals()
|
|
239
|
+
const job = ws.jobs.find((j) => j.name === name)
|
|
240
|
+
if (!job) throw new Error(`unknown job "${name ?? ""}" in workspace "${ws.name}"`)
|
|
241
|
+
const { key, already } = await adopt(ctxFor(ws, marks), marks, job, wanted)
|
|
242
|
+
console.log(already ? `${job.name} ${key} was already recorded` : `adopted ${job.name} ${key}`)
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.error(String(e).replace(/^Error: /, ""))
|
|
246
|
+
marks.close()
|
|
247
|
+
global.close()
|
|
248
|
+
process.exit(2)
|
|
249
|
+
}
|
|
250
|
+
marks.close()
|
|
251
|
+
global.close()
|
|
252
|
+
process.exit(0)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// MemAvailable is the kernel's own estimate of what a new process can get
|
|
256
|
+
// without swapping, which is the question being asked. Treat an unreadable
|
|
257
|
+
// /proc as unlimited rather than blocking the loop on a parse failure.
|
|
258
|
+
async function memAvailableMb(): Promise<number> {
|
|
259
|
+
try {
|
|
260
|
+
const m = (await Bun.file("/proc/meminfo").text()).match(/^MemAvailable:\s+(\d+) kB/m)
|
|
261
|
+
return m ? Math.round(Number(m[1]) / 1024) : Number.POSITIVE_INFINITY
|
|
262
|
+
} catch {
|
|
263
|
+
return Number.POSITIVE_INFINITY
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const protocol = await makeHerdr(rj).protocol().catch(() => -1)
|
|
268
|
+
if (protocol !== TESTED_PROTOCOL) {
|
|
269
|
+
console.log(`${stamp()} WARN herdr protocol ${protocol}, tested ${TESTED_PROTOCOL}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const ws of selected) {
|
|
273
|
+
const stateDir = stateDirFor(ws.name)
|
|
274
|
+
mkdirSync(stateDir, { recursive: true })
|
|
275
|
+
const marks = openState(`${stateDir}/state.db`)
|
|
276
|
+
try {
|
|
277
|
+
const ctx = ctxFor(ws, marks)
|
|
278
|
+
const names = ws.jobs.map((j) => j.name)
|
|
279
|
+
const paused = pausedJobs(stateDir, names)
|
|
280
|
+
await runTick(ctx, ws.jobs, { paused: paused.length === names.length, pausedJobs: paused })
|
|
281
|
+
} catch (e) {
|
|
282
|
+
// One service mid-edit or one unreachable remote must not stop the rest of
|
|
283
|
+
// the box for the next two minutes.
|
|
284
|
+
console.log(`${stamp()} ${renderDecision({ pass: "error", where: "workspace", workspace: ws.name, reason: String(e) })}`)
|
|
285
|
+
} finally {
|
|
286
|
+
marks.close()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log(`${stamp()} ${renderDecision({ pass: "tick", workspace: "total", ms: Date.now() - processStarted })}`)
|
|
290
|
+
global.close()
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { AccountConfig, Config, Provider } from "./types"
|
|
2
|
+
import { expandHome } from "./paths"
|
|
3
|
+
import { unknownKey } from "./kinds"
|
|
4
|
+
import { isAbsolute } from "node:path"
|
|
5
|
+
|
|
6
|
+
const KNOWN_PROVIDERS: Provider[] = ["claude", "codex", "grok"]
|
|
7
|
+
|
|
8
|
+
// An unknown key is a typo, and a typo here is silent: `reserved: 40` leaves
|
|
9
|
+
// the reserve at 0, so the one mechanism keeping the loop out of a human's
|
|
10
|
+
// quota disappears with nothing said, and `maxSpawnsPerday: 5` leaves the
|
|
11
|
+
// runaway circuit breaker at 200. Rejecting it is what makes `check` catch
|
|
12
|
+
// this at the commit that broke it (spec 3.5).
|
|
13
|
+
const CONFIG_KEYS = [
|
|
14
|
+
"accounts", "workspaces", "maxConcurrentPerAccount", "minFreeMb", "usageMax",
|
|
15
|
+
"releaseBefore", "maxSpawnsPerDay", "blockedTimeoutMin", "workerRateSeed",
|
|
16
|
+
]
|
|
17
|
+
const ACCOUNT_KEYS = [
|
|
18
|
+
"id", "provider", "configDir", "reserve", "reservePerWeekday", "weekendWeight", "soleConsumer", "maxConcurrent", "allowWhenUnreadable",
|
|
19
|
+
"agentKind", "startArgs", "model", "oauthClientId", "configEnv",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
// Clamps have defaults so a working config.yml is accounts plus workspaces.
|
|
23
|
+
// Every value here is the one the spec's example carries.
|
|
24
|
+
export const DEFAULTS = {
|
|
25
|
+
maxConcurrentPerAccount: 4,
|
|
26
|
+
minFreeMb: 3000,
|
|
27
|
+
usageMax: 90,
|
|
28
|
+
releaseBefore: 120,
|
|
29
|
+
maxSpawnsPerDay: 200,
|
|
30
|
+
blockedTimeoutMin: 180,
|
|
31
|
+
workerRateSeed: 0.35,
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
// A selector names an account directly or names every account of a provider.
|
|
35
|
+
export function selects(a: AccountConfig, selector: string): boolean {
|
|
36
|
+
return selector === a.id || selector === a.provider
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
40
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function num(v: unknown, name: string, dflt: number, errs: string[]): number {
|
|
44
|
+
if (v === undefined || v === null) return dflt
|
|
45
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
46
|
+
errs.push(`${name} must be a number`)
|
|
47
|
+
return dflt
|
|
48
|
+
}
|
|
49
|
+
return v
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function account(raw: unknown, i: number, errs: string[]): AccountConfig | null {
|
|
53
|
+
if (!isRecord(raw)) {
|
|
54
|
+
errs.push(`accounts[${i}] must be a mapping`)
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
const id = typeof raw.id === "string" ? raw.id : ""
|
|
58
|
+
if (!id) {
|
|
59
|
+
errs.push(`accounts[${i}]: id is required`)
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
for (const key of Object.keys(raw)) {
|
|
63
|
+
if (!ACCOUNT_KEYS.includes(key)) errs.push(`account "${id}": ${unknownKey(key, ACCOUNT_KEYS)}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const provider = raw.provider as Provider
|
|
67
|
+
if (!KNOWN_PROVIDERS.includes(provider)) {
|
|
68
|
+
errs.push(
|
|
69
|
+
`account "${id}": unknown provider "${String(raw.provider)}"; known: ${KNOWN_PROVIDERS.join(", ")}`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
if (typeof raw.configDir !== "string" || !raw.configDir) {
|
|
73
|
+
errs.push(`account "${id}": configDir is required`)
|
|
74
|
+
}
|
|
75
|
+
const reserve = num(raw.reserve, `account "${id}": reserve`, 0, errs)
|
|
76
|
+
if (reserve < 0 || reserve > 100) {
|
|
77
|
+
errs.push(`account "${id}": reserve must be between 0 and 100`)
|
|
78
|
+
}
|
|
79
|
+
const reservePerWeekday = num(raw.reservePerWeekday, `account "${id}": reservePerWeekday`, 0, errs)
|
|
80
|
+
if (reservePerWeekday < 0 || reservePerWeekday > 100) {
|
|
81
|
+
errs.push(`account "${id}": reservePerWeekday must be between 0 and 100`)
|
|
82
|
+
}
|
|
83
|
+
const weekendWeight = num(raw.weekendWeight, `account "${id}": weekendWeight`, 0.25, errs)
|
|
84
|
+
if (weekendWeight < 0 || weekendWeight > 1) {
|
|
85
|
+
errs.push(`account "${id}": weekendWeight must be between 0 and 1`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const str = (v: unknown, field: string): string | undefined => {
|
|
89
|
+
if (v === undefined || v === null) return undefined
|
|
90
|
+
if (typeof v !== "string") { errs.push(`account "${id}": ${field} must be a string`); return undefined }
|
|
91
|
+
return v
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let allowWhenUnreadable: boolean | undefined
|
|
95
|
+
if (raw.allowWhenUnreadable !== undefined && raw.allowWhenUnreadable !== null) {
|
|
96
|
+
if (typeof raw.allowWhenUnreadable !== "boolean") {
|
|
97
|
+
errs.push(`account "${id}": allowWhenUnreadable must be true or false`)
|
|
98
|
+
} else {
|
|
99
|
+
allowWhenUnreadable = raw.allowWhenUnreadable
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let startArgs: string[] | undefined
|
|
104
|
+
if (raw.startArgs !== undefined && raw.startArgs !== null) {
|
|
105
|
+
if (!Array.isArray(raw.startArgs) || !raw.startArgs.every((s) => typeof s === "string")) {
|
|
106
|
+
errs.push(`account "${id}": startArgs must be a list of strings`)
|
|
107
|
+
} else {
|
|
108
|
+
startArgs = raw.startArgs
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// maxConcurrent stays undefined rather than defaulted: the code downstream
|
|
113
|
+
// distinguishes "unset" from an explicit value, including an invalid one.
|
|
114
|
+
const maxConcurrentNum = num(raw.maxConcurrent, `account "${id}": maxConcurrent`, Number.NaN, errs)
|
|
115
|
+
const maxConcurrent = Number.isNaN(maxConcurrentNum) ? undefined : maxConcurrentNum
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
provider,
|
|
120
|
+
configDir: expandHome(typeof raw.configDir === "string" ? raw.configDir : ""),
|
|
121
|
+
reserve,
|
|
122
|
+
reservePerWeekday,
|
|
123
|
+
weekendWeight,
|
|
124
|
+
soleConsumer: raw.soleConsumer === true ? true : undefined,
|
|
125
|
+
maxConcurrent,
|
|
126
|
+
allowWhenUnreadable,
|
|
127
|
+
agentKind: str(raw.agentKind, "agentKind"),
|
|
128
|
+
startArgs,
|
|
129
|
+
model: str(raw.model, "model"),
|
|
130
|
+
oauthClientId: str(raw.oauthClientId, "oauthClientId"),
|
|
131
|
+
configEnv: str(raw.configEnv, "configEnv"),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Returns every error rather than throwing at the first, because `check` prints
|
|
136
|
+
// them all and an operator fixing one at a time is the slowest possible loop.
|
|
137
|
+
export function parseConfig(text: string): { config: Config; errors: string[] } {
|
|
138
|
+
const errs: string[] = []
|
|
139
|
+
let raw: unknown
|
|
140
|
+
try {
|
|
141
|
+
raw = Bun.YAML.parse(text)
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { config: empty(), errors: [`config is not valid YAML: ${String(e)}`] }
|
|
144
|
+
}
|
|
145
|
+
if (!isRecord(raw)) return { config: empty(), errors: ["config must be a mapping of settings"] }
|
|
146
|
+
|
|
147
|
+
for (const key of Object.keys(raw)) {
|
|
148
|
+
if (!CONFIG_KEYS.includes(key)) errs.push(unknownKey(key, CONFIG_KEYS))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const accounts: AccountConfig[] = []
|
|
152
|
+
if (!Array.isArray(raw.accounts) || raw.accounts.length === 0) {
|
|
153
|
+
errs.push("accounts must be a non-empty list")
|
|
154
|
+
} else {
|
|
155
|
+
const seen = new Set<string>()
|
|
156
|
+
raw.accounts.forEach((a, i) => {
|
|
157
|
+
const parsed = account(a, i, errs)
|
|
158
|
+
if (!parsed) return
|
|
159
|
+
if (seen.has(parsed.id)) errs.push(`duplicate account id "${parsed.id}"`)
|
|
160
|
+
seen.add(parsed.id)
|
|
161
|
+
accounts.push(parsed)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const workspaces: string[] = []
|
|
166
|
+
if (!Array.isArray(raw.workspaces) || raw.workspaces.length === 0) {
|
|
167
|
+
errs.push("workspaces must be a non-empty list of paths")
|
|
168
|
+
} else {
|
|
169
|
+
raw.workspaces.forEach((p, i) => {
|
|
170
|
+
if (typeof p !== "string") errs.push(`workspaces[${i}] must be a path`)
|
|
171
|
+
else {
|
|
172
|
+
const expanded = expandHome(p)
|
|
173
|
+
// The path is used as given, from whatever directory cron started the
|
|
174
|
+
// tick in, so a relative one is silently a different folder depending
|
|
175
|
+
// on how the binary was invoked.
|
|
176
|
+
if (!isAbsolute(expanded)) errs.push(`workspaces[${i}] "${p}" must be an absolute path or start with ~`)
|
|
177
|
+
else workspaces.push(expanded)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workerRateSeed = num(raw.workerRateSeed, "workerRateSeed", DEFAULTS.workerRateSeed, errs)
|
|
183
|
+
if (!(workerRateSeed > 0)) errs.push("workerRateSeed must be greater than 0")
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
config: {
|
|
187
|
+
accounts,
|
|
188
|
+
workspaces,
|
|
189
|
+
maxConcurrentPerAccount: num(raw.maxConcurrentPerAccount, "maxConcurrentPerAccount", DEFAULTS.maxConcurrentPerAccount, errs),
|
|
190
|
+
minFreeMb: num(raw.minFreeMb, "minFreeMb", DEFAULTS.minFreeMb, errs),
|
|
191
|
+
usageMax: num(raw.usageMax, "usageMax", DEFAULTS.usageMax, errs),
|
|
192
|
+
releaseBefore: num(raw.releaseBefore, "releaseBefore", DEFAULTS.releaseBefore, errs),
|
|
193
|
+
maxSpawnsPerDay: num(raw.maxSpawnsPerDay, "maxSpawnsPerDay", DEFAULTS.maxSpawnsPerDay, errs),
|
|
194
|
+
blockedTimeoutMin: num(raw.blockedTimeoutMin, "blockedTimeoutMin", DEFAULTS.blockedTimeoutMin, errs),
|
|
195
|
+
workerRateSeed,
|
|
196
|
+
},
|
|
197
|
+
errors: errs,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function empty(): Config {
|
|
202
|
+
return { accounts: [], workspaces: [], ...DEFAULTS }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function loadConfig(path: string): Promise<Config> {
|
|
206
|
+
const file = Bun.file(path)
|
|
207
|
+
if (!(await file.exists())) throw new Error(`no config at ${path}`)
|
|
208
|
+
const { config, errors } = parseConfig(await file.text())
|
|
209
|
+
if (errors.length) throw new Error(`invalid config at ${path}:\n ${errors.join("\n ")}`)
|
|
210
|
+
return config
|
|
211
|
+
}
|