@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/briefs/default/build.md +35 -0
  4. package/briefs/default/core.md +52 -0
  5. package/briefs/default/journal.optional.md +7 -0
  6. package/briefs/default/review.md +65 -0
  7. package/briefs/default/routine.md +18 -0
  8. package/briefs/default/screenshots.optional.md +10 -0
  9. package/briefs/default/subagents.optional.md +7 -0
  10. package/docs/cutover.md +121 -0
  11. package/package.json +46 -0
  12. package/src/adapters/gh.ts +77 -0
  13. package/src/adapters/git.ts +82 -0
  14. package/src/adapters/herdr.ts +121 -0
  15. package/src/adapters/run.ts +64 -0
  16. package/src/adopt.ts +47 -0
  17. package/src/brief.ts +124 -0
  18. package/src/check.ts +126 -0
  19. package/src/cli-pause.ts +16 -0
  20. package/src/cli.ts +290 -0
  21. package/src/config.ts +211 -0
  22. package/src/ctx.ts +64 -0
  23. package/src/discover.ts +344 -0
  24. package/src/effects/monitor.ts +148 -0
  25. package/src/effects/spawn.ts +246 -0
  26. package/src/effects/sweep.ts +27 -0
  27. package/src/engine/item.ts +32 -0
  28. package/src/engine/monitor.ts +117 -0
  29. package/src/engine/naming.ts +45 -0
  30. package/src/engine/spawn.ts +101 -0
  31. package/src/engine/sweep.ts +91 -0
  32. package/src/engine/tick.ts +25 -0
  33. package/src/filing.ts +56 -0
  34. package/src/globalstate.ts +228 -0
  35. package/src/journal.ts +13 -0
  36. package/src/kinds/builder.ts +128 -0
  37. package/src/kinds/index.ts +13 -0
  38. package/src/kinds/reviewer.ts +220 -0
  39. package/src/kinds/routine.ts +174 -0
  40. package/src/kinds/shared.ts +49 -0
  41. package/src/kinds/validate.ts +187 -0
  42. package/src/lock.ts +63 -0
  43. package/src/paths.ts +21 -0
  44. package/src/render.ts +42 -0
  45. package/src/router/budget.ts +81 -0
  46. package/src/router/providers/claude.ts +177 -0
  47. package/src/router/providers/codex.ts +120 -0
  48. package/src/router/providers/grok.ts +10 -0
  49. package/src/router/rate.ts +62 -0
  50. package/src/router/route.ts +179 -0
  51. package/src/router/window.ts +39 -0
  52. package/src/runtime/worker.ts +75 -0
  53. package/src/state.ts +128 -0
  54. package/src/status.ts +41 -0
  55. 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
+ }