fix(ci): keep admitted Testboxes alive and enforce current workflow limits (#163021)

This commit is contained in:
Vincent Koc 2026-10-02 09:51:59 +07:00 • committed by GitHub
parent 70ca83470f
commit d6411f28a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 128 additions and 21 deletions

View file

@ -45,6 +45,10 @@ availability, Blacksmith control-plane health, and downstream queue drains.
named memory-heavy command; see `docs/reference/test/remote-proof.md`.
That workflow has at most four concurrent leases inside the shared 32-slot
Testbox pool. All Testbox profiles cap idle time at 15 minutes.
- Allocate through the current OpenClaw wrapper with workflow ref `main`;
the source capsule preserves the checkout being tested. Do not dispatch an
old workflow ref to bypass spending limits. Queue age is checked before
checkout; an admitted lease keeps its job and idle deadlines.
- Do not promote an entire workflow family because one command needs more RAM.
Keep proven high-memory CI rows scoped to their owning planner and evidence;
remeasure before changing their allocation. A 32-class label is not proof of

View file

@ -132,8 +132,6 @@ jobs:
- name: Bound Testbox idle lifetime
if: github.event_name == 'workflow_dispatch'
env:
TESTBOX_EXPIRES_AT: ${{ needs.admission.outputs.expires_at }}
run: node scripts/ci-testbox-budget.mjs configure
- name: Setup Node environment

View file

@ -135,8 +135,6 @@ jobs:
persist-credentials: false
- name: Bound Testbox idle lifetime
if: github.event_name == 'workflow_dispatch'
env:
TESTBOX_EXPIRES_AT: ${{ needs.admission.outputs.expires_at }}
run: node scripts/ci-testbox-budget.mjs configure
- name: Setup Node environment

View file

@ -84,8 +84,6 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Bound Testbox idle lifetime
env:
TESTBOX_EXPIRES_AT: ${{ needs.admission.outputs.expires_at }}
run: node scripts/ci-testbox-budget.mjs configure
- name: Setup Node environment

View file

@ -128,8 +128,6 @@ jobs:
persist-credentials: false
- name: Bound Testbox idle lifetime
if: github.event_name == 'workflow_dispatch'
env:
TESTBOX_EXPIRES_AT: ${{ needs.admission.outputs.expires_at }}
run: node scripts/ci-testbox-budget.mjs configure
- name: Setup Node environment

View file

@ -531,7 +531,9 @@ A supported sibling or `PATH` binary can run directly. The wrapper automatically
replaces an outdated selection with the plugin-managed binary; rebuilding the
sibling checkout is no longer a prerequisite for proof.
The `blacksmith:` block in `.crabbox.yaml` already pins the org, workflow, job, and ref defaults, so the explicit flags below are optional. Explicit clean-machine changed-gate parity:
The `blacksmith:` block in `.crabbox.yaml` pins the org, workflow, and job defaults.
The wrapper enforces workflow ref `main` while syncing the selected local source,
so the explicit flags below are optional. Explicit clean-machine changed-gate parity:
```bash
pnpm crabbox:run -- --provider blacksmith-testbox \

View file

@ -21,6 +21,8 @@ collisions can leave other slots unused; this is a ceiling, not a promise of
Admission expires ten minutes after the workflow was created. A request that
waits longer fails before checkout or hydration when its runner starts; it can
still incur runner startup cost. This does not remove the queued job immediately.
Once the request passes that check, checkout and hydration do not recheck queue
age. They remain bounded by the job timeout and idle limit.
Stop an abandoned lease by its exact ID instead of leaving a warmup pending.
Do not retry in a loop when the pool is full.
@ -75,7 +77,10 @@ operating choice; remeasure after the 15-minute idle cap is deployed before
making 30 minutes or 8-class the general default.
These controls cover dispatches using the updated workflows in this repository.
Historical refs, other repositories, alternate workflows, and Windows probe
The OpenClaw wrapper selects the workflow from `main` for Testbox `run` and
`warmup`, overriding configured refs and rejecting explicit historical refs.
Its source capsule still reconstructs the checkout being tested. Old wrappers,
direct historical-ref dispatches, other repositories, alternate workflows, and Windows probe
workflows are outside the shared pool. Organization-wide concurrency, per-token
admission, SKU restrictions, and a hard spending stop require provider controls.

View file

@ -26,6 +26,13 @@ for later remote commands, sync the current checkout on every run, and stop it
before handoff. Let the previous command and its cleanup finish before
another synchronization or reuse of that lease.
Testbox `run` and `warmup` use the workflow from `main` so new allocations
inherit the maintained spending limits. The wrapper overrides configured workflow
refs and rejects an explicit `--blacksmith-ref` other than `main`. Choose the
source revision in your local checkout; its source capsule and frozen dependency
install preserve that selection independently of the workflow ref. Explicit
workflow and job selection still support the high-memory profile.
At allocation, the wrapper records the caller task, physical checkout, HEAD,
base, dependency inputs, and Testbox preparation fingerprint under
`.crabbox/testbox-leases/`. Reuse requires the same task, checkout, base,

View file

@ -78,7 +78,7 @@ export function boundedTestboxIdleMinutes(value) {
async function main() {
if (process.argv[2] === "configure") {
assertFreshTestboxAdmission(process.env.TESTBOX_EXPIRES_AT);
// The workflow checks queue age before checkout; admitted work keeps its lease.
const path = "/tmp/.testbox/idle_timeout";
const idle = boundedTestboxIdleMinutes(readFileSync(path, "utf8"));
writeFileSync(path, `${idle}\n`);

View file

@ -3774,6 +3774,19 @@ if (provider && !isProviderAdvertised(provider, providers)) {
}
if (canonicalProvider === "blacksmith-testbox") {
if (["run", "warmup"].includes(normalizedArgs[0] ?? "")) {
const workflowRef = parseCommandInvocation(help.text, normalizedArgs).optionEntries.findLast(
({ name }) => name === "blacksmith-ref",
);
if (workflowRef && workflowRef.value !== "main") {
console.error(
"[crabbox] Testbox workflow ref must be main so allocations use current spending limits. Omit --blacksmith-ref; the source capsule preserves the checkout being tested.",
);
process.exit(2);
}
// Override config/environment refs before binding the allocation receipt.
normalizedArgs.splice(commandOptionEnd(normalizedArgs), 0, "--blacksmith-ref=main");
}
// The delegated provider rejects uploaded scripts before acquiring a lease.
if (
normalizedArgs[0] === "run" &&

View file

@ -1,4 +1,6 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import {
@ -98,6 +100,37 @@ describe("Testbox spending admission", () => {
expect(() => boundedTestboxIdleMinutes("0")).toThrow(/invalid/);
});
it("configures idle time after an admitted checkout crosses the queue deadline", () => {
const script = resolve("scripts/ci-testbox-budget.mjs");
const result = spawnSync(
process.execPath,
[
"--input-type=module",
"--eval",
`
import assert from "node:assert/strict";
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { pathToFileURL } from "node:url";
const read = fs.readFileSync;
fs.readFileSync = (path, ...args) =>
path === "/tmp/.testbox/idle_timeout" ? "90\\n" : read(path, ...args);
fs.writeFileSync = (path, value) => {
assert.equal(path, "/tmp/.testbox/idle_timeout");
assert.equal(value, "15\\n");
console.log("idle setting updated");
};
syncBuiltinESMExports();
process.argv = [process.execPath, ${JSON.stringify(script)}, "configure"];
await import(pathToFileURL(process.argv[1]).href);
`,
],
{ encoding: "utf8", env: { TESTBOX_EXPIRES_AT: "1" } },
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("idle setting updated");
});
it.each([
["ci-check-testbox.yml", "check", "check"],
["ci-check-high-memory-testbox.yml", "check", "check-memory"],

View file

@ -70,7 +70,9 @@ const azureProviderHelp =
"provider: hetzner, aws, azure, local-container, blacksmith-testbox, or cloudflare\n";
const fakeRunValueOptionHelp = [
"artifact-glob value",
"blacksmith-job string",
"blacksmith-ref string",
"blacksmith-workflow string",
"capture-stderr string",
"capture-stdout string",
"download value",
@ -2388,6 +2390,7 @@ describe("scripts/crabbox-wrapper", () => {
"blacksmith-testbox",
"--id",
id,
"--blacksmith-ref=main",
"--reclaim",
"--shell",
"--",
@ -3829,9 +3832,9 @@ esac
it.each([
{
scenario: "Blacksmith feature ref",
scenario: "Blacksmith maintained workflow",
provider: "blacksmith-testbox",
args: ["--blacksmith-ref", "feature-branch"],
args: ["--blacksmith-ref", "main"],
command: ["corepack", "pnpm", "check:changed"],
},
{ scenario: "local container", provider: "local-container", args: [], command: ["echo ok"] },
@ -3858,6 +3861,61 @@ esac
},
);
it.each([
["warmup", "--blacksmith-ref=main", "--blacksmith-ref", "feature-branch"],
["run", "-blacksmith-ref=feature-branch", "--sync-only"],
])("refuses historical Testbox workflow allocation before delegation: %s", (...args) => {
const invocationLog = makeInvocationLog();
const result = runDefaultWrapper([...args, "--provider", "blacksmith-testbox"], {
env: { OPENCLAW_FAKE_CRABBOX_INVOCATION_LOG: invocationLog },
});
expect(result.status).toBe(2);
expect(result.stderr).toContain("Testbox workflow ref must be main");
expect(
readInvocations(invocationLog).some(
(invocation) =>
["run", "warmup"].includes(invocation[0] ?? "") && !invocation.includes("--help"),
),
).toBe(false);
});
it.each([[], ["--blacksmith-ref=old-workflow", "-blacksmith-ref", "main"]])(
"pins Testbox policy independently of configured refs and payload arguments: %j",
(...refs) => {
const { output } = runSuccessfulDefaultWrapper(
[
"run",
"--provider",
"blacksmith-testbox",
...refs,
"--blacksmith-workflow",
".github/workflows/ci-check-high-memory-testbox.yml",
"--blacksmith-job",
"check",
"--",
"echo",
"--blacksmith-ref",
"historical-source",
],
{
configJson: { provider: "blacksmith-testbox", blacksmith: { ref: "old-workflow" } },
env: { CRABBOX_BLACKSMITH_REF: "old-environment-ref" },
},
);
const optionEnd = output.args.indexOf("--");
expect(output.args.slice(0, optionEnd)).toContain("--blacksmith-ref=main");
expect(output.args.slice(0, optionEnd)).toEqual(
expect.arrayContaining([
"--blacksmith-workflow",
".github/workflows/ci-check-high-memory-testbox.yml",
"--blacksmith-job",
"check",
]),
);
expect(output.args.at(-1)).toContain("historical-source");
},
);
it("uses an explicit release base for changed-gate sync and remote Git metadata", () => {
const { remoteCommand, result } = runSuccessfulDefaultWrapper(
[
@ -6463,14 +6521,7 @@ cp.spawnSync = (command, args, options) => {
it("uses the temporary full checkout for sparse sync-only runs", () => {
const { output, result } = runSuccessfulDefaultWrapper(
[
"run",
"--provider",
"blacksmith-testbox",
"--blacksmith-ref",
"feature-branch",
"--sync-only",
],
["run", "--provider", "blacksmith-testbox", "--blacksmith-ref", "main", "--sync-only"],
cleanSparseSyncOptions,
);