Merge the re-run time cap fix: a job run again is no longer stopped when the attempt before it would have run out of time, since every attempt of a job runs in the same sandbox object and each left an alarm for its own cap; and a sandbox g1t stops says why at once rather than ten minutes later.
5 files+81−100/5 viewed
| 1405 | 1405 | the page to read it. Its jobs have ids of their own, so a link to an | |
| 1406 | 1406 | earlier attempt's job keeps showing that job's log. | |
| 1407 | 1407 | ||
| 1408 | + | A job that runs again has its whole time limit from when it starts: the | |
| 1409 | + | time cap of the attempt before it never stops it. | |
| 1410 | + | ||
| 1408 | 1411 | #### Debug logging | |
| 1409 | 1412 | ||
| 1410 | 1413 | Each re-run asks whether to **Enable debug logging**. The new attempt's |
| 295 | 295 | runner's image is tagged with a hash of what it is built from, so a deploy | |
| 296 | 296 | builds it only when the registry does not already have it. When nothing | |
| 297 | 297 | the image is built from changed, the deploy leaves running sandboxes | |
| 298 | − | alone. | |
| 298 | + | alone. When it did, Cloudflare replaces sandboxes on the new image but | |
| 299 | + | leaves any that started within the last two hours | |
| 300 | + | (`rollout_active_grace_period` in `services/runner/wrangler.jsonc`) to | |
| 301 | + | finish first. A workflow job on g1t's machines runs for at most 60 | |
| 302 | + | minutes, so a deploy never cuts one short; an agent run whose guardrails | |
| 303 | + | allow more than two hours can be. | |
| 299 | 304 | ||
| 300 | 305 | Jobs choose larger machines with `runs-on: g1t-2core` or `g1t-4core`. Your | |
| 301 | 306 | account's Containers limits must allow `standard-4`. |
| 92 | 92 | import { buildMentionPrompt, describeThread, handleMention, jobTokenRefusal, planMention } from "./mentions"; | |
| 93 | 93 | import { instructionsFor, repoInstructions, withBlock } from "./repo-instructions"; | |
| 94 | 94 | import { cancelTask, enqueueTask, handedOverStep, selfHostedRoute, taskEnv, taskRepo } from "./self-hosted"; | |
| 95 | − | import { answered, describeError, tellStopped, withinTimeCap } from "./lifecycle"; | |
| 95 | + | import { answered, capReached, describeError, stoppedShort, tellStopped, withinTimeCap } from "./lifecycle"; | |
| 96 | 96 | import { | |
| 97 | 97 | ABUSE_EXIT_CODE, | |
| 98 | 98 | ABUSE_HOST, | |
| ⋯ | |||
| 480 | 480 | throw error; | |
| 481 | 481 | } | |
| 482 | 482 | await this.ctx.storage.put("run", run); | |
| 483 | − | await this.ctx.storage.delete(["abuse", "stopReason", "remote", "misdirected"]); | |
| 483 | + | // What an earlier attempt in this object left: its reason, its cap | |
| 484 | + | // and its cap's alarm, which would otherwise stop this attempt when | |
| 485 | + | // the earlier one's time ran out (re-runs use the same object). | |
| 486 | + | await this.ctx.storage.delete(["abuse", "stopReason", "remote", "misdirected", "timeCap"]); | |
| 487 | + | this.deleteSchedules("timeUp"); | |
| 484 | 488 | if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() }); | |
| 485 | 489 | await this.ctx.storage.put("started", Date.now()); | |
| 486 | 490 | const who = meter ? { workspace: meter.workspace, repo: meter.repo } : owner; | |
| ⋯ | |||
| 681 | 685 | ||
| 682 | 686 | /** The run's time cap has passed: stop it, as stopped for time. */ | |
| 683 | 687 | async timeUp(): Promise<void> { | |
| 684 | − | // It already stopped: nothing to stop. | |
| 685 | − | if (!(await this.ctx.storage.get<number>("started"))) return; | |
| 686 | − | const tracked = await this.ctx.storage.get<TrackedRun>("agentRun"); | |
| 688 | + | // It already stopped (nothing to stop), or the run in it now is a later | |
| 689 | + | // attempt whose cap has not passed: this alarm is an earlier attempt's, | |
| 690 | + | // left by a version of g1t that did not clear it, and the run now has | |
| 691 | + | // its own. | |
| 692 | + | const started = await this.ctx.storage.get<number>("started"); | |
| 687 | 693 | const minutes = (await this.ctx.storage.get<number>("timeCap")) ?? 0; | |
| 694 | + | if (!capReached(started, minutes, Date.now())) { | |
| 695 | + | if (started) console.log("an earlier attempt's time cap passed; the run now keeps going", this.ctx.id.toString()); | |
| 696 | + | return; | |
| 697 | + | } | |
| 698 | + | const tracked = await this.ctx.storage.get<TrackedRun>("agentRun"); | |
| 688 | 699 | // A workflow job or a build has no run to halt: it fails saying why. | |
| 689 | 700 | await this.ctx.storage.put("stopReason", timeCapMessage(minutes)); | |
| 690 | 701 | if (tracked) await reportRun(this.env.WORK, tracked, { halt: "time", error: timeCapMessage(minutes) }); | |
| ⋯ | |||
| 816 | 827 | const flagged = (await this.ctx.storage.get<boolean>("abuse")) ?? false; | |
| 817 | 828 | // Why it stopped, when g1t stopped it: said in place of a plain failure. | |
| 818 | 829 | const why = flagged ? ABUSE_MESSAGE : ((await this.ctx.storage.get<string>("stopReason")) ?? null); | |
| 830 | + | // Stopped by g1t, it failed whatever exit code the library passed on. | |
| 831 | + | const short = stoppedShort(exitCode, why); | |
| 819 | 832 | const ended = await this.closeRun( | |
| 820 | − | exitCode === 0 ? "succeeded" : "failed", | |
| 821 | − | exitCode === 0 ? undefined : (why ?? `The sandbox exited with ${exitCode}.`), | |
| 833 | + | short ? "failed" : "succeeded", | |
| 834 | + | short ? (why ?? `The sandbox exited with ${exitCode}.`) : undefined, | |
| 822 | 835 | ); | |
| 823 | 836 | await this.settleStopped(started, tracked); | |
| 824 | 837 | await this.ctx.storage.delete("started"); | |
| 825 | − | if (exitCode === 0 && !flagged) return; | |
| 838 | + | if (!short) return; | |
| 826 | 839 | const run = await this.ctx.storage.get<Run>("run"); | |
| 827 | 840 | // A person stopped it: g1t has already left the pull request for them. | |
| 828 | 841 | if (ended === "stopped" && run && STOP_ENDS.has(run.kind)) return; | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { answered, describeError, tellStopped, withinTimeCap } from "./lifecycle.ts"; | |
| 4 | + | import { answered, capReached, describeError, stoppedShort, tellStopped, withinTimeCap } from "./lifecycle.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const quiet = { waitMs: 0 }; | |
| 7 | 7 | ||
| ⋯ | |||
| 84 | 84 | assert.equal(withinTimeCap(now - 1000, null, now, 180), false); | |
| 85 | 85 | assert.equal(withinTimeCap(now - 1000, 0, now, 180), false); | |
| 86 | 86 | }); | |
| 87 | + | ||
| 88 | + | test("an earlier attempt's time cap does not stop the attempt running now", () => { | |
| 89 | + | const minute = 60_000; | |
| 90 | + | // Desktop release, Linux job: attempt 1 started 12:47:17 with a 60-minute | |
| 91 | + | // cap, so its alarm fired at 13:50:17, four minutes into attempt 3 in the | |
| 92 | + | // same Durable Object. | |
| 93 | + | const attempt3 = Date.UTC(2026, 9, 12, 13, 46, 15); | |
| 94 | + | const alarm = Date.UTC(2026, 9, 12, 13, 50, 17); | |
| 95 | + | assert.equal(capReached(attempt3, 60, alarm), false); | |
| 96 | + | // Its own alarm, at its cap and the alarm's grace, does stop it. | |
| 97 | + | assert.equal(capReached(attempt3, 60, attempt3 + 63 * minute), true); | |
| 98 | + | assert.equal(capReached(attempt3, 60, attempt3 + 60 * minute), true); | |
| 99 | + | assert.equal(capReached(attempt3, 60, attempt3 + 60 * minute - 1), false); | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | test("a sandbox with no run or no cap is never stopped for time", () => { | |
| 103 | + | const now = Date.now(); | |
| 104 | + | assert.equal(capReached(undefined, 60, now), false); | |
| 105 | + | assert.equal(capReached(now - 120 * 60_000, undefined, now), false); | |
| 106 | + | assert.equal(capReached(now - 120 * 60_000, 0, now), false); | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | test("a sandbox g1t stopped fails even when its exit code says 0", () => { | |
| 110 | + | assert.equal(stoppedShort(0, null), false); | |
| 111 | + | assert.equal(stoppedShort(0, "It ran into its time cap of 60 minutes."), true); | |
| 112 | + | assert.equal(stoppedShort(137, null), true); | |
| 113 | + | assert.equal(stoppedShort(1, "why"), true); | |
| 114 | + | }); | |
| 67 | 67 | return now < started + (capMinutes * 60 + graceSeconds) * 1000; | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | /** | |
| 71 | + | * Whether the run a sandbox holds now has used its whole time cap, so a | |
| 72 | + | * `timeUp` alarm may stop it. A sandbox's Durable Object is named for its | |
| 73 | + | * work (`actions:{job}` and the like), and a re-run of that work starts in | |
| 74 | + | * the same object; an alarm an earlier attempt left must not stop the | |
| 75 | + | * attempt running now, which started later and has its own alarm. | |
| 76 | + | */ | |
| 77 | + | export function capReached(started: number | null | undefined, capMinutes: number | null | undefined, now: number): boolean { | |
| 78 | + | if (typeof started !== "number" || typeof capMinutes !== "number" || capMinutes <= 0) return false; | |
| 79 | + | return now >= started + capMinutes * 60 * 1000; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Whether a sandbox's stop ends its work as a failure. A sandbox g1t | |
| 84 | + | * stopped (at its time cap, say) can come back with exit code 0, since the | |
| 85 | + | * container library reports a destroyed container as a clean exit; the | |
| 86 | + | * reason g1t stopped it, not the exit code, says how it ended. | |
| 87 | + | */ | |
| 88 | + | export function stoppedShort(exitCode: number, why: string | null): boolean { | |
| 89 | + | return exitCode !== 0 || why !== null; | |
| 90 | + | } | |
| 91 | + | ||
| 70 | 92 | /** An error as one line, for a log: its message, or what was thrown. */ | |
| 71 | 93 | export function describeError(error: unknown): string { | |
| 72 | 94 | if (error instanceof Error) return error.message || error.name; |
Comments
Sign in to comment.