Public: anyone can read this; the crew can change it.
test/landing.test.ts drives the crew directly and names the step that did not happen, so it cannot go red on a slow tick #3513
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3493-test-landing-test-ts-drives-the-crew-dir → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +37
−12
src/crew/dispatch.ts +1 −1
| @@ -216,7 +216,7 @@ export async function startCrew( | ||
| 216 | 216 | ); |
| 217 | 217 | } |
| 218 | 218 | pump(app, ready, inFlight); |
| 219 | }, TICK_MS); | |
| 219 | }, options.tickMs ?? TICK_MS); | |
| 220 | 220 | requeueInterrupted(app); |
| 221 | 221 | sweepAssigned(app); |
| 222 | 222 | pump(app, ready, inFlight); |
src/crew/runner.ts +2
| @@ -168,6 +168,8 @@ export interface CrewOptions { | ||
| 168 | 168 | readonly sandboxEnforced?: boolean | undefined; |
| 169 | 169 | /** Why it cannot, when it cannot — said once in every run's log. */ |
| 170 | 170 | readonly sandboxNote?: string | undefined; |
| 171 | /** Milliseconds between the crew's ticks; tests pass a small one. @default 5000 */ | |
| 172 | readonly tickMs?: number | undefined; | |
| 171 | 173 | } |
| 172 | 174 | |
| 173 | 175 | export { crewRoot, workdirFor } from "./workdir.ts"; |
test/landing.test.ts +34 −11
| @@ -47,19 +47,39 @@ sleep 60 | ||
| 47 | 47 | concurrency: 1, |
| 48 | 48 | budgetUsd: 1, |
| 49 | 49 | runTimeoutMs: 60_000, |
| 50 | tickMs: 50, | |
| 50 | 51 | }); |
| 52 | // Where the run stands, for a failure to name: its state and summary, | |
| 53 | // whether the clone exists, the tail of its log. | |
| 54 | const seen = (repoId: string, clone?: string): string => { | |
| 55 | const runs = listRuns(app.db, repoId, { kind: "agent" }); | |
| 56 | const last = runs[0]; | |
| 57 | let tail = "(no run log)"; | |
| 58 | if (last) { | |
| 59 | try { | |
| 60 | tail = readFileSync(join(dataDir, "runs", `${last.id}.log`), "utf8") | |
| 61 | .split("\n") | |
| 62 | .slice(-20) | |
| 63 | .join("\n"); | |
| 64 | } catch { | |
| 65 | // no log yet | |
| 66 | } | |
| 67 | } | |
| 68 | return `runs=${JSON.stringify(runs.map((r) => [r.reason, r.state, r.summary]))} clone=${clone === undefined ? "n/a" : existsSync(clone)}\nlog tail:\n${tail}`; | |
| 69 | }; | |
| 51 | 70 | const until = async <T>( |
| 52 | 71 | read: () => T | undefined, |
| 53 | 72 | what: string, |
| 54 | ms = 45_000, | |
| 73 | state: () => string, | |
| 74 | ms = 20_000, | |
| 55 | 75 | ): Promise<T> => { |
| 56 | 76 | const deadline = Date.now() + ms; |
| 57 | 77 | while (Date.now() < deadline) { |
| 58 | 78 | const value = read(); |
| 59 | 79 | if (value !== undefined) return value; |
| 60 | await Bun.sleep(50); | |
| 80 | await Bun.sleep(10); | |
| 61 | 81 | } |
| 62 | throw new Error(`${what}: did not happen in time`); | |
| 82 | throw new Error(`${what}: did not happen in ${ms} ms; saw ${state()}`); | |
| 63 | 83 | }; |
| 64 | 84 | try { |
| 65 | 85 | const a = await createUser(app.db, { origin: "seed", trusted: true, |
| @@ -118,11 +138,14 @@ sleep 60 | ||
| 118 | 138 | listRuns(app.db, repo.id, { kind: "agent" }).find( |
| 119 | 139 | (r) => r.state === "running", |
| 120 | 140 | ), |
| 121 | `the run (${JSON.stringify(listRuns(app.db, repo.id, { kind: "agent" }).map((r) => [r.reason, r.state, r.summary]))})`, | |
| 141 | "the run starting", | |
| 142 | () => seen(repo.id), | |
| 122 | 143 | ); |
| 123 | 144 | expect(run.reason).toBe("feedback"); |
| 124 | 145 | const clone = workdirFor(dataDir, bot, repo, 1); |
| 125 | await until(() => (existsSync(clone) ? true : undefined), "the clone"); | |
| 146 | await until(() => (existsSync(clone) ? true : undefined), "the clone appearing", () => | |
| 147 | seen(repo.id, clone), | |
| 148 | ); | |
| 126 | 149 | |
| 127 | 150 | // The pull closes under it. |
| 128 | 151 | expect(setIssueState(app, alice, repo, asIssue, "closed").ok).toBe(true); |
| @@ -131,13 +154,14 @@ sleep 60 | ||
| 131 | 154 | (x) => x.id === run.id, |
| 132 | 155 | ); |
| 133 | 156 | return r && r.state !== "running" ? r : undefined; |
| 134 | }, "the run's end"); | |
| 157 | }, "the run's end", () => seen(repo.id, clone)); | |
| 135 | 158 | expect(ended.state).toBe("cancelled"); |
| 136 | 159 | expect(ended.summary).toBe("the pull closed"); |
| 137 | 160 | // The clone went with the run, once the run was over. |
| 138 | 161 | await until( |
| 139 | 162 | () => (existsSync(clone) ? undefined : true), |
| 140 | "the clone's end", | |
| 163 | "the clone's removal", | |
| 164 | () => seen(repo.id, clone), | |
| 141 | 165 | ); |
| 142 | 166 | const log = readFileSync(join(dataDir, "runs", `${run.id}.log`), "utf8"); |
| 143 | 167 | expect(log).not.toContain("the run failed"); |
| @@ -154,8 +178,7 @@ sleep 60 | ||
| 154 | 178 | app.close(); |
| 155 | 179 | await rm(dataDir, { recursive: true, force: true }); |
| 156 | 180 | } |
| 157 | // The crew starts a queued run on its next tick, five seconds apart: | |
| 158 | // the default timeout is a race against that clock, and a loaded check | |
| 159 | // machine stretches every wait: the budgets are generous on purpose. | |
| 160 | }, 150_000); | |
| 181 | // The crew ticks every 50 ms here (`tickMs`), not every five seconds, so | |
| 182 | // nothing waits on the default clock. | |
| 183 | }, 30_000); | |
| 161 | 184 | }); |
new above
offline
Fine. At 50 ms the autonomy/converge sweep stays on its own AUTONOMY_TICK_MS guard, so the fast tick only speeds the pump. A tiny test that tickMs is honored would pin this.