@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,246 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, statSync, chmodSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import type { Ctx, Job, WorkItem, AccountConfig } from "../types"
|
|
4
|
+
import { owns, keyOf, matchesCwd, worktreePath, branchName } from "../engine/naming"
|
|
5
|
+
import { itemKind, repoOf, trackerless } from "../engine/item"
|
|
6
|
+
import { withRepoLock } from "../lock"
|
|
7
|
+
import { expandHome } from "../paths"
|
|
8
|
+
import { startWorker } from "../runtime/worker"
|
|
9
|
+
|
|
10
|
+
function repoPath(ctx: Ctx, p: Job): string {
|
|
11
|
+
const repo = ctx.workspace.repos[p.repo ?? ""]
|
|
12
|
+
if (!repo) throw new Error(`job "${p.name}" has no resolvable repo`)
|
|
13
|
+
return repo
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Idempotent, and it runs on every path. Round two of a multi-round job
|
|
17
|
+
// otherwise fails at `worktree add -b` against a path and branch that both
|
|
18
|
+
// still exist, and a crash between the claim and the worker start leaves an
|
|
19
|
+
// orphan no sweep predicate can match.
|
|
20
|
+
export async function preClean(ctx: Ctx, p: Job, key: string): Promise<void> {
|
|
21
|
+
const repo = repoPath(ctx, p)
|
|
22
|
+
const base = ctx.workspace.worktreeBase
|
|
23
|
+
const git = ctx.git(repo)
|
|
24
|
+
|
|
25
|
+
// Read fresh rather than through the tick cache: the sweep pass earlier in
|
|
26
|
+
// this same tick may already have removed some of these.
|
|
27
|
+
const worktrees = await git.worktrees()
|
|
28
|
+
const mine = worktrees.filter((wt) => owns(p.name, base, wt) && keyOf(p.name, wt.branch) === key)
|
|
29
|
+
|
|
30
|
+
const panes = await ctx.herdr.panes()
|
|
31
|
+
for (const wt of mine) {
|
|
32
|
+
const pane = panes.find((x) => matchesCwd(x.cwd, wt.path))
|
|
33
|
+
if (pane) await ctx.herdr.tabClose(pane.tabId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await withRepoLock(repo, ctx.lock, async () => {
|
|
37
|
+
for (const wt of mine) await git.worktreeRemove(wt.path)
|
|
38
|
+
// By computed name rather than per worktree, and last so no worktree still
|
|
39
|
+
// holds it: `mine` only ever contains this key's branch anyway, and a
|
|
40
|
+
// branch whose worktree removal succeeded while its delete failed is listed
|
|
41
|
+
// by nothing here or in the sweep, yet `worktree add -b` refuses forever
|
|
42
|
+
// while it exists. Failure is ignored: usually it simply is not there.
|
|
43
|
+
await git.branchDelete(branchName(p.name, key)).catch(() => {})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The write is checked. Unchecked, a forge 5xx produces a live worker with no
|
|
48
|
+
// claim, the item is re-picked next tick, and the second spawn's pre-clean
|
|
49
|
+
// removes the first worker's worktree out from under a running agent.
|
|
50
|
+
export async function claim(ctx: Ctx, p: Job, item: WorkItem): Promise<boolean> {
|
|
51
|
+
// Nothing to claim, so nothing can fail to stick: the spawn proceeds and the
|
|
52
|
+
// spawned mark is the claim.
|
|
53
|
+
if (trackerless(item)) return true
|
|
54
|
+
const label = ctx.workspace.naming.labels.claim
|
|
55
|
+
const repo = repoOf(item)
|
|
56
|
+
const kind = itemKind(item)
|
|
57
|
+
await ctx.gh.label(repo, kind, item.number, { add: [label] })
|
|
58
|
+
return (await ctx.gh.labelsOf(repo, kind, item.number)).includes(label)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function unclaim(ctx: Ctx, item: WorkItem): Promise<void> {
|
|
62
|
+
if (trackerless(item)) return
|
|
63
|
+
await ctx.gh.label(repoOf(item), itemKind(item), item.number, {
|
|
64
|
+
remove: [ctx.workspace.naming.labels.claim],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function createWorktree(
|
|
69
|
+
ctx: Ctx,
|
|
70
|
+
p: Job,
|
|
71
|
+
item: WorkItem,
|
|
72
|
+
key: string,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
if (!p.base) throw new Error(`job "${p.name}" has no base(), so it cannot spawn`)
|
|
75
|
+
const repo = repoPath(ctx, p)
|
|
76
|
+
const path = worktreePath(ctx.workspace.worktreeBase, p.name, key)
|
|
77
|
+
const branch = branchName(p.name, key)
|
|
78
|
+
const from = await p.base(ctx, item)
|
|
79
|
+
const git = ctx.git(repo)
|
|
80
|
+
|
|
81
|
+
// Both under the lock: two workspaces ticking the same minute against one
|
|
82
|
+
// repo collide on .git/worktrees/* and index.lock, and a transient git lock
|
|
83
|
+
// failure here would unclaim the item and count a strike against it.
|
|
84
|
+
await withRepoLock(repo, ctx.lock, async () => {
|
|
85
|
+
await git.fetch()
|
|
86
|
+
await git.worktreeAdd(path, branch, from)
|
|
87
|
+
})
|
|
88
|
+
return path
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function prepareWorktree(ctx: Ctx, p: Job, path: string): Promise<void> {
|
|
92
|
+
const repo = repoPath(ctx, p)
|
|
93
|
+
for (const rel of p.copyIntoWorktree ?? []) {
|
|
94
|
+
const src = `${repo}/${rel}`
|
|
95
|
+
// A local env file that does not exist on this box is not a reason to
|
|
96
|
+
// fail the spawn and take the pipeline down with it.
|
|
97
|
+
if (!existsSync(src)) continue
|
|
98
|
+
const dst = `${path}/${rel}`
|
|
99
|
+
mkdirSync(dirname(dst), { recursive: true })
|
|
100
|
+
copyFileSync(src, dst)
|
|
101
|
+
// Preserve the mode: these are usually secrets at 0600, and copyFileSync
|
|
102
|
+
// applies the process umask instead.
|
|
103
|
+
chmodSync(dst, statSync(src).mode & 0o777)
|
|
104
|
+
}
|
|
105
|
+
if (p.prepare) await p.prepare(ctx, path)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// tab create is the only verb that accepts --env, so this is the sole channel
|
|
109
|
+
// by which the router's account choice reaches a worker.
|
|
110
|
+
export const CONFIG_ENV_BY_PROVIDER: Record<string, string> = {
|
|
111
|
+
claude: "CLAUDE_CONFIG_DIR",
|
|
112
|
+
codex: "CODEX_HOME",
|
|
113
|
+
grok: "GROK_HOME", // unverified: set configEnv on the account before routing real work
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const PANE_RETRIES = 5
|
|
117
|
+
export const PANE_DELAY_MS = 1000
|
|
118
|
+
|
|
119
|
+
// The pane appears a moment after the tab, and its id is never cached: herdr
|
|
120
|
+
// ids are not stable, and the same logical worker has been seen at four
|
|
121
|
+
// different pane ids across four rounds.
|
|
122
|
+
export async function paneAt(ctx: Ctx, cwd: string): Promise<string> {
|
|
123
|
+
for (let attempt = 1; attempt <= PANE_RETRIES; attempt++) {
|
|
124
|
+
const panes = await ctx.herdr.panes()
|
|
125
|
+
const pane = panes.find((x) => matchesCwd(x.cwd, cwd))
|
|
126
|
+
if (pane) return pane.paneId
|
|
127
|
+
if (attempt < PANE_RETRIES) await ctx.sleep(PANE_DELAY_MS)
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`no pane appeared at ${cwd} after ${PANE_RETRIES} looks`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function startTab(
|
|
133
|
+
ctx: Ctx,
|
|
134
|
+
p: Job,
|
|
135
|
+
item: WorkItem,
|
|
136
|
+
key: string,
|
|
137
|
+
account: AccountConfig,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const label = ctx.workspace.herdrWorkspace
|
|
140
|
+
// By label, every spawn. A cached id survives exactly until the herdr
|
|
141
|
+
// server restarts, and then points at somebody else's workspace.
|
|
142
|
+
const workspaces = await ctx.herdr.workspaces()
|
|
143
|
+
const ws = workspaces.find((w) => w.label === label)
|
|
144
|
+
if (!ws) throw new Error(`no herdr workspace labelled "${label}"`)
|
|
145
|
+
|
|
146
|
+
const cwd = worktreePath(ctx.workspace.worktreeBase, p.name, key)
|
|
147
|
+
const name = `${p.name}-${key}`
|
|
148
|
+
const envVar = account.configEnv ?? CONFIG_ENV_BY_PROVIDER[account.provider] ?? "AGENT_CONFIG_DIR"
|
|
149
|
+
|
|
150
|
+
await ctx.herdr.tabCreate({
|
|
151
|
+
workspaceId: ws.id,
|
|
152
|
+
cwd,
|
|
153
|
+
label: name,
|
|
154
|
+
env: { [envVar]: expandHome(account.configDir) },
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
await startWorker(ctx, {
|
|
158
|
+
pane: await paneAt(ctx, cwd),
|
|
159
|
+
kind: account.agentKind ?? account.provider,
|
|
160
|
+
name,
|
|
161
|
+
// The account's args say how a worker starts on this account; the job's
|
|
162
|
+
// model says what it should be thinking with. Appended last so a job that
|
|
163
|
+
// names one wins over an account that already passed --model.
|
|
164
|
+
args: [...(account.startArgs ?? []), ...(p.model ? ["--model", p.model] : [])],
|
|
165
|
+
brief: await p.brief(ctx, item),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// One bad minute is infrastructure, not a verdict on the work; two on the same
|
|
170
|
+
// key is a pattern worth a human's attention.
|
|
171
|
+
export const STRIKE_MARK = "spawn-fail"
|
|
172
|
+
|
|
173
|
+
export async function rollback(ctx: Ctx, p: Job, item: WorkItem, key: string): Promise<void> {
|
|
174
|
+
// Order matters: unclaim first, so a rollback that itself fails half way
|
|
175
|
+
// still leaves the item free for the next tick rather than claimed forever.
|
|
176
|
+
await unclaim(ctx, item)
|
|
177
|
+
await preClean(ctx, p, key)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function applySpawn(
|
|
181
|
+
ctx: Ctx,
|
|
182
|
+
p: Job,
|
|
183
|
+
item: WorkItem,
|
|
184
|
+
key: string,
|
|
185
|
+
account: AccountConfig,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
// The reservation was taken in the router, before applySpawn ever runs, so
|
|
188
|
+
// any throw from here on out - including a pre-clean that fails or a claim
|
|
189
|
+
// that never sticks - must release it. Otherwise the row stays pending
|
|
190
|
+
// forever and keeps counting against the daily cap with nothing to show
|
|
191
|
+
// for it. A failed claim still means nothing was created, so it stays a
|
|
192
|
+
// skip, not a strike: only the try block below marks strikes.
|
|
193
|
+
try {
|
|
194
|
+
await preClean(ctx, p, key)
|
|
195
|
+
|
|
196
|
+
// A failed claim means nothing was created, so this is a skip, not a strike.
|
|
197
|
+
if (!(await claim(ctx, p, item))) {
|
|
198
|
+
throw new Error(`claim did not stick for ${p.name} ${key}, skipping this tick`)
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
ctx.global.release(p.name, key, ctx.now)
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const path = await createWorktree(ctx, p, item, key)
|
|
207
|
+
await prepareWorktree(ctx, p, path)
|
|
208
|
+
await startTab(ctx, p, item, key, account)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
await rollback(ctx, p, item, key).catch(() => {
|
|
211
|
+
// A rollback that fails must not replace the original error, which is
|
|
212
|
+
// the one that explains what actually went wrong.
|
|
213
|
+
})
|
|
214
|
+
try {
|
|
215
|
+
ctx.global.release(p.name, key, ctx.now)
|
|
216
|
+
} catch {
|
|
217
|
+
// A release that fails must not replace the original error either, or
|
|
218
|
+
// skip the strike bookkeeping below.
|
|
219
|
+
}
|
|
220
|
+
if (ctx.marks.has(p.name, key, STRIKE_MARK)) {
|
|
221
|
+
const labels = ctx.workspace.naming.labels
|
|
222
|
+
// A routine has no url and so no label to move: its second strike is the
|
|
223
|
+
// mark being cleared and the error being reported, nothing more. And
|
|
224
|
+
// like the rollback and the release above, a label that fails must not
|
|
225
|
+
// replace the original error, which is the one explaining what broke.
|
|
226
|
+
if (!trackerless(item)) {
|
|
227
|
+
await ctx.gh
|
|
228
|
+
.label(repoOf(item), itemKind(item), item.number, {
|
|
229
|
+
add: [labels.failed],
|
|
230
|
+
remove: [labels.claim],
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {})
|
|
233
|
+
}
|
|
234
|
+
ctx.marks.clear(p.name, key, STRIKE_MARK)
|
|
235
|
+
} else {
|
|
236
|
+
ctx.marks.set(p.name, key, STRIKE_MARK)
|
|
237
|
+
}
|
|
238
|
+
throw err
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Last, and only now: confirming any earlier would attribute quota to a
|
|
242
|
+
// worker that never started and skew the in-flight count for the day.
|
|
243
|
+
ctx.global.confirm(p.name, key, ctx.now)
|
|
244
|
+
ctx.marks.set(p.name, key, "spawned")
|
|
245
|
+
ctx.marks.clear(p.name, key, STRIKE_MARK)
|
|
246
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Ctx, Job } from "../types"
|
|
2
|
+
import type { Worktree } from "../adapters/git"
|
|
3
|
+
import { matchesCwd } from "../engine/naming"
|
|
4
|
+
import { withRepoLock } from "../lock"
|
|
5
|
+
|
|
6
|
+
export async function applySweep(ctx: Ctx, p: Job, wt: Worktree): Promise<void> {
|
|
7
|
+
const repo = ctx.workspace.repos[p.repo ?? ""]
|
|
8
|
+
if (!repo) return
|
|
9
|
+
|
|
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)
|
|
16
|
+
|
|
17
|
+
const git = ctx.git(repo)
|
|
18
|
+
await withRepoLock(repo, ctx.lock, async () => {
|
|
19
|
+
await git.worktreeRemove(wt.path)
|
|
20
|
+
if (wt.branch) {
|
|
21
|
+
await git.branchDelete(wt.branch)
|
|
22
|
+
// Review branches are never pushed, so only a job that pushes asks
|
|
23
|
+
// for the remote side to be deleted.
|
|
24
|
+
if (p.deleteRemote) await git.remoteDelete(wt.branch)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { WorkItem } from "../types"
|
|
2
|
+
|
|
3
|
+
// The gh adapter stamps every item's id as "<kind>:<number>".
|
|
4
|
+
export function itemKind(item: WorkItem): "issue" | "pr" {
|
|
5
|
+
return item.id.startsWith("pr:") ? "pr" : "issue"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// gh takes an "owner/name" slug while a job's repo names a local path, so
|
|
9
|
+
// the slug is derived from the item's own url. Anchored at the end so the
|
|
10
|
+
// path is unambiguous, and host agnostic so a self-hosted forge resolves the
|
|
11
|
+
// same way. A missing or unusable url yields null rather than a guess.
|
|
12
|
+
const REPO_FROM_URL = /\/([^/]+\/[^/]+)\/(?:pull|issues)\/\d+\/?$/
|
|
13
|
+
|
|
14
|
+
export function ghRepo(item: WorkItem): string | null {
|
|
15
|
+
return item.url?.match(REPO_FROM_URL)?.[1] ?? null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A caller that needs a repo to act (label, comment) has no fallback: an item
|
|
19
|
+
// with no usable url is an error to report, not a silent no-op. Shared here
|
|
20
|
+
// because spawn's executor needs the identical wrapper.
|
|
21
|
+
export function repoOf(item: WorkItem): string {
|
|
22
|
+
const repo = ghRepo(item)
|
|
23
|
+
if (!repo) throw new Error(`item ${item.id} has no url to derive a repo from`)
|
|
24
|
+
return repo
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A routine occurrence has no issue and no pull request behind it: the loop's
|
|
28
|
+
// own mark and the worktree are its whole record. The writes that label and
|
|
29
|
+
// unlabel an item have nothing to address, and must not throw on the way past.
|
|
30
|
+
export function trackerless(item: WorkItem): boolean {
|
|
31
|
+
return ghRepo(item) === null
|
|
32
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Ctx, Job, Decision, MonitorAction } from "../types"
|
|
2
|
+
import { worktreePath, matchesCwd } from "./naming"
|
|
3
|
+
import { applyDone, applyNudge, applyEscalate, applyRestart, applyFail, applyNotifyBlocked } from "../effects/monitor"
|
|
4
|
+
|
|
5
|
+
export async function monitorJob(ctx: Ctx, p: Job): Promise<Decision[]> {
|
|
6
|
+
const claimed = await ctx.cache(`engine:claimed:${p.name}`, () => p.discoverClaimed(ctx))
|
|
7
|
+
const agents = await ctx.cache("engine:agents", () => ctx.herdr.agents())
|
|
8
|
+
const panes = await ctx.cache("engine:panes", () => ctx.herdr.panes())
|
|
9
|
+
const timeout = ctx.config.blockedTimeoutMin ?? 180
|
|
10
|
+
const out: Decision[] = []
|
|
11
|
+
|
|
12
|
+
for (const item of claimed) {
|
|
13
|
+
const key = await p.key(ctx, item)
|
|
14
|
+
const wt = worktreePath(ctx.workspace.worktreeBase, p.name, key)
|
|
15
|
+
const mk = (action: MonitorAction, reason: string): Decision => ({
|
|
16
|
+
pass: "monitor", job: p.name, key, action, reason,
|
|
17
|
+
})
|
|
18
|
+
const act = async (fn: () => Promise<void>) => {
|
|
19
|
+
if (!ctx.live) return
|
|
20
|
+
try {
|
|
21
|
+
await fn()
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// One item's action failing must not stop the pass: the remaining
|
|
24
|
+
// claimed items are unrelated to this failure.
|
|
25
|
+
out.push({ pass: "error", job: p.name, where: "monitor", reason: String(err) })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (item.state !== "OPEN") {
|
|
30
|
+
out.push(mk("done", `state ${item.state}`))
|
|
31
|
+
await act(() => applyDone(ctx, item))
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (await p.done(ctx, item)) {
|
|
35
|
+
if (ctx.marks.has(p.name, key, "spawned")) {
|
|
36
|
+
out.push(mk("done", "done() true"))
|
|
37
|
+
} else {
|
|
38
|
+
ctx.marks.set(p.name, key, "spawned")
|
|
39
|
+
out.push(mk("external", "done() true with no spawned mark"))
|
|
40
|
+
}
|
|
41
|
+
// Either way the work is over, so the claim comes off: an item that keeps
|
|
42
|
+
// it counts against the job's slots for good.
|
|
43
|
+
await act(() => applyDone(ctx, item))
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const agent = agents.find((a) => matchesCwd(a.cwd, wt))
|
|
48
|
+
|
|
49
|
+
if (agent?.status === "working") {
|
|
50
|
+
out.push(mk("busy", "agent working"))
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (agent?.status === "blocked") {
|
|
55
|
+
const age = ctx.marks.age(p.name, key, "blocked")
|
|
56
|
+
if (age === null) {
|
|
57
|
+
ctx.marks.set(p.name, key, "blocked")
|
|
58
|
+
out.push(mk("blocked", "first sighting, notify once"))
|
|
59
|
+
await act(() => applyNotifyBlocked(ctx, p, item, key))
|
|
60
|
+
} else if (age >= timeout) {
|
|
61
|
+
ctx.marks.clear(p.name, key, "blocked")
|
|
62
|
+
out.push(mk("escalate", `blocked ${age}m >= ${timeout}m`))
|
|
63
|
+
await act(() => applyEscalate(ctx, p, item, key))
|
|
64
|
+
} else {
|
|
65
|
+
out.push(mk("hold", `blocked ${age}m < ${timeout}m`))
|
|
66
|
+
}
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (agent?.status === "missing") {
|
|
71
|
+
// An unrecognized herdr agent_status maps to "missing" (see adapters/herdr.ts).
|
|
72
|
+
// Hold, not nudge or fail: hold cannot kill a live agent and cannot
|
|
73
|
+
// tombstone an item, so it is fail-safe if herdr renames or adds a status.
|
|
74
|
+
out.push(mk("hold", "agent status missing, holding"))
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!agent) {
|
|
79
|
+
const paneAlive = panes.some((pane) => matchesCwd(pane.cwd, wt))
|
|
80
|
+
// Once, per spec 11. A restart takes no spawns row, so an agent that
|
|
81
|
+
// keeps dying would otherwise be restarted every tick forever, burning
|
|
82
|
+
// real quota outside maxSpawnsPerDay.
|
|
83
|
+
if (paneAlive && !ctx.marks.has(p.name, key, "restarted")) {
|
|
84
|
+
ctx.marks.set(p.name, key, "restarted")
|
|
85
|
+
out.push(mk("restart", "pane alive, agent gone"))
|
|
86
|
+
await act(() => applyRestart(ctx, p, item, key))
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
out.push(mk("fail", paneAlive ? "agent gone again after a restart" : "no agent and no pane"))
|
|
90
|
+
await act(() => applyFail(ctx, p, item, key))
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!ctx.marks.has(p.name, key, "nudged")) {
|
|
95
|
+
ctx.marks.set(p.name, key, "nudged")
|
|
96
|
+
out.push(mk("nudge", agent ? `agent ${agent.status}` : "no agent"))
|
|
97
|
+
await act(() => applyNudge(ctx, p, item, key))
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
out.push(mk("fail", "still not done after a nudge"))
|
|
102
|
+
await act(() => applyFail(ctx, p, item, key))
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function monitorAll(ctx: Ctx, jobs: Job[]): Promise<Decision[]> {
|
|
108
|
+
const out: Decision[] = []
|
|
109
|
+
for (const p of jobs) {
|
|
110
|
+
try {
|
|
111
|
+
out.push(...(await monitorJob(ctx, p)))
|
|
112
|
+
} catch (err) {
|
|
113
|
+
out.push({ pass: "error", job: p.name, where: "monitor", reason: String(err) })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Worktree } from "../adapters/git"
|
|
2
|
+
|
|
3
|
+
export function worktreePath(base: string, job: string, key: string): string {
|
|
4
|
+
return `${base}/wt-${job}-${key}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function branchName(job: string, key: string): string {
|
|
8
|
+
return `${job}/${key}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function keyOf(job: string, branch: string | null): string | null {
|
|
12
|
+
if (!branch) return null
|
|
13
|
+
const prefix = `${job}/`
|
|
14
|
+
if (!branch.startsWith(prefix)) return null
|
|
15
|
+
const key = branch.slice(prefix.length)
|
|
16
|
+
return key.length > 0 ? key : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function owns(job: string, base: string, wt: Worktree): boolean {
|
|
20
|
+
const pathPrefix = `${base}/wt-${job}-`
|
|
21
|
+
if (!wt.path.startsWith(pathPrefix)) return false
|
|
22
|
+
const key = keyOf(job, wt.branch)
|
|
23
|
+
if (key === null) return false
|
|
24
|
+
// The path and the branch must name the same key. A worktree whose checked-out
|
|
25
|
+
// branch has drifted to another key would otherwise be swept under the wrong
|
|
26
|
+
// identity. A trailing "-<slug>" is still the same key, because spawn pre-clean
|
|
27
|
+
// matches worktrees by the "<key>-*" glob and slugged directories are real.
|
|
28
|
+
const suffix = wt.path.slice(pathPrefix.length)
|
|
29
|
+
return suffix === key || suffix.startsWith(`${key}-`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function matchesCwd(cwd: string, worktree: string): boolean {
|
|
33
|
+
if (cwd === worktree) return true
|
|
34
|
+
if (!cwd.startsWith(worktree)) return false
|
|
35
|
+
const rest = cwd.slice(worktree.length)
|
|
36
|
+
if (rest.startsWith("/")) return true
|
|
37
|
+
// A trailing "-" belongs to a title slug. A digit right after it is treated
|
|
38
|
+
// as a longer key instead (so key "b4" cannot match worktree "b4-8", which
|
|
39
|
+
// belongs to key "b48" or similar). The cost: a worktree whose title slug
|
|
40
|
+
// happens to start with a digit, e.g. "wt-review-r80-2fa-login", fails this
|
|
41
|
+
// match and becomes invisible to monitor and sweep even while a healthy
|
|
42
|
+
// agent is working in it. No slug-vs-key disambiguation exists to fix this
|
|
43
|
+
// without also changing how keys and slugs are told apart elsewhere.
|
|
44
|
+
return rest.startsWith("-") && !/^-\d/.test(rest)
|
|
45
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Ctx, Job, Decision } from "../types"
|
|
2
|
+
import { chooseAccount } from "../router/route"
|
|
3
|
+
import { applySpawn } from "../effects/spawn"
|
|
4
|
+
|
|
5
|
+
// Shared with render.ts, which uses it to tell an idle job (no candidates)
|
|
6
|
+
// apart from a job skipped for any other reason.
|
|
7
|
+
export const IDLE_REASON = "idle"
|
|
8
|
+
|
|
9
|
+
export async function spawnOne(
|
|
10
|
+
ctx: Ctx,
|
|
11
|
+
jobs: Job[],
|
|
12
|
+
paused: string[] = [],
|
|
13
|
+
): Promise<Decision[]> {
|
|
14
|
+
const out: Decision[] = []
|
|
15
|
+
const skip = (job: string, reason: string): Decision => ({
|
|
16
|
+
pass: "spawn", job, key: "", action: "skip", reason,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
for (const p of jobs) {
|
|
20
|
+
try {
|
|
21
|
+
if (paused.includes(p.name)) {
|
|
22
|
+
out.push(skip(p.name, "paused"))
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (p.admit) {
|
|
27
|
+
const reason = await p.admit(ctx)
|
|
28
|
+
if (reason) {
|
|
29
|
+
out.push(skip(p.name, reason))
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const slots = p.slots ?? 1
|
|
35
|
+
const inFlight = await ctx.cache(`engine:claimed:${p.name}`, () => p.discoverClaimed(ctx))
|
|
36
|
+
if (inFlight.length >= slots) {
|
|
37
|
+
out.push(skip(p.name, `slots ${inFlight.length}/${slots} in flight`))
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cached under the same key the filing budget reads, so a reviewer that
|
|
42
|
+
// asks how deep the builder's queue is and the builder's own spawn pass
|
|
43
|
+
// pay for one remote read between them.
|
|
44
|
+
const candidates = await ctx.cache(`engine:discover:${p.name}`, () => p.discover(ctx))
|
|
45
|
+
if (candidates.length === 0) {
|
|
46
|
+
out.push(skip(p.name, IDLE_REASON))
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let picked = null
|
|
51
|
+
for (const c of candidates) {
|
|
52
|
+
if (p.guard && !(await p.guard(ctx, c))) continue
|
|
53
|
+
picked = c
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
if (!picked) {
|
|
57
|
+
out.push(skip(p.name, `all ${candidates.length} candidates guarded out`))
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const key = await p.key(ctx, picked)
|
|
62
|
+
const route = await chooseAccount(ctx, p, picked)
|
|
63
|
+
if (!route.ok) {
|
|
64
|
+
out.push({ pass: "spawn", job: p.name, key, action: "skip", reason: route.reason })
|
|
65
|
+
// CAP and LOWMEM describe the day and the box, so no later job can
|
|
66
|
+
// do better. STARVED is this job's own requires/prefer, so the walk
|
|
67
|
+
// continues; stopping there would let one constrained job mute the
|
|
68
|
+
// whole workspace.
|
|
69
|
+
if (route.global) return out
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
out.push({
|
|
74
|
+
pass: "spawn",
|
|
75
|
+
job: p.name,
|
|
76
|
+
key,
|
|
77
|
+
action: "spawn",
|
|
78
|
+
account: route.account,
|
|
79
|
+
reason: route.reason,
|
|
80
|
+
})
|
|
81
|
+
if (ctx.live) {
|
|
82
|
+
const account = ctx.config.accounts.find((a) => a.id === route.account)!
|
|
83
|
+
try {
|
|
84
|
+
await applySpawn(ctx, p, picked, key, account)
|
|
85
|
+
} catch (err) {
|
|
86
|
+
out.push({ pass: "error", job: p.name, where: "spawn", reason: String(err) })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// A live tick spawns one worker and stops, because the next tick re-reads
|
|
90
|
+
// the world with that worker in it. A dry tick spawns nothing and its
|
|
91
|
+
// marks are not persisted, so stopping here would hide every job after
|
|
92
|
+
// this one behind the first due item and the mask would never lift: a
|
|
93
|
+
// shadow week reports one job for seven days. Nothing downstream of the
|
|
94
|
+
// decision is reserved without ctx.live, so the walk is free to continue.
|
|
95
|
+
if (ctx.live) return out
|
|
96
|
+
} catch (err) {
|
|
97
|
+
out.push({ pass: "error", job: p.name, where: "spawn", reason: String(err) })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Ctx, Job, Decision, WorkItem } from "../types"
|
|
2
|
+
import { owns, keyOf, matchesCwd } from "./naming"
|
|
3
|
+
import { applySweep } from "../effects/sweep"
|
|
4
|
+
import { auditFiling } from "../filing"
|
|
5
|
+
import { appendJournal } from "../journal"
|
|
6
|
+
import { renderDecision } from "../render"
|
|
7
|
+
|
|
8
|
+
async function isFinished(ctx: Ctx, p: Job, rawKey: string): Promise<boolean> {
|
|
9
|
+
if (p.sweepOk) return p.sweepOk(ctx, rawKey)
|
|
10
|
+
const digits = rawKey.match(/\d+/g) ?? []
|
|
11
|
+
const synthetic: WorkItem = {
|
|
12
|
+
id: `key:${rawKey}`,
|
|
13
|
+
// A synthetic item has no real number. Use one only when the key holds a
|
|
14
|
+
// single digit run ("r80" -> 80). A multi-group key such as a date is not a
|
|
15
|
+
// number, and a fabricated one would mislead done(); those jobs define
|
|
16
|
+
// sweepOk instead.
|
|
17
|
+
number: digits.length === 1 ? Number.parseInt(digits[0]!, 10) : 0,
|
|
18
|
+
title: "",
|
|
19
|
+
state: "OPEN",
|
|
20
|
+
labels: [],
|
|
21
|
+
}
|
|
22
|
+
return p.done(ctx, synthetic)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function sweepJob(ctx: Ctx, p: Job): Promise<Decision[]> {
|
|
26
|
+
const repo = ctx.workspace.repos[p.repo ?? ""]
|
|
27
|
+
if (!repo) return []
|
|
28
|
+
const base = ctx.workspace.worktreeBase
|
|
29
|
+
// "engine:" prefixes every cache key the engine owns (here and in monitor.ts
|
|
30
|
+
// and spawn.ts), reserving that namespace so a job's own ctx.cache key can
|
|
31
|
+
// never collide with, and poison, the engine's snapshot for this tick.
|
|
32
|
+
const worktrees = await ctx.cache(`engine:worktrees:${repo}`, () => ctx.git(repo).worktrees())
|
|
33
|
+
const agents = await ctx.cache("engine:agents", () => ctx.herdr.agents())
|
|
34
|
+
const out: Decision[] = []
|
|
35
|
+
|
|
36
|
+
for (const wt of worktrees) {
|
|
37
|
+
if (!owns(p.name, base, wt)) continue
|
|
38
|
+
const rawKey = keyOf(p.name, wt.branch)!
|
|
39
|
+
const mk = (action: "clean" | "hold", reason: string): Decision => ({
|
|
40
|
+
pass: "sweep", job: p.name, worktree: wt.path, branch: wt.branch!, action, reason,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const live = agents.some((a) => a.status === "working" && matchesCwd(a.cwd, wt.path))
|
|
44
|
+
if (live && !p.sweepIgnoresWorking) {
|
|
45
|
+
out.push(mk("hold", "agent working"))
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const predicate = p.sweepOk ? "sweepOk" : "done"
|
|
49
|
+
if (!(await isFinished(ctx, p, rawKey))) {
|
|
50
|
+
out.push(mk("hold", `${predicate}(${rawKey}) false`))
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
out.push(mk("clean", `${predicate}(${rawKey})`))
|
|
54
|
+
// The run is over, so this is the moment its output can be counted. Failure
|
|
55
|
+
// here is reported and never blocks the cleanup: an audit is bookkeeping.
|
|
56
|
+
if (p.filing) {
|
|
57
|
+
try {
|
|
58
|
+
const audit = await auditFiling(ctx, p, rawKey)
|
|
59
|
+
if (audit && audit.filed > audit.budget) {
|
|
60
|
+
const d: Decision = { pass: "audit", job: p.name, key: rawKey, filed: audit.filed, budget: audit.budget }
|
|
61
|
+
out.push(d)
|
|
62
|
+
appendJournal(ctx, renderDecision(d, ctx.live))
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
out.push({ pass: "error", job: p.name, where: "sweep", reason: String(err) })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (ctx.live) {
|
|
69
|
+
try {
|
|
70
|
+
await applySweep(ctx, p, wt)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// One worktree that will not clean up must not strand the others: the
|
|
73
|
+
// next worktree in this job's list is unrelated to this failure.
|
|
74
|
+
out.push({ pass: "error", job: p.name, where: "sweep", reason: String(err) })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function sweepAll(ctx: Ctx, jobs: Job[]): Promise<Decision[]> {
|
|
82
|
+
const out: Decision[] = []
|
|
83
|
+
for (const p of jobs) {
|
|
84
|
+
try {
|
|
85
|
+
out.push(...(await sweepJob(ctx, p)))
|
|
86
|
+
} catch (err) {
|
|
87
|
+
out.push({ pass: "error", job: p.name, where: "sweep", reason: String(err) })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|