Public: anyone can read this; the crew can change it.
Archive, part 2c: a run queued before Archive is cancelled with the archived line instead of starting #2488
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2478-archive-part-2c-a-run-queued-before-arch → main 1 commit fork 2 threads · 2 unresolved
2 files
changed +48
−2
src/crew/dispatch.ts +14 −2
| @@ -33,7 +33,7 @@ import { accountActive } from "../core/accounts.ts"; | ||
| 33 | 33 | import { runningLimitSentence, runningRuns } from "../settings/quotas.ts"; |
| 34 | 34 | import { appendRunLog } from "../runs/log.ts"; |
| 35 | 35 | import { decideAndRecord } from "./ledger.ts"; |
| 36 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 36 | import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts"; | |
| 37 | 37 | import { listReviews } from "../core/reviews.ts"; |
| 38 | 38 | import { |
| 39 | 39 | greenHead, |
| @@ -1139,10 +1139,22 @@ function pump( | ||
| 1139 | 1139 | const noted = notedBy.get(app) ?? new Set<string>(); |
| 1140 | 1140 | notedBy.set(app, noted); |
| 1141 | 1141 | for (const queued of listQueued(app.db, "agent", 50)) { |
| 1142 | if (slots <= 0) break; | |
| 1143 | 1142 | if (queued.issueId !== null && runningIssues.has(queued.issueId)) continue; |
| 1144 | 1143 | const fresh = getRun(app.db, queued.id); |
| 1145 | 1144 | if (!fresh || fresh.state !== "queued") continue; |
| 1145 | // A fresh run queued before Archive is cancelled, not started, and | |
| 1146 | // stays cancelled through Wake. One carrying on after a pause or a | |
| 1147 | // restart (attempt > 0) is already going, and finishes. | |
| 1148 | const home = getRepoById(app.db, fresh.repoId); | |
| 1149 | if (fresh.attempt === 0 && home !== undefined && home.archivedAt !== null) { | |
| 1150 | const cancelled = cancelRun(app.db, fresh.id, ARCHIVED_WHY); | |
| 1151 | if (cancelled) { | |
| 1152 | appendRunLog(app.config.dataDir, fresh.id, ARCHIVED_WHY); | |
| 1153 | announceRun(app, cancelled); | |
| 1154 | } | |
| 1155 | continue; | |
| 1156 | } | |
| 1157 | if (slots <= 0) break; | |
| 1146 | 1158 | // An account at its concurrent-runs limit keeps this run queued, with |
| 1147 | 1159 | // the count in its log once, until one of its runs ends. |
| 1148 | 1160 | const owner = getRepoById(app.db, fresh.repoId)?.ownerId; |
test/crew.test.ts +34
| @@ -242,4 +242,38 @@ describe("the crew, with a stand-in Claude Code", () => { | ||
| 242 | 242 | expect(run).toBeDefined(); |
| 243 | 243 | cancelRepoRun(app, alice, woken, run!.id); |
| 244 | 244 | }); |
| 245 | ||
| 246 | test("a run queued before Archive is cancelled with the archived line, never started; Wake does not revive it", async () => { | |
| 247 | const issue = openIssue(app, alice, repo, { title: "Rake", body: "The leaves." }); | |
| 248 | expect(issue.ok).toBe(true); | |
| 249 | if (!issue.ok) return; | |
| 250 | const agent = getAgentByName(app.db, "bot")!; | |
| 251 | const queued = queueAgentRun(app, repo, issue.value, agent, "assigned"); | |
| 252 | expect(queued).toBeDefined(); | |
| 253 | // A run carrying on after a restart, queued in the same breath on another issue. | |
| 254 | const other = openIssue(app, alice, repo, { title: "Weed", body: "The beds." }); | |
| 255 | if (!other.ok) throw new Error("issue"); | |
| 256 | const again = { notBefore: new Date().toISOString(), attempt: 1, summary: "carries on" }; | |
| 257 | const carrying = queueAgentRun(app, repo, other.value, agent, "assigned", undefined, again); | |
| 258 | expect(carrying).toBeDefined(); | |
| 259 | // Archived at once, before the crew's next pick. | |
| 260 | const archived = updateRepoSettings(app.db, repo, { archived: true }); | |
| 261 | const done = await settled((r) => r.id === queued!.id, 60_000); | |
| 262 | expect(done.state).toBe("cancelled"); | |
| 263 | expect(done.summary).toBe(ARCHIVED_WHY); | |
| 264 | expect(readRunLog(dataDir, done.id).lines.join("\n")).toContain(ARCHIVED_WHY); | |
| 265 | // The carry-on run is not cancelled for the archive. | |
| 266 | const kept = await settled((r) => r.id === carrying!.id, 60_000); | |
| 267 | expect(kept.summary).not.toBe(ARCHIVED_WHY); | |
| 268 | expect(kept.state).not.toBe("cancelled"); | |
| 269 | // The fresh run spawned nothing: its whole log is the one archived line. | |
| 270 | expect(readRunLog(dataDir, done.id).lines.filter((l) => l.trim() !== "").length).toBe(1); | |
| 271 | // Wake leaves it cancelled; a new assignment starts normally. | |
| 272 | const woken = updateRepoSettings(app.db, archived, { archived: false }); | |
| 273 | await Bun.sleep(6_000); | |
| 274 | expect(listRuns(app.db, repo.id, { issueId: issue.value.id }).find((r) => r.id === queued!.id)!.state).toBe("cancelled"); | |
Fixed 6s sleep makes the suite slower and timing-dependent; wait on a pump tick instead. |
||
| 275 | const next = queueAgentRun(app, woken, issue.value, agent, "assigned"); | |
| 276 | expect(next).toBeDefined(); | |
| 277 | await settled((r) => r.id === next!.id, 60_000); | |
| 278 | }, 120_000); | |
| 245 | 279 | }); |
new above
offline
Move the archived check above this continue so a queued fresh run on an issue that is still running is cancelled on this tick, not later.