Public: anyone can read this; the crew can change it.
test/crew-interrupted.test.ts runs in under 10 s #11062
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11053-test-crew-interrupted-test-ts-runs-in-un → main 2 commits fork 2 threads · 2 unresolved
1 file
changed +86
−44
test/crew-interrupted.test.ts +86 −44
| @@ -7,7 +7,6 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 7 | 7 | import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 8 | 8 | import { listComments } from "../src/core/issues.ts"; |
| 9 | 9 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 10 | import { getRepo } from "../src/core/repos.ts"; | |
| 11 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 11 | import { |
| 13 | 12 | cancelRun, |
| @@ -26,6 +25,11 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 26 | 25 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 27 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | 27 | |
| 28 | /** The crew's tick, injected short: every wait below is for the next tick (#11053). */ | |
| 29 | const TICK_MS = 50; | |
| 30 | /** How often a test looks again at a run it waits on. */ | |
| 31 | const POLL_MS = 20; | |
| 32 | ||
| 29 | 33 | describe("a run the last forge left running", () => { |
| 30 | 34 | let dataDir: string; |
| 31 | 35 | let app: App; |
| @@ -60,14 +64,61 @@ describe("a run the last forge left running", () => { | ||
| 60 | 64 | await rm(dataDir, { recursive: true, force: true }); |
| 61 | 65 | }); |
| 62 | 66 | |
| 63 | test("is cancelled at boot and queued again under the same reason", async () => { | |
| 67 | /** | |
| 68 | * No crew left up and no run left queued or running by the test before: | |
| 69 | * with no crew up, a running run is a leftover, and the next crew's tick | |
| 70 | * would requeue it once its lease lapsed or count it against a slot. Each | |
| 71 | * test starts from rest, so they pass in any order (`--randomize`). | |
| 72 | */ | |
| 73 | const atRest = async () => { | |
| 74 | await crew?.stop(); | |
| 75 | crew = undefined; | |
| 76 | const left = app.db | |
| 77 | .query<{ id: string }, []>( | |
| 78 | "SELECT id FROM run WHERE state IN ('queued', 'running')", | |
| 79 | ) | |
| 80 | .all(); | |
| 81 | for (const { id } of left) cancelRun(app.db, id, "cleared for the next test"); | |
| 82 | }; | |
| 83 | ||
| 84 | /** A repository of the test's own, with the bot on its crew: no case shares one. */ | |
| 85 | const crewedRepo = async (name: string) => { | |
| 64 | 86 | const created = await createRepo(app, alice, { |
| 65 | name: "garden", | |
| 87 | name, | |
| 66 | 88 | visibility: "private", |
| 67 | 89 | }); |
| 68 | 90 | if (!created.ok) throw new Error(String(created.error)); |
| 69 | const repo = created.value; | |
| 70 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 91 | expect(addToCrew(app, alice, created.value, "bot").ok).toBe(true); | |
| 92 | return created.value; | |
| 93 | }; | |
| 94 | ||
| 95 | /** Poll a run until it ends (ok, failed or cancelled), or the deadline passes. */ | |
| 96 | const settled = async (id: string, withinMs: number) => { | |
| 97 | const deadline = Date.now() + withinMs; | |
| 98 | while ( | |
| 99 | !["ok", "failed", "cancelled"].includes(getRun(app.db, id)!.state) && | |
| 100 | Date.now() < deadline | |
| 101 | ) | |
| 102 | await Bun.sleep(POLL_MS); | |
| 103 | return getRun(app.db, id)!; | |
| 104 | }; | |
| 105 | ||
| 106 | /** Concurrency 0: the crew boots, tidies, and starts nothing. */ | |
| 107 | const idleCrew = () => | |
| 108 | startCrew(app, { | |
| 109 | forgeUrl: servers.url, | |
| 110 | localUrl: servers.url, | |
| 111 | binary: join(dataDir, "no-such-claude"), | |
| 112 | concurrency: 0, | |
| 113 | budgetUsd: 1, | |
| 114 | runTimeoutMs: 60_000, | |
| 115 | tickMs: TICK_MS, | |
| 116 | networkRetryScale: 0, | |
| 117 | }); | |
| 118 | ||
| 119 | test("is cancelled at boot and queued again under the same reason", async () => { | |
| 120 | await atRest(); | |
| 121 | const repo = await crewedRepo("garden"); | |
| 71 | 122 | const issued = openIssue(app, alice, repo, { |
| 72 | 123 | title: "Plant something", |
| 73 | 124 | body: "", |
| @@ -103,15 +154,7 @@ describe("a run the last forge left running", () => { | ||
| 103 | 154 | }); |
| 104 | 155 | claimRun(app.db, alive.id); |
| 105 | 156 | |
| 106 | // Concurrency 0: the crew boots, tidies, and starts nothing. | |
| 107 | crew = await startCrew(app, { | |
| 108 | forgeUrl: servers.url, | |
| 109 | localUrl: servers.url, | |
| 110 | binary: join(dataDir, "no-such-claude"), | |
| 111 | concurrency: 0, | |
| 112 | budgetUsd: 1, | |
| 113 | runTimeoutMs: 60_000, | |
| 114 | }); | |
| 157 | crew = await idleCrew(); | |
| 115 | 158 | |
| 116 | 159 | const after = getRun(app.db, orphan.id)!; |
| 117 | 160 | expect(after.state).toBe("cancelled"); |
| @@ -125,10 +168,13 @@ describe("a run the last forge left running", () => { | ||
| 125 | 168 | expect(getRun(app.db, alive.id)?.state).toBe("running"); |
| 126 | 169 | // The other forge finishes it; here, it is simply out of the way. |
| 127 | 170 | cancelRun(app.db, alive.id, "finished elsewhere"); |
| 128 | }); | |
| 171 | }, 30_000); | |
| 129 | 172 | |
| 130 | 173 | test("a run whose lease lapses while the crew is up is queued again on the next tick", async () => { |
| 131 | const repo = getRepo(app.db, "alice", "garden")!; | |
| 174 | await atRest(); | |
| 175 | const repo = await crewedRepo("allotment"); | |
| 176 | // The crew is up before the lease lapses: only a tick can see it. | |
| 177 | crew = await idleCrew(); | |
| 132 | 178 | const issued = openIssue(app, alice, repo, { |
| 133 | 179 | title: "Water it", |
| 134 | 180 | body: "", |
| @@ -156,7 +202,7 @@ describe("a run the last forge left running", () => { | ||
| 156 | 202 | getRun(app.db, orphan.id)?.state === "running" && |
| 157 | 203 | Date.now() < deadline |
| 158 | 204 | ) |
| 159 | await Bun.sleep(200); | |
| 205 | await Bun.sleep(POLL_MS); | |
| 160 | 206 | const after = getRun(app.db, orphan.id)!; |
| 161 | 207 | expect(after.state).toBe("cancelled"); |
| 162 | 208 | expect(after.summary).toContain("interrupted by a restart"); |
| @@ -168,8 +214,7 @@ describe("a run the last forge left running", () => { | ||
| 168 | 214 | }, 30_000); |
| 169 | 215 | |
| 170 | 216 | test("a run in flight when the crew stops goes back on the queue", async () => { |
| 171 | await crew?.stop(); | |
| 172 | crew = undefined; | |
| 217 | await atRest(); | |
| 173 | 218 | const created = await createRepo(app, alice, { |
| 174 | 219 | name: "orchard", |
| 175 | 220 | visibility: "private", |
| @@ -215,13 +260,15 @@ sleep 120 | ||
| 215 | 260 | concurrency: 1, |
| 216 | 261 | budgetUsd: 1, |
| 217 | 262 | runTimeoutMs: 60_000, |
| 263 | tickMs: TICK_MS, | |
| 264 | networkRetryScale: 0, | |
| 218 | 265 | }); |
| 219 | 266 | const deadline = Date.now() + 20_000; |
| 220 | 267 | while ( |
| 221 | 268 | getRun(app.db, queuedRun.id)?.state !== "running" && |
| 222 | 269 | Date.now() < deadline |
| 223 | 270 | ) |
| 224 | await Bun.sleep(100); | |
| 271 | await Bun.sleep(POLL_MS); | |
| 225 | 272 | expect(getRun(app.db, queuedRun.id)?.state).toBe("running"); |
| 226 | 273 | |
| 227 | 274 | await crew.stop(); |
| @@ -240,10 +287,7 @@ sleep 120 | ||
| 240 | 287 | }, 40_000); |
| 241 | 288 | |
| 242 | 289 | test("a draining crew hands a long run back queued within its budget (#7799)", async () => { |
| 243 | await crew?.stop(); | |
| 244 | crew = undefined; | |
| 245 | // The run the test before put back on the queue would take the one slot. | |
| 246 | for (const left of listQueued(app.db, "agent")) cancelRun(app.db, left.id, "cleared for the next test"); | |
| 290 | await atRest(); | |
| 247 | 291 | const created = await createRepo(app, alice, { name: "thicket", visibility: "private" }); |
| 248 | 292 | if (!created.ok) throw new Error(String(created.error)); |
| 249 | 293 | const repo = created.value; |
| @@ -281,13 +325,15 @@ sleep 120 | ||
| 281 | 325 | concurrency: 1, |
| 282 | 326 | budgetUsd: 1, |
| 283 | 327 | runTimeoutMs: 60_000, |
| 328 | tickMs: TICK_MS, | |
| 329 | networkRetryScale: 0, | |
| 284 | 330 | }); |
| 285 | 331 | const deadline = Date.now() + 20_000; |
| 286 | while (getRun(app.db, queuedRun.id)?.state !== "running" && Date.now() < deadline) await Bun.sleep(100); | |
| 332 | while (getRun(app.db, queuedRun.id)?.state !== "running" && Date.now() < deadline) await Bun.sleep(POLL_MS); | |
| 287 | 333 | expect(getRun(app.db, queuedRun.id)?.state).toBe("running"); |
| 288 | 334 | |
| 289 | 335 | const started = Date.now(); |
| 290 | await crew.stop({ drain: true, drainMs: 1_000 }); | |
| 336 | await crew.stop({ drain: true, drainMs: 250 }); | |
| 291 | 337 | crew = undefined; |
| 292 | 338 | expect(Date.now() - started).toBeLessThan(15_000); |
| 293 | 339 | |
| @@ -299,8 +345,7 @@ sleep 120 | ||
| 299 | 345 | }, 40_000); |
| 300 | 346 | |
| 301 | 347 | test("a draining crew lets a run in flight finish and land; nothing is queued again", async () => { |
| 302 | await crew?.stop(); | |
| 303 | crew = undefined; | |
| 348 | await atRest(); | |
| 304 | 349 | const created = await createRepo(app, alice, { |
| 305 | 350 | name: "grove", |
| 306 | 351 | visibility: "private", |
| @@ -324,7 +369,7 @@ sleep 120 | ||
| 324 | 369 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi |
| 325 | 370 | cat > /dev/null & |
| 326 | 371 | echo '{"type":"system","subtype":"init","session_id":"brief-1","model":"fake"}' |
| 327 | sleep 2 | |
| 372 | sleep 0.5 | |
| 328 | 373 | echo "drained work" >> CREW.md |
| 329 | 374 | git add -A && git commit -qm "drained" && git push -q -u origin HEAD |
| 330 | 375 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Done while draining.","total_cost_usd":0.01,"num_turns":2,"session_id":"brief-1","terminal_reason":"completed"}' |
| @@ -345,19 +390,22 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done while | ||
| 345 | 390 | concurrency: 1, |
| 346 | 391 | budgetUsd: 1, |
| 347 | 392 | runTimeoutMs: 60_000, |
| 393 | tickMs: TICK_MS, | |
| 394 | networkRetryScale: 0, | |
| 348 | 395 | }); |
| 349 | 396 | const deadline = Date.now() + 20_000; |
| 350 | 397 | while ( |
| 351 | 398 | getRun(app.db, queuedRun.id)?.state !== "running" && |
| 352 | 399 | Date.now() < deadline |
| 353 | 400 | ) |
| 354 | await Bun.sleep(100); | |
| 401 | await Bun.sleep(POLL_MS); | |
| 355 | 402 | expect(getRun(app.db, queuedRun.id)?.state).toBe("running"); |
| 356 | 403 | |
| 357 | 404 | await crew.stop({ drain: true, drainMs: 30_000 }); |
| 358 | 405 | crew = undefined; |
| 359 | 406 | |
| 360 | const ended = getRun(app.db, queuedRun.id)!; | |
| 407 | // The run's end, however long the machine took to reach it. | |
| 408 | const ended = await settled(queuedRun.id, 20_000); | |
| 361 | 409 | expect(ended.state).toBe("ok"); |
| 362 | 410 | expect(ended.summary).toMatch(/^opened pull #\d+/); |
| 363 | 411 | expect( |
| @@ -366,8 +414,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done while | ||
| 366 | 414 | }, 40_000); |
| 367 | 415 | |
| 368 | 416 | const keptClone = async (name: string) => { |
| 369 | await crew?.stop(); | |
| 370 | crew = undefined; | |
| 417 | await atRest(); | |
| 371 | 418 | const created = await createRepo(app, alice, { |
| 372 | 419 | name, |
| 373 | 420 | visibility: "private", |
| @@ -403,6 +450,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to | ||
| 403 | 450 | concurrency: 1, |
| 404 | 451 | budgetUsd: 1, |
| 405 | 452 | runTimeoutMs: 60_000, |
| 453 | tickMs: TICK_MS, | |
| 454 | networkRetryScale: 0, | |
| 406 | 455 | }; |
| 407 | 456 | const first = createRun(app.db, { |
| 408 | 457 | repoId: repo.id, |
| @@ -412,16 +461,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to | ||
| 412 | 461 | reason: "assigned", |
| 413 | 462 | }); |
| 414 | 463 | crew = await startCrew(app, options); |
| 415 | const wait = async (id: string) => { | |
| 416 | const deadline = Date.now() + 25_000; | |
| 417 | while ( | |
| 418 | !["ok", "failed", "cancelled"].includes(getRun(app.db, id)!.state) && | |
| 419 | Date.now() < deadline | |
| 420 | ) | |
| 421 | await Bun.sleep(100); | |
| 422 | return getRun(app.db, id)!; | |
| 423 | }; | |
| 424 | expect((await wait(first.id)).state).toBe("ok"); | |
| 464 | expect((await settled(first.id, 25_000)).state).toBe("ok"); | |
| 425 | 465 | |
| 426 | 466 | // The clone is kept while the pull is open; a cut-off run left a lock. |
| 427 | 467 | const agent = getAgent(app.db, bot.id)!; |
| @@ -443,7 +483,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to | ||
| 443 | 483 | while (Date.now() < deadline) { |
| 444 | 484 | next = listRuns(app.db, repo.id, { issueId: pull!.id })[0]; |
| 445 | 485 | if (next && ["ok", "failed", "cancelled"].includes(next.state)) break; |
| 446 | await Bun.sleep(100); | |
| 486 | await Bun.sleep(POLL_MS); | |
| 447 | 487 | } |
| 448 | 488 | expect(next).toBeDefined(); |
| 449 | 489 | return next!; |
| @@ -566,6 +606,8 @@ describe("an issue handed to someone else", () => { | ||
| 566 | 606 | concurrency: 0, |
| 567 | 607 | budgetUsd: 1, |
| 568 | 608 | runTimeoutMs: 60_000, |
| 609 | tickMs: TICK_MS, | |
| 610 | networkRetryScale: 0, | |
| 569 | 611 | }); |
| 570 | 612 | }); |
| 571 | 613 |