Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ jobs:
strategy:
fail-fast: false
# Hosted macOS runners can queue past the timeout, which then reports a job
# that never ran as failed. Pull requests rely on Release's native macOS
# lifecycle checks instead; main still checks the flake's macOS build.
# that never ran as failed. Pull requests skip macOS here and in Release;
# main checks the flake's macOS build, and tags check the released binaries.
matrix:
runner: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "macos-14"]') }}
runs-on: ${{ matrix.runner }}
Expand Down
27 changes: 21 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,13 @@ jobs:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-14
- target: x86_64-apple-darwin
runner: macos-15-intel
- target: aarch64-unknown-linux-gnu
runner: ubuntu-22.04-arm
- target: x86_64-unknown-linux-gnu
runner: ubuntu-22.04
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps:
steps: &check-steps
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
Expand Down Expand Up @@ -128,6 +124,25 @@ jobs:
shell: bash
run: bash scripts/lifecycle-check.sh "$RUNNER_TEMP/quest"

# Hosted macOS runners can queue past the timeout, which then reports a job
# that never ran as failed. Pull requests skip these; tags, which publish the
# artifacts, and manual runs still check them. Check covers macOS on main.
macos:
name: check (${{ matrix.target }})
if: github.event_name != 'pull_request'
needs: build
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-14
- target: x86_64-apple-darwin
runner: macos-15-intel
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps: *check-steps

windows:
name: check (${{ matrix.target }})
needs: build
Expand Down Expand Up @@ -164,7 +179,7 @@ jobs:
./scripts/install-check.ps1 -Method archive -Target $env:TARGET -Tag $tag -ReleaseDirectory (Resolve-Path release).Path

publish:
needs: [check, windows]
needs: [check, macos, windows]
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ ownership-manifest design:
## Required

- [Kix decision UI](/quest/m0/kix/README.md) - planning and implementation through prompts and summaries, run on the maintainer's own runners
- [CI flakes](/quest/m0/ci-flakes.md) - native Windows tests and Release's macOS checks fail only on real breakage
- [Gate bench](/quest/m0/gate-bench.md) - measure headroom admission on an idle host
- [Agent lifecycle integration](/quest/m0/worktree-agents.md) - reclaim completed and suspended Claude checkouts through its hooks
- [Agent workflow helpers](/quest/m0/helpers/README.md) - CLI commands for CI waits, PR status, claims, quest moves, and serialized merges
Expand Down
36 changes: 0 additions & 36 deletions quest/m0/ci-flakes.md

This file was deleted.

30 changes: 18 additions & 12 deletions src/cli/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,15 @@ function parse(text: string): Admission[] {
return valid ? (value as Admission[]) : [];
}

/** Remove a file that another caller may already have removed, or on Windows may still hold open. */
/**
* Remove a file that another caller may already have removed, or on Windows may still hold open
* or be removing too. Bun on Windows reports a concurrent removal as EFAULT, where Node reports EPERM.
*/
function remove(file: string) {
try {
rmSync(file, { force: true });
} catch (error) {
if (!["EPERM", "EBUSY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
if (!["EPERM", "EBUSY", "EFAULT"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
}
}

Expand All @@ -231,22 +234,25 @@ function remove(file: string) {
*/
export function fileHost(directory: string, now = Date.now): Host {
const path = (version: number) => join(directory, String(version));
const latest = (): number => {
let names: string[];
try {
names = readdirSync(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
throw error;
}
return names.reduce((max, name) => (/^\d+$/.test(name) ? Math.max(max, Number(name)) : max), 0);
};
const ledger = (): Ledger => {
for (;;) {
let names: string[];
try {
names = readdirSync(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 0, admissions: [] };
throw error;
}
const version = names.reduce((max, name) => (/^\d+$/.test(name) ? Math.max(max, Number(name)) : max), 0);
const version = latest();
if (version === 0) return { version, admissions: [] };
try {
return { version, admissions: parse(readFileSync(path(version), "utf8")) };
} catch (error) {
// Superseded and pruned since the listing.
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
// Superseded and pruned since the listing: gone, or on Windows refusing reads while it is removed.
if (latest() === version) throw error;
}
}
};
Expand Down
41 changes: 31 additions & 10 deletions test/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,21 +241,31 @@ test("a forward clock step forgets at most the last minute of admissions", async
expect(events).toEqual([`sleep ${SETTLE_MS}`]);
});

test("concurrent processes never admit past the headroom", { timeout: 30_000 }, async () => {
test("concurrent processes never admit past the headroom, and admit every caller", { timeout: 30_000 }, async () => {
const directory = tempDir();
const ledger = join(directory, "ledger");
const go = join(directory, "go");
const gate = fileURLToPath(new URL("../src/cli/gate.ts", import.meta.url));
// Each caller waits for the others, then races for one of 4 CPUs' headroom until a shared deadline.
// Each caller waits for the others, then races for one of 4 CPUs' headroom. Their shared clock
// runs 20 times faster from the start, so spacing the other 8 takes seconds rather than a minute.
// The last version a caller publishes ends with its own admission, which it reports.
const script = `
const { existsSync, readFileSync } = await import("node:fs");
const { admit, fileHost } = await import(${JSON.stringify(gate)});
console.log("ready");
while (!existsSync(${JSON.stringify(go)})) await new Promise((resolve) => setTimeout(resolve, 2));
const deadline = Number(readFileSync(${JSON.stringify(go)}, "utf8"));
const host = { ...fileHost(${JSON.stringify(ledger)}), load: () => 0, memory: () => Infinity };
const signal = AbortSignal.timeout(Math.max(0, deadline - Date.now()));
await admit({ load: 4, memory: 1 }, host, signal).then(() => console.log("admitted"), () => {});
const start = Number(readFileSync(${JSON.stringify(go)}, "utf8"));
const file = fileHost(${JSON.stringify(ledger)}, () => start + (Date.now() - start) * 20);
let admission;
const host = {
...file,
load: () => 0,
memory: () => Infinity,
publish: (base, admissions) => file.publish(base, admissions) && Boolean((admission = admissions.at(-1))),
sleep: (ms, signal) => file.sleep(ms / 20, signal),
};
await admit({ load: 4, memory: 1 }, host, new AbortController().signal);
console.log(JSON.stringify(admission));
`;
const callers = Array.from({ length: 12 }, () => {
const child = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "inherit"] });
Expand All @@ -270,13 +280,24 @@ test("concurrent processes never admit past the headroom", { timeout: 30_000 },
return { ready, exited: once(child, "exit").then(([code]) => ({ code, stdout })) };
});
await Promise.all(callers.map(({ ready }) => ready));
// The next admission is due SETTLE_MS after the last; the deadline comes sooner.
writeFileSync(`${go}.tmp`, String(Date.now() + SETTLE_MS / 2));
writeFileSync(`${go}.tmp`, String(Date.now()));
renameSync(`${go}.tmp`, go);
const results = await Promise.all(callers.map(({ exited }) => exited));
expect(results.map(({ code }) => code)).toEqual(Array(12).fill(0));
expect(results.filter(({ stdout }) => stdout.includes("admitted"))).toHaveLength(4);
expect(fileHost(ledger).ledger().admissions).toHaveLength(4);
const admissions: { id: string; at: number }[] = results
.map(({ stdout }) => JSON.parse(stdout.split("\n")[1]))
.sort((a, b) => a.at - b.at);
expect(new Set(admissions.map(({ id }) => id)).size).toBe(12);
// However slowly the callers arrive, one that finds 4 admissions from the last minute
// comes SETTLE_MS after the previous one.
admissions.forEach(({ at }, i) => {
const recent = admissions.slice(0, i).filter((previous) => previous.at > at - RECENT_MS);
if (recent.length >= 4) expect(at - admissions[i - 1].at).toBeGreaterThanOrEqual(SETTLE_MS);
});
// The ledger holds only those admissions, up to the last.
const recorded = fileHost(ledger).ledger().admissions;
expect(admissions).toEqual(expect.arrayContaining(recorded));
expect(recorded).toContainEqual(admissions.at(-1));
});

test.each(["v1", "v2"])("Linux memory counts reclaimable cache and bounds %s container headroom", (version) => {
Expand Down
5 changes: 4 additions & 1 deletion test/landed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import { execFileSync } from "node:child_process";

import { expect, test } from "vitest";
import { expect, test, vi } from "vitest";

import { Tree } from "./fixture";

const FINDING = "neither deletes nor edits its quest since main";

// Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s.
vi.setConfig({ testTimeout: 30_000 });

/** The baseline tree committed on `main`, then checked out on `branch`. */
function repo(branch: string): Tree {
const tree = Tree.baseline();
Expand Down
5 changes: 4 additions & 1 deletion test/move.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";

import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";

import { move, remove } from "../src/core";
import { openPullRequests } from "../src/cli/claims";
import { GitHub, type GitHubDependencies } from "../src/cli/github";
import { main } from "../src/cli/main";
import { ROOT_README, Tree, tempDir } from "./fixture";

// Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s.
vi.setConfig({ testTimeout: 30_000 });

function git(dir: string, ...args: string[]): string {
return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
Expand Down
Loading