diff --git a/.agents/skills/work-issue/SKILL.md b/.agents/skills/work-issue/SKILL.md index 7608e0a01..5b426cd44 100644 --- a/.agents/skills/work-issue/SKILL.md +++ b/.agents/skills/work-issue/SKILL.md @@ -267,7 +267,13 @@ Missing tools, skipped runtimes, failures/cancellations and unexpected job skips leave selected hosted proof incomplete. Never delete tests, weaken offline harnesses or rerun a real failure to obtain green. Keep drafts cheap and batch pushes. A ready PR synchronize event runs fast feedback with a deliberately -nonpassing final gate. After the last push, convert to draft and mark ready to +nonpassing final gate. For ordinary candidates, acquire the exact-candidate integration reservation +with `bun scripts/integrate.ts confirm --issues owner/repo#N,...` before +requesting expensive confirmation; it handles the draft-to-ready transition, +minute renewal and shared proof-checked merge. Hold competing confirmation and +main merges while another lease is live. See the CI reference for explicit +acquire/renew/release/revoke, expiry, hard cap and unsupported native candidates. +After the last push, convert to draft and mark ready under that reservation to request complete candidate confirmation; opening/reopening ready also requests it. After cheap gates independent categories run concurrently; per-PR cancellation cannot cancel another PR. Measure queue/elapsed time and wasted cancellations before adding shards. Follow docs/decisions/ci-utility.md. @@ -301,7 +307,8 @@ type/area/`contract` labels; do not copy readiness labels onto the PR. under the GitHub skill. This applies to every merge, including docs/test/CI work and `Refs` slices; choose the confirmed upcoming release for unreleased work and preserve the initial release of unchanged shipped behavior. Then - squash with `--match-head-commit` for an ordinary PR, or follow the stack + use `bun scripts/integrate.ts merge --lease --issues owner/repo#N,...` + for the shared reservation-checked ordinary squash, or follow the stack merge/evidence procedure for native stacked PRs. Respect a merge queue and monitor actual completion. Do not ask again for permission included in the end-to-end request. diff --git a/.agents/skills/work-issue/references/ci-and-merge.md b/.agents/skills/work-issue/references/ci-and-merge.md index 217396cdf..d73d9fe00 100644 --- a/.agents/skills/work-issue/references/ci-and-merge.md +++ b/.agents/skills/work-issue/references/ci-and-merge.md @@ -245,6 +245,21 @@ Reconcile any different integrated tree and verify automatic main-push proof, then read back issue closure and milestone assignments for every delivered slice. Queued PRs require actual merge-group and eventual landing evidence. +## Reservation before expensive confirmation + +For ordinary PRs, use `bun scripts/integrate.ts confirm --issues +owner/repo#N,...` before requesting full confirmation. It acquires the remote +exact PR/head/base reservation, renews every minute, waits for actual complete +candidate proof, and uses the shared merge path. `inspect` shows ownership; +busy callers have bounded retries, never a FIFO queue. Separate `acquire` +requires minute `renew` and explicit `merge --lease`; `release` abandons it. +The lease lasts five minutes with an absolute sixty-minute cap including +queue, proof and merge. Do not reacquire to extend an old proof window; a new +candidate/lease needs new confirmation. Maintainer `revoke ''` records +actor/time/reason and immediately removes the old authority without waiving +proof. Native candidates need separately qualified prefix integration; this +ordinary command rejects them. See the [binding decision](../../../../docs/decisions/ci-utility.md#exact-candidate-integration-reservation). + ## Merge and verify Before every merge, assign the PR and each completed/advanced issue a release @@ -277,8 +292,8 @@ passing head, a ready PR and satisfied branch rules. A completed partial slice uses `Refs` and states remaining acceptance/verification work. ```sh -rtk proxy gh pr merge --repo schlessera/brain-kit \ - --squash --match-head-commit +rtk proxy bun scripts/integrate.ts merge --lease \ + --issues schlessera/brain-kit# rtk proxy gh pr view --repo schlessera/brain-kit \ --json state,mergeCommit,mergedAt,headRefOid,url rtk proxy git fetch origin @@ -290,7 +305,12 @@ rtk proxy test "$(rtk proxy git rev-parse ^{tree})" \ = "$verified_tree" ``` -Avoid `--delete-branch` while a worktree/worker needs the branch. Follow a +The shared command independently verifies selected jobs, actual checkout +identities, milestones and final live ownership before its matching-head +squash. A successful squash returns exit 2: reconcile automatic main jobs and +checkout evidence separately even if a workflow summary is already green. +Direct `gh pr merge` is not the cooperative merge procedure. Preserve branches +while a worktree/worker needs them. Follow a required merge queue's accepted strategy and monitor queue checks and **actual merge**: enqueued does not mean merged. For a queue, inspect the actual merge-group checkout, its head/base relationships and required checks; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519c33df8..ad310e465 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -191,6 +191,19 @@ input/dependency/harness changes require fresh proof; unknown changes expand conservatively. There is no generic cache of passing tests. Respect branch rules and verify the actual squash parent/tree and automatic main-push result. +For ordinary PRs, acquire the exact-candidate integration reservation before +expensive confirmation and keep it through merge. Use +`bun scripts/integrate.ts confirm --issues owner/repo#N,...` for automatic +renewal and proof-checked squash, or the explicit acquire/renew/merge commands +in [the reservation decision](docs/decisions/ci-utility.md#exact-candidate-integration-reservation). +Busy owners may continue coding and fast feedback; hold competing confirmation +and main merges. The five-minute lease renews every minute with a sixty-minute +absolute cap, including queues. Direct `gh pr merge` bypasses this cooperative +procedure. A successful squash returns exit 2 until actual automatic main jobs +are reconciled separately; do not treat a run summary as complete proof. +Native stacks require separately qualified integration support; this ordinary +command rejects them rather than inventing an ordinary merge receipt. + Independent fast test batches and typechecking share a queue with at most two active processes on one runner. Each selected test runs exactly once; another batch starts when a slot becomes free. Ordinary Bun processes retain their module diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index 8d81ecaa3..1c8ed7044 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -668,6 +668,69 @@ Agents must not do so, and this policy does not change protection settings. The read-only [Get a pull request API](https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request) accepts the existing `contents: read` permission; no privileged token/event is added. +## Exact-candidate integration reservation + +The maintainer rulings on #2111 select cooperative coordination for expensive +confirmation and main merges. One reservation covers exactly one repository, +PR, head SHA and base SHA, with an authenticated numeric GitHub actor ID. It +lasts five minutes, renews every minute and expires absolutely sixty minutes +after acquisition, including runner queue, proof and merge time. Renewal never +resets that cap. A changed head/base, terminal failure/cancellation, abandonment +or delivery releases ownership; a stopped process loses it after its last +five-minute term. An expired or superseded identity cannot renew itself or +release a successor. + +The shared command is `bun scripts/integrate.ts`. Its authoritative state is +`reservation.json` in `refs/heads/brain-integration-reservation` on origin. +Each update is a commit parented by the inspected remote state and pushed with +an exact `--force-with-lease` comparison, including absent-ref acquisition. +Competing snapshots cannot both acquire. Git history retains acquisition, +renewal, release and explicit revocation records. Expiry uses a fresh GitHub +HTTP Date response, requested without caches, with no local-clock fallback. +Malformed state, unavailable time or transport errors fail closed. Public state +is leakage-screened before publication. The active record shows owner, +candidate, expiry and absolute cap; busy callers get that record and at most +three attempts with bounded backoff. There is no persistent or FIFO queue. + +Use `confirm --issues owner/repo#N,...` before requesting costly ordinary +candidate proof. The command acquires, requests draft-to-ready confirmation, +renews while waiting, and calls its shared merge path after success. Nonowners +may code and run fast feedback but must hold competing confirmation and merges. +For a separately managed proof window, `acquire ` returns the explicit +lease ID; its owner must run `renew ` every minute and later +`merge --lease --issues owner/repo#N,...`. `release ` abandons +it. `inspect` is read-only. A maintainer may `revoke ''`, recording +actor, server time, reason and former candidate; a replacement needs its own +reservation and passing confirmation. Revocation is never a proof waiver. + +The merge path independently reads canonical head-specific CI and contract +runs, complete attempt/job inventories, structured validated selections, +intentional skips, matrix partitions and actual checkout logs. Missing or +failed selected jobs, mismatched receipts/checkouts, stale live identities, +missing issue milestones or wrong lease ownership reject before merging. +Immediately before the matching-head squash it rechecks the live exact +candidate and reservation. It then verifies the actual squash parent/tree, +main ancestry and PR/issue milestones and releases ownership. Exit 2 after a +successful squash explicitly leaves automatic main proof to the existing +actual-job/checkout reconciliation procedure; even a green run summary is not +claimed as completion. Failures exit 1, with best-effort owned cleanup. + +This is mandatory cooperative procedure, not an atomic GitHub branch lock. +Direct API/UI merges under existing permissions can bypass it or race the +final read; actual squash checks still own reconciliation. No protection, +permission, provider or native merge-queue change is introduced. Native stack +candidates remain unsupported by this command pending separately qualified +prefix integration; do not flatten them into an ordinary receipt or bypass +reservation policy. A reservation never qualifies several layers at once. + +Offline controls use real concurrent remote Git pushes, actual executable +merge sentinels, and isolated authoritative-clock fixtures for expiry, +automatic renewal, cancellation, revocation and the hard cap. They establish +local behavior only. Hosted integration qualification and measured savings +remain outstanding on #2111: historical invalidations and cancellation costs +are preserved there, not relabeled as a controlled speedup. A runner queue +longer than the hard cap cannot be solved by renewal or an implicit extension. + ## Historical measurement and upstream guidance The following guidance records the paid-provider investigation which motivated diff --git a/docs/process/github.md b/docs/process/github.md index c36130631..e41acd269 100644 --- a/docs/process/github.md +++ b/docs/process/github.md @@ -307,6 +307,21 @@ its last blocker closes (the `project-sync` workflow does it for a readable `Blocked by` line), and a duplicate is closed into one survivor. The `github` skill's "Keeping an issue true" has the procedure. +## Coordinating confirmation and ordinary merges + +Before expensive candidate confirmation and every ordinary main merge, use the +shared exact-candidate reservation command, `bun scripts/integrate.ts`. +`confirm --issues owner/repo#N,...` acquires, renews and verifies the +ordinary candidate before a matching-head squash. `inspect` shows the current +owner, PR/head/base, expiry and hard cap. Separate acquisition requires explicit +minute renewal and an explicit lease ID for `merge`; release on abandonment. +A busy result is bounded, not a queue. Maintainer revocation requires a reason +and records actor, time and former candidate. No override waives proof or +milestones. Read [the decision and command lifecycle](../decisions/ci-utility.md#exact-candidate-integration-reservation) +for the five-minute lease, sixty-minute cap, supported candidates, actual +squash/main verification and direct-merge race limits. This cooperative policy +changes no GitHub protection or merge permissions. + ## Stacked pull requests Use a stack when independently reviewable slices depend on each other. A single diff --git a/scripts/ci-proof.ts b/scripts/ci-proof.ts index 926f87ccf..83fc6d4ab 100644 --- a/scripts/ci-proof.ts +++ b/scripts/ci-proof.ts @@ -90,6 +90,7 @@ if (import.meta.main) { const problems = proofProblems(needs, retained); if (problems.length) throw new Error(problems.join("\n")); for (const category of retained) console.log(`Validated equivalent ${category} proof; original run/job/checkout identity remains in the ledger.`); + console.log(`BRAIN_SELECTED_PROOF_RECEIPT ${JSON.stringify({ selections: Object.fromEntries(PROOF_JOBS.map(name => [name, needs.changeset.outputs[name]])), results: Object.fromEntries(PROOF_JOBS.map(name => [name, needs[name]?.result])), retained: [...retained], unitShards: needs.changeset.outputs.unit_shards ?? null })}`); console.log("All selected hosted proof passed; remaining skips are planner-intended."); } else { const root = resolve(import.meta.dir, ".."); diff --git a/scripts/integrate.ts b/scripts/integrate.ts new file mode 100644 index 000000000..f958cba8a --- /dev/null +++ b/scripts/integrate.ts @@ -0,0 +1,175 @@ +/** Exact-candidate reservation lifecycle and cooperative, proof-checked merge. */ +import { randomUUID } from "node:crypto"; +import { scanText } from "./check-leakage"; +import { GitReservationStore, assertLease, liveLease, reservationServerTime, reservationTransition, RENEW_MS, sameCandidate, + type Candidate, type Operation, type Lease } from "./integration-reservation"; +import { latestIntegrationRun, verifyIntegrationProof } from "./integration-proof"; +import type { API } from "./proof-reuse"; + +async function gh(...args: string[]): Promise { + const child = Bun.spawn(["gh", ...args], { stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (code !== 0) throw new Error(err.trim()); + return out.trim(); +} + +if (import.meta.main) { + let own: Lease | undefined, store: GitReservationStore | undefined; + let actor = 0, repository = ""; + let timer: ReturnType | undefined, pending = Promise.resolve(), lost: unknown; + const clock = async () => reservationServerTime(await gh("api", "--include", "-H", "Cache-Control: no-cache", + `repos/${repository}/git/ref/heads/main?integration_clock=${randomUUID()}`)); + const api: API = { async json(path) { return JSON.parse(await gh("api", `repos/${repository}/${path}`)); }, + async log(id) { return gh("api", "--allow-escape-sequences", `repos/${repository}/actions/jobs/${id}/logs`); } }; + async function candidate(pr: number): Promise { + const live = await api.json(`pulls/${pr}`), base = await api.json("git/ref/heads/main"); + if (live.state !== "open" || live.stack || live.base?.ref !== "main" || live.head?.repo?.full_name !== repository || + live.base?.repo?.full_name !== repository || live.base?.sha !== base.object?.sha || + !/^[a-f0-9]{40}$/.test(live.head?.sha ?? "") || !/^[a-f0-9]{40}$/.test(base.object?.sha ?? "")) throw new Error("An open ordinary exact-main candidate is required; refresh stale or unsupported native candidates"); + return { repository: `https://github.com/${repository}`, pr, head: live.head.sha, base: base.object.sha }; + } + async function change(operation: Operation) { + for (let attempt = 0; attempt < 3; attempt++) { + const snapshot = store!.read(), next = reservationTransition(snapshot.state, operation, actor, await clock()); + if (scanText("coordination", JSON.stringify(next)).length) throw new Error("Public coordination state failed the leakage gate"); + if (store!.publish(snapshot, next)) return next; + await Bun.sleep(100 * 2 ** attempt); + } + throw new Error("Concurrent coordination update; bounded retries exhausted"); + } + async function renew() { + if (!own) throw new Error("No owned reservation"); + if (!sameCandidate(await candidate(own.candidate.pr), own.candidate)) throw new Error("Candidate head/base changed; reacquisition and new confirmation required"); + const state = await change({ kind: "renew", id: own.id }); own = state.active!; + } + async function acquire(desired: Candidate) { + for (let attempt = 0; attempt < 3; attempt++) { + try { return (await change({ kind: "acquire", candidate: desired })).active!; } + catch (error) { + if (!String(error).includes("Integration busy:") || attempt === 2) throw error; + console.error(String(error)); await Bun.sleep(1000 * 2 ** attempt); + if (!sameCandidate(await candidate(desired.pr), desired)) throw new Error("Candidate changed while waiting for integration ownership"); + } + } + throw new Error("Bounded acquisition exhausted"); + } + function heartbeat() { + timer = setInterval(() => { pending = pending.then(async () => { if (!lost) try { await renew(); } catch (error) { lost = error; } }); return pending; }, RENEW_MS); + } + async function stopHeartbeat() { if (timer) clearInterval(timer); timer = undefined; await pending; } + async function milestones(pr: number, issueRefs: string[]) { + const live = await api.json(`pulls/${pr}`); + if (!live.milestone?.title) throw new Error("PR release milestone required"); + const closing = JSON.parse(await gh("pr", "view", String(pr), "--repo", repository, "--json", "closingIssuesReferences")).closingIssuesReferences; + const required: string[] = closing.map((issue: { url: string }) => { + const match = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)$/.exec(issue.url); + if (!match) throw new Error("Unknown closing-issue identity"); return `${match[1]}#${match[2]}`; + }); + if (!issueRefs.length || required.some(ref => !issueRefs.includes(ref))) throw new Error("Explicit delivered/advanced issues must include every closing issue"); + for (const ref of issueRefs) { + const [repo, number] = ref.split("#"), issue = JSON.parse(await gh("api", `repos/${repo}/issues/${number}`)); + if (issue.milestone?.title !== live.milestone.title) throw new Error(`Release milestone mismatch: ${ref}`); + } + } + async function merge(issueRefs: string[]) { + if (!own) throw new Error("Explicit owned reservation required"); + heartbeat(); + const desired = own.candidate; + const proof = await verifyIntegrationProof(store!.root, repository, desired, api, sha => { store!.git(["fetch", "origin", sha]); }); + await milestones(desired.pr, issueRefs); + await stopHeartbeat(); + if (lost) throw lost; + if (!sameCandidate(await candidate(desired.pr), desired)) throw new Error("Candidate changed before merge"); + assertLease(store!.read().state, own.id, actor, desired, await clock()); + await gh("pr", "merge", String(desired.pr), "--repo", repository, "--squash", "--match-head-commit", desired.head); + const merged = await api.json(`pulls/${desired.pr}`); + if (!merged.merged || !merged.merge_commit_sha) throw new Error("Merge not complete; no delivery claim"); + const commit = await api.json(`git/commits/${merged.merge_commit_sha}`); + if (commit.parents?.length !== 1 || commit.parents[0]?.sha !== desired.base || commit.tree?.sha !== proof.checkout.tree) throw new Error("Actual squash parent/tree differ; reconcile actual main before claiming completion"); + store!.git(["fetch", "origin", "main"]); store!.git(["merge-base", "--is-ancestor", merged.merge_commit_sha, "origin/main"]); + await milestones(desired.pr, issueRefs); + await change({ kind: "release", id: own.id }); own = undefined; + const main = await api.json(`actions/workflows/ci.yml/runs?event=push&head_sha=${merged.merge_commit_sha}&per_page=10`); + const mainProof = "pending; inspect actual main jobs before claiming full completion"; + const observedMainConclusion = main.workflow_runs?.[0]?.conclusion ?? null; + console.log(JSON.stringify({ merged: true, sha: merged.merge_commit_sha, candidateRun: proof.run, mainProof, observedMainConclusion })); + process.exitCode = 2; // A run summary alone never completes actual main proof reconciliation. + } + try { + const [command, target, ...options] = process.argv.slice(2), values = new Map(); + for (let index = 0; index < options.length; index += 2) { + const key = options[index]!, value = options[index + 1]; + if (!["--issues", "--lease"].includes(key) || !value || value.startsWith("--") || values.has(key)) throw new Error("Invalid integration options"); + values.set(key, value); + } + const issues = values.has("--issues") ? values.get("--issues")!.split(",") : []; + if (issues.some(value => !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[1-9]\d*$/.test(value)) || issues.length > 20) throw new Error("Invalid delivered issue identities"); + repository = await gh("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"); + const user = JSON.parse(await gh("api", "user")); actor = user.id; + store = new GitReservationStore(process.cwd()); + const remote = store.git(["remote", "get-url", "origin"]).replace(/^git@github.com:/, "https://github.com/").replace(/\.git$/, ""); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || remote !== `https://github.com/${repository}`) throw new Error("Authenticated repository and origin differ"); + if (command === "inspect") { + const snapshot = store.read(), now = await clock(); + console.log(JSON.stringify({ ...snapshot, live: liveLease(snapshot.state.active, now), serverTime: now })); + } else if (command === "revoke") { + const permission = await api.json(`collaborators/${encodeURIComponent(user.login)}/permission`); + console.log(JSON.stringify(await change({ kind: "revoke", reason: target ?? "", maintainer: permission.permission === "admin" || ["admin", "maintain"].includes(permission.role_name) }))); + } else if (command === "release" || command === "renew") { + const state = store.read().state; + if (!state.active || state.active.id !== target || state.active.actor !== actor) throw new Error("Lease identity does not belong to this invocation"); + own = state.active; + if (command === "renew") { await renew(); console.log(JSON.stringify(own)); own = undefined; } + else { console.log(JSON.stringify(await change({ kind: "release", id: own.id }))); own = undefined; } + } else if (["acquire", "confirm", "merge"].includes(command ?? "")) { + if (!/^[1-9]\d*$/.test(target ?? "") || !Number.isSafeInteger(Number(target))) throw new Error("A PR number is required"); + if (command === "merge" && values.has("--lease")) { + const current = store.read().state.active; + if (current && current.id === values.get("--lease") && current.actor === actor && current.candidate.pr === Number(target) && current.candidate.repository === `https://github.com/${repository}`) own = current; + } + const desired = await candidate(Number(target)); + if (command === "merge") { + const lease = values.get("--lease"); + if (!lease) throw new Error("Explicit --lease required"); + const state = store.read().state; + if (state.active?.id === lease && state.active.actor === actor) own = state.active; + assertLease(state, lease, actor, desired, await clock()); + await merge(issues); + } else { + own = await acquire(desired); + console.log(JSON.stringify({ acquired: own })); + if (command === "acquire") own = undefined; // Caller retains the explicit id. + else { + heartbeat(); + const previousRun = await latestIntegrationRun(api, "ci.yml", desired); + const live = await api.json(`pulls/${desired.pr}`); + if (!sameCandidate(await candidate(desired.pr), desired)) throw new Error("Candidate changed before confirmation request"); + assertLease(store.read().state, own.id, actor, desired, await clock()); + if (!live.draft) await gh("pr", "ready", String(desired.pr), "--repo", repository, "--undo"); + await gh("pr", "ready", String(desired.pr), "--repo", repository); + while (!lost) { + const run = await latestIntegrationRun(api, "ci.yml", desired); + if (run?.id > (previousRun?.id ?? 0) && run.status === "completed") { + const jobs = await api.json(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`); + const metadata = jobs.jobs?.find((job: { name: string }) => job.name === "changeset"); + const log = metadata ? await api.log(metadata.id) : ""; + if (/"cadence":\s*"(?:draft|feedback)"/.test(log)) { await Bun.sleep(RENEW_MS); continue; } + if (run.conclusion !== "success") throw new Error(`Candidate proof ${run.conclusion}; reservation released without retry`); + await stopHeartbeat(); await merge(issues); break; + } + await Bun.sleep(RENEW_MS); + } + if (lost) throw lost; + } + } + } else throw new Error("usage: bun scripts/integrate.ts inspect | acquire/confirm/merge [--lease id] [--issues owner/repo#N,...] | renew/release | revoke "); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; + } finally { + await stopHeartbeat(); + if (own && store) try { + const current = store.read().state.active; + if (current?.id === own.id && current.actor === actor) await change({ kind: "release", id: own.id }); + } catch (error) { console.error(`Owned reservation cleanup unfinished: ${String(error)}`); process.exitCode = 1; } + } +} diff --git a/scripts/integration-proof.ts b/scripts/integration-proof.ts new file mode 100644 index 000000000..b590192a5 --- /dev/null +++ b/scripts/integration-proof.ts @@ -0,0 +1,70 @@ +/** The shared merge command consumes actual proof, never a green summary alone. */ +import { validateCandidate } from "./candidate-proof"; +import { checkout, type Checkout } from "./proof-inputs"; +import type { Candidate } from "./integration-reservation"; +import type { API } from "./proof-reuse"; +import { PROOF_JOBS, proofProblems } from "./ci-proof"; +import { shardNumbers } from "./proof-shards"; + +export function checkoutFromJob(log: string): string { + const lines = log.replace(/\x1b\[[0-9;]*m/g, "").split("\n"); + const index = lines.findIndex(line => line.includes("git log -1 --format=%H")); + const value = index < 0 ? undefined : /\b([a-f0-9]{40})\s*$/.exec(lines[index + 1] ?? "")?.[1]; + if (!value) throw new Error("Actual selected job checkout is missing or ambiguous"); + return value; +} + +export async function latestIntegrationRun(api: API, workflow: string, candidate: Candidate): Promise { + const result = await api.json(`actions/workflows/${workflow}/runs?event=pull_request&head_sha=${candidate.head}&per_page=10`); + if (!Array.isArray(result.workflow_runs)) throw new Error("Missing canonical workflow run inventory"); + const runs: any[] = result.workflow_runs; + return runs.filter(run => run.event === "pull_request" && run.head_sha === candidate.head && + run.repository?.html_url === candidate.repository && run.head_repository?.html_url === candidate.repository && + run.pull_requests?.some((pr: { number: number }) => pr.number === candidate.pr)).sort((a, b) => b.id - a.id)[0]; +} + +export async function verifyIntegrationProof(root: string, repository: string, candidate: Candidate, api: API, + fetchCheckout: (sha: string) => void): Promise<{ run: number; checkout: Checkout }> { + const run = await latestIntegrationRun(api, "ci.yml", candidate); + const contract = await latestIntegrationRun(api, "contract.yml", candidate); + if (run?.status !== "completed" || run.conclusion !== "success" || contract?.status !== "completed" || contract.conclusion !== "success") throw new Error("Complete current-candidate CI and contract proof required"); + const inventory = await api.json(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`); + const jobs: Array<{ id: number; name: string; status: string; conclusion: string; run_id: number; head_sha: string }> = inventory.jobs; + if (!Array.isArray(jobs) || inventory.total_count !== jobs.length || !jobs.length || jobs.length > 30 || + jobs.some(job => !Number.isSafeInteger(job.id) || job.run_id !== run.id || job.head_sha !== candidate.head || job.status !== "completed" || !["success", "skipped"].includes(job.conclusion))) throw new Error("Incomplete or failed selected job inventory"); + const proofs = jobs.filter(job => job.name === "proof" && job.conclusion === "success"); + if (proofs.length !== 1) throw new Error("Missing complete candidate aggregate"); + const proofLog = await api.log(proofs[0].id); + const receipts = proofLog.split("\n").filter(line => line.includes("BRAIN_CANDIDATE_RECEIPT ")); + if (receipts.length !== 1 || !proofLog.includes("All selected hosted proof passed; remaining skips are planner-intended.")) throw new Error("Missing source-bound selected-proof receipt"); + const selectedLines = proofLog.split("\n").filter(line => line.includes("BRAIN_SELECTED_PROOF_RECEIPT ")); + if (selectedLines.length !== 1) throw new Error("Fresh structured selected-proof receipt required"); + const selected = JSON.parse(selectedLines[0]!.split("BRAIN_SELECTED_PROOF_RECEIPT ")[1]!) as { + selections: Record; results: Record; retained: string[]; unitShards: string | null; + }; + if (!Array.isArray(selected.retained) || selected.retained.some(name => !["browser", "layout"].includes(name))) throw new Error("Unsupported retained proof identity"); + const needs = { changeset: { result: "success", outputs: selected.selections }, ...Object.fromEntries(PROOF_JOBS.map(name => [name, { result: selected.results[name] }])) }; + if (proofProblems(needs, new Set(selected.retained)).length) throw new Error("Selected proof did not satisfy the aggregate policy"); + const expected = ["changeset:success", "proof:success"]; + for (const name of PROOF_JOBS) { + const fresh = selected.selections[name] === "true" && !selected.retained.includes(name); + if (fresh && (name === "browser" || name === "layout")) expected.push(...shardNumbers(name).map(shard => `${name} (${shard}):success`)); + else if (fresh && name === "unit") { + const shards: unknown = JSON.parse(selected.unitShards ?? "null"); + if (!Array.isArray(shards) || !shards.length || shards.length > 4 || shards.some((value, index) => value !== index + 1)) throw new Error("Missing actual unit partition identity"); + expected.push(...shards.map(index => `unit (${index}):success`)); + } else expected.push(`${name}:${fresh ? "success" : "skipped"}`); + } + if (JSON.stringify(jobs.map(job => `${job.name}:${job.conclusion}`).sort()) !== JSON.stringify(expected.sort())) throw new Error("Actual selected job inventory differs from the validated selection"); + const receipt = JSON.parse(receipts[0]!.split("BRAIN_CANDIDATE_RECEIPT ")[1]!) as { event: string; head: string; base: string; checkout: Checkout }; + if (!["opened", "reopened", "ready_for_review"].includes(receipt.event) || receipt.head !== candidate.head || receipt.base !== candidate.base) throw new Error("Proof does not bind this requested candidate"); + for (const job of jobs.filter(job => job.conclusion === "success")) { + if (checkoutFromJob(job.id === proofs[0].id ? proofLog : await api.log(job.id)) !== receipt.checkout.sha) throw new Error("Selected job ran a different checkout"); + } + fetchCheckout(receipt.checkout.sha); + if (JSON.stringify(checkout(root, receipt.checkout.sha)) !== JSON.stringify(receipt.checkout)) throw new Error("Local immutable checkout differs from job evidence"); + await validateCandidate(root, { action: receipt.event, pull_request: { number: candidate.pr, draft: false, + head: { sha: candidate.head }, base: { ref: "main", sha: candidate.base } } }, + { GITHUB_EVENT_NAME: "pull_request", GITHUB_SHA: receipt.checkout.sha, GITHUB_REPOSITORY: repository }, api, receipt.checkout); + return { run: run.id, checkout: receipt.checkout }; +} diff --git a/scripts/integration-reservation.ts b/scripts/integration-reservation.ts new file mode 100644 index 000000000..d5716406c --- /dev/null +++ b/scripts/integration-reservation.ts @@ -0,0 +1,118 @@ +/** Cooperative integration ownership: remote Git CAS, never a branch lock. */ +import { randomBytes } from "node:crypto"; + +export const RESERVATION_REF = "refs/heads/brain-integration-reservation"; +export const LEASE_MS = 5 * 60_000, RENEW_MS = 60_000, MAX_MS = 60 * 60_000; +export interface Candidate { repository: string; pr: number; head: string; base: string; } +export interface Lease { id: string; actor: number; candidate: Candidate; acquired: number; expires: number; cap: number; } +export interface ReservationState { + version: 1; updated: number; active: Lease | null; + last: { action: "acquire" | "renew" | "release" | "revoke"; actor: number; at: number; previous: Lease | null; reason?: string } | null; +} +export interface Snapshot { oid: string | null; state: ReservationState; } +export type Operation = { kind: "acquire"; candidate: Candidate; id?: string } | + { kind: "renew"; id: string } | { kind: "release"; id: string } | { kind: "revoke"; reason: string; maintainer: boolean }; +const hash = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value); +export const emptyReservation = (): ReservationState => ({ version: 1, updated: 0, active: null, last: null }); +export const sameCandidate = (a: Candidate, b: Candidate): boolean => a.repository === b.repository && a.pr === b.pr && a.head === b.head && a.base === b.base; +export const liveLease = (lease: Lease | null, now: number): lease is Lease => !!lease && now < lease.expires && now < lease.cap; + +export function reservationServerTime(response: string): number { + const headers = response.split(/\r?\n\r?\n/, 1)[0]!; + const dates = [...headers.matchAll(/^date:\s*(.+)$/gim)]; + const age = /^age:\s*(\d+)/im.exec(headers); + const now = dates.length === 1 ? Date.parse(dates[0]![1]!.trim()) : NaN; + if (!/^HTTP\/\S+\s+200\b/.test(headers) || !Number.isFinite(now) || (age && Number(age[1]) > 0)) throw new Error("Fresh authoritative GitHub server clock unavailable"); + return now; +} + +export function parseReservation(text: string): ReservationState { + if (text.length > 16000) throw new Error("Unbounded shared reservation state"); + const state = JSON.parse(text) as ReservationState; + if (state?.version !== 1 || !Number.isSafeInteger(state.updated) || state.updated < 0 || !Object.hasOwn(state, "active") || !Object.hasOwn(state, "last")) throw new Error("Invalid shared reservation state"); + const validActor = (value: unknown) => Number.isSafeInteger(value) && Number(value) > 0; + const validLease = (lease: Lease | null): boolean => lease === null || !!lease && typeof lease === "object" && + /^[a-f0-9]{32}$/.test(lease.id) && validActor(lease.actor) && + Number.isSafeInteger(lease.candidate?.pr) && lease.candidate.pr > 0 && /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(lease.candidate.repository) && + hash(lease.candidate.head) && hash(lease.candidate.base) && + [lease.acquired, lease.expires, lease.cap].every(value => Number.isSafeInteger(value) && value >= 0) && lease.acquired <= state.updated && lease.cap === lease.acquired + MAX_MS && + lease.expires <= Math.min(state.updated + LEASE_MS, lease.cap) && lease.expires > lease.acquired; + if (!validLease(state.active)) throw new Error("Invalid shared reservation lease"); + const last = state.last; + if (last !== null && (!last || typeof last !== "object" || !["acquire", "renew", "release", "revoke"].includes(last.action) || + !validActor(last.actor) || last.at !== state.updated || !validLease(last.previous) || + (last.action === "revoke" && (typeof last.reason !== "string" || !last.reason.trim() || last.reason.length > 1000 || !last.previous || state.active !== null)))) throw new Error("Invalid shared reservation audit"); + if (last === null && (state.active !== null || state.updated !== 0)) throw new Error("Missing shared reservation audit"); + return state; +} + +export function assertLease(state: ReservationState, id: string, actor: number, candidate: Candidate, now: number): Lease { + const lease = state.active; + if (!liveLease(lease, now) || lease.id !== id || lease.actor !== actor || !sameCandidate(lease.candidate, candidate)) throw new Error("Missing, expired, superseded, wrong-owner or mismatched candidate reservation"); + return lease; +} + +export function reservationTransition(state: ReservationState, operation: Operation, actor: number, now: number): ReservationState { + if (!Number.isSafeInteger(actor) || actor <= 0 || !Number.isSafeInteger(now) || now < state.updated) throw new Error("Invalid actor or backwards/unavailable authoritative clock"); + const previous = state.active; + let active = previous; + if (operation.kind === "acquire") { + if (liveLease(previous, now)) throw new Error(`Integration busy: ${JSON.stringify(previous)}`); + active = { id: operation.id ?? randomBytes(16).toString("hex"), actor, candidate: operation.candidate, acquired: now, expires: now + LEASE_MS, cap: now + MAX_MS }; + } else if (operation.kind === "renew") { + if (!previous) throw new Error("No reservation to renew"); + assertLease(state, operation.id, actor, previous.candidate, now); + active = { ...previous, expires: Math.min(now + LEASE_MS, previous.cap) }; + } else if (operation.kind === "release") { + // An expired owner may clean up its own lease, never a successor's. + if (!previous || previous.id !== operation.id || previous.actor !== actor) throw new Error("Reservation release does not own the current lease"); + active = null; + } else { + if (!operation.maintainer || !operation.reason.trim() || operation.reason.length > 1000) throw new Error("Maintainer revocation requires an explicit bounded reason"); + if (!previous) throw new Error("No reservation to revoke"); + active = null; + } + return parseReservation(JSON.stringify({ version: 1, updated: now, active, + last: { action: operation.kind, actor, at: now, previous, ...(operation.kind === "revoke" ? { reason: operation.reason } : {}) } })); +} + +/** Every published commit has the inspected remote commit as its parent. */ +export class GitReservationStore { + constructor(readonly root: string, readonly remote = "origin", readonly ref = RESERVATION_REF) {} + git(args: string[], input?: string): string { + const result = Bun.spawnSync(["git", ...args], { cwd: this.root, stdout: "pipe", stderr: "pipe", + stdin: input === undefined ? "ignore" : new TextEncoder().encode(input), env: { ...process.env, + GIT_AUTHOR_NAME: "brain-kit coordination", GIT_AUTHOR_EMAIL: "coordination@example.invalid", + GIT_COMMITTER_NAME: "brain-kit coordination", GIT_COMMITTER_EMAIL: "coordination@example.invalid" } }); + if (result.exitCode !== 0) throw new Error(new TextDecoder().decode(result.stderr)); + return new TextDecoder().decode(result.stdout).trim(); + } + remoteOid(): string | null { + const output = this.git(["ls-remote", "--refs", this.remote, this.ref]); + if (!output) return null; + const [oid, ref, ...extra] = output.split(/\s+/); + if (!hash(oid) || ref !== this.ref || extra.length) throw new Error("Invalid remote reservation ref"); + return oid; + } + read(): Snapshot { + const oid = this.remoteOid(); + if (!oid) return { oid, state: emptyReservation() }; + this.git(["fetch", "--no-tags", this.remote, oid]); + const tree = this.git(["ls-tree", oid]); + if (!/^100644 blob [a-f0-9]{40}\treservation\.json$/.test(tree)) throw new Error("Unknown shared coordination tree"); + return { oid, state: parseReservation(this.git(["show", `${oid}:reservation.json`])) }; + } + publish(snapshot: Snapshot, state: ReservationState): boolean { + const content = JSON.stringify(parseReservation(JSON.stringify(state))) + "\n"; + const blob = this.git(["hash-object", "-w", "--stdin"], content); + const tree = this.git(["mktree"], `100644 blob ${blob}\treservation.json\n`); + const oid = this.git(["commit-tree", tree, ...(snapshot.oid ? ["-p", snapshot.oid] : []), "-m", `integration: ${state.last?.action ?? "state"}`]); + try { + this.git(["push", "--porcelain", `--force-with-lease=${this.ref}:${snapshot.oid ?? ""}`, this.remote, `${oid}:${this.ref}`]); + return true; + } catch (error) { + if (this.remoteOid() !== snapshot.oid) return false; + throw error; + } + } +} diff --git a/tests/hosted-proof.test.ts b/tests/hosted-proof.test.ts index f051f13f4..ca963f0bb 100644 --- a/tests/hosted-proof.test.ts +++ b/tests/hosted-proof.test.ts @@ -57,7 +57,15 @@ test("emitted aggregate executable fails a cancelled selected job and accepts it const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); expect(code, err).toBe(result === "success" ? 0 : 1); if (result === "cancelled") expect(err).toContain("layout: selected proof did not pass (cancelled)"); - else expect(out).toContain("All selected hosted proof passed"); + else { + expect(out).toContain("All selected hosted proof passed"); + const line = out.split("\n").find(line => line.startsWith("BRAIN_SELECTED_PROOF_RECEIPT ")); + expect(line, "MERGE_CONSUMER_RECEIVES_VALIDATED_SELECTED_INVENTORY").toBeDefined(); + expect(JSON.parse(line!.slice("BRAIN_SELECTED_PROOF_RECEIPT ".length))).toEqual({ + selections: Object.fromEntries(PROOF_JOBS.map(name => [name, "true"])), + results: Object.fromEntries(PROOF_JOBS.map(name => [name, "success"])), retained: [], unitShards: null, + }); + } } }); @@ -196,6 +204,14 @@ test("emitted aggregate refuses skipped or missing selected native host job", as const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); expect(code, "EMITTED_NATIVE_HOST_REQUIRED").toBe(state === "success" ? 0 : 1); if (state !== "success") expect(err).toContain(`runtime_host: selected proof did not pass (${state})`); - else expect(out).toContain("All selected hosted proof passed"); + else { + expect(out).toContain("All selected hosted proof passed"); + const line = out.split("\n").find(line => line.startsWith("BRAIN_SELECTED_PROOF_RECEIPT ")); + expect(line, "MERGE_CONSUMER_RECEIVES_VALIDATED_SELECTED_INVENTORY").toBeDefined(); + expect(JSON.parse(line!.slice("BRAIN_SELECTED_PROOF_RECEIPT ".length))).toEqual({ + selections: Object.fromEntries(PROOF_JOBS.map(name => [name, "true"])), + results: Object.fromEntries(PROOF_JOBS.map(name => [name, "success"])), retained: [], unitShards: null, + }); + } } }); diff --git a/tests/integration-command.test.ts b/tests/integration-command.test.ts new file mode 100644 index 000000000..7337e1c2a --- /dev/null +++ b/tests/integration-command.test.ts @@ -0,0 +1,140 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { GitReservationStore, emptyReservation, reservationTransition, MAX_MS } from "../scripts/integration-reservation"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const NOW = Date.parse("2026-07-12T00:00:00Z"); +async function scenario(mode: string) { + const dir = mkdtempSync(join(tmpdir(), "odysseus-integration-command-")); dirs.push(dir); + const root = join(dir, "client"), remote = join(dir, "remote.git"), bin = join(dir, "bin"); + mkdirSync(root); mkdirSync(bin); + const realGit = Bun.which("git")!; + const setup = (cwd: string, ...args: string[]) => { + const result = Bun.spawnSync([realGit, ...args], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env, + GIT_AUTHOR_NAME: "Odysseus", GIT_AUTHOR_EMAIL: "odysseus@example.invalid", GIT_COMMITTER_NAME: "Odysseus", GIT_COMMITTER_EMAIL: "odysseus@example.invalid" } }); + expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); return new TextDecoder().decode(result.stdout).trim(); + }; + setup(dir, "init", "--bare", remote); setup(root, "init"); setup(root, "remote", "add", "origin", remote); + writeFileSync(join(root, "README.md"), "Odysseus sails.\n"); setup(root, "add", "."); + const base = setup(root, "commit-tree", setup(root, "write-tree"), "-m", "base"); + setup(root, "update-ref", "HEAD", base); + writeFileSync(join(root, "raft.md"), "The crossing.\n"); setup(root, "add", "."); + const tree = setup(root, "write-tree"), head = setup(root, "commit-tree", tree, "-p", base, "-m", "head"); + const synthetic = setup(root, "commit-tree", tree, "-p", base, "-p", head, "-m", "tested merge"); + const squash = setup(root, "commit-tree", tree, "-p", base, "-m", "actual squash"); + setup(root, "push", "origin", `${base}:refs/heads/main`, `${synthetic}:refs/heads/proof-fixture`, `${squash}:refs/heads/squash-fixture`); + const candidate = { repository: "https://github.com/odysseus/brain-kit", pr: 7, head, base }; + const id = "1".repeat(32), store = new GitReservationStore(root), snapshot = store.read(); + const state = mode === "missing" || mode.startsWith("confirm-") ? emptyReservation() : reservationTransition(emptyReservation(), { kind: "acquire", candidate, id }, mode === "wrong-owner" ? 202 : 101, mode === "expired" ? NOW - 300_000 : NOW); + expect(store.publish(snapshot, state)).toBe(true); + const sentinel = join(dir, "merged"), apiState = join(dir, "api.json"); + writeFileSync(apiState, JSON.stringify({ mode, revoked: false, merged: false, now: NOW, requested: false, draft: false })); + const library = resolve(import.meta.dir, "../scripts/integration-reservation.ts"); + const shim = join(bin, "git"); + writeFileSync(shim, `#!${process.execPath} + const args=process.argv.slice(2);if(args.join(" ")==="remote get-url origin")console.log("https://github.com/odysseus/brain-kit.git"); + else {const result=Bun.spawnSync([${JSON.stringify(realGit)},...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});process.exit(result.exitCode);}`); chmodSync(shim, 0o755); + const gh = join(bin, "gh"), actual = { sha: synthetic, tree, parents: [base, head] }; + writeFileSync(gh, `#!${process.execPath} + import {readFileSync,writeFileSync} from "node:fs"; + import {GitReservationStore,reservationTransition} from ${JSON.stringify(library)}; + const args=process.argv.slice(2), file=${JSON.stringify(apiState)}, s=JSON.parse(readFileSync(file,"utf8")); + const candidate=${JSON.stringify(candidate)}, actual=${JSON.stringify(actual)}, root=${JSON.stringify(root)}, remote=${JSON.stringify(remote)}; + const repo="odysseus/brain-kit", base=candidate.base, head=candidate.head, squash=${JSON.stringify(squash)}; + const categories=["verify","pack","unit","browser","layout","captures","runtime","runtime_host","endurance"]; + const selected={selections:Object.fromEntries(categories.map(x=>[x,"false"])),results:Object.fromEntries(categories.map(x=>[x,"skipped"])),retained:[],unitShards:null}; + if(s.mode.startsWith("browser-")){selected.selections.browser="true";selected.results.browser="success";} + if(s.mode==="missing-selection")delete selected.selections.endurance; + if(s.mode==="unexpected-skip"){selected.selections.endurance="true";selected.results.endurance="success";} + const job=(id,name,conclusion="success")=>({id,name,status:"completed",conclusion,run_id:91,head_sha:head}); + const jobs=[job(1,"changeset"),job(2,"proof"),...categories.flatMap((name,index)=>name==="browser"&&s.mode.startsWith("browser-") + ?[1,2,3].filter(shard=>s.mode!=="browser-missing-third"||shard!==3).map(shard=>job(20+shard,"browser ("+shard+")")) + :[job(index+3,name,"skipped")])]; + const run=(id,workflow)=>({id,run_attempt:1,event:"pull_request",head_sha:head,status:s.mode.startsWith("confirm-")&&workflow==="ci"&&s.requested&&(s.mode==="confirm-cap"||s.now===${NOW})?"queued":"completed",conclusion:s.mode==="proof-failure"&&workflow==="ci"?"failure":s.mode==="confirm-cancel"&&s.requested&&workflow==="ci"?"cancelled":"success",repository:{html_url:candidate.repository},head_repository:{html_url:candidate.repository},pull_requests:[{number:7}]}); + const checkout="[command]git log -1 --format=%H\\n2026-07-12T00:00:00Z "+(s.mode==="wrong-checkout"?"c".repeat(40):actual.sha)+"\\n"; + let value; + if(args[0]==="repo")value=repo; + else if(args[0]==="pr"&&args[1]==="view")value={closingIssuesReferences:[{url:"https://github.com/odysseus/brain-kit/issues/9"}]}; + else if(args[0]==="pr"&&args[1]==="ready"){s.draft=args.includes("--undo");if(!s.draft)s.requested=true;value="ready";} + else if(args[0]==="pr"&&args[1]==="merge") {writeFileSync(${JSON.stringify(sentinel)},"merged");s.merged=true;const result=Bun.spawnSync([${JSON.stringify(realGit)},"--git-dir",remote,"update-ref","refs/heads/main",squash]);if(result.exitCode)throw new Error("Fixture main update failed");value="merged";} + else if(args[0]==="api") { + const path=args.find(x=>x.startsWith("repos/"))??args[1]; + if(args.includes("--include")){console.log("HTTP/2.0 200 OK\\nDate: "+new Date(s.now).toUTCString()+"\\n\\n{}");writeFileSync(file,JSON.stringify(s));process.exit(0);} + if(path==="user")value={id:101,login:"odysseus"}; + else if(path.endsWith("/pulls/7"))value={number:7,state:s.merged?"closed":"open",draft:s.draft,merged:s.merged,merge_commit_sha:squash,head:{sha:s.mode==="changed-head"?"e".repeat(40):head,repo:{full_name:repo}},base:{ref:"main",sha:base,repo:{full_name:repo}},milestone:{title:"0.44.0"}}; + else if(path.endsWith("git/ref/heads/main"))value={object:{sha:s.mode==="changed-base"?"d".repeat(40):base}}; + else if(path.includes("actions/workflows/ci.yml/runs?event=push"))value={workflow_runs:[]}; + else if(path.includes("actions/workflows/ci.yml/runs"))value={workflow_runs:[run(s.mode.startsWith("confirm-")&&!s.requested?90:91,"ci")]}; + else if(path.includes("actions/workflows/contract.yml/runs"))value={workflow_runs:[run(92,"contract")]}; + else if(path.includes("/attempts/1/jobs"))value={total_count:jobs.length,jobs}; + else if(path.endsWith("actions/jobs/1/logs"))value=checkout+'"cadence": "confirmation"'; + else if(path.endsWith("actions/jobs/2/logs")) { + if(s.mode==="revoked-during-proof"&&!s.revoked){const store=new GitReservationStore(root), snap=store.read();store.publish(snap,reservationTransition(snap.state,{kind:"revoke",maintainer:true,reason:"Abandon controlled fixture"},202,s.now));s.revoked=true;} + value=checkout+"BRAIN_CANDIDATE_RECEIPT "+JSON.stringify({event:"ready_for_review",head,base,checkout:actual})+"\\nBRAIN_SELECTED_PROOF_RECEIPT "+JSON.stringify(selected)+"\\nAll selected hosted proof passed; remaining skips are planner-intended.";} + else if(/actions\\/jobs\\/2[123]\\/logs$/.test(path))value=checkout; + else if(path.endsWith("git/commits/"+actual.sha))value={sha:actual.sha,tree:{sha:actual.tree},parents:actual.parents.map(sha=>({sha}))}; + else if(path.endsWith("git/commits/"+squash))value={sha:squash,tree:{sha:actual.tree},parents:[{sha:base}]}; + else if(path.endsWith("issues/9"))value={milestone:{title:s.mode==="wrong-milestone"?"0.43.1":"0.44.0"}}; + else throw new Error("Unexpected offline API "+path); + } else throw new Error("Unexpected offline gh "+JSON.stringify(args)); + writeFileSync(file,JSON.stringify(s));console.log(typeof value==="string"?value:JSON.stringify(value));`); chmodSync(gh, 0o755); + const preload = join(dir, "clock.ts"); + writeFileSync(preload, `import {readFileSync,writeFileSync} from "node:fs";const callbacks=new Map();let id=0; + const originalSet=globalThis.setInterval,originalClear=globalThis.clearInterval,originalSleep=Bun.sleep; + globalThis.setInterval=(callback,ms,...args)=>ms===60000?(callbacks.set(++id,callback),id):originalSet(callback,ms,...args); + globalThis.clearInterval=timer=>callbacks.has(timer)?callbacks.delete(timer):originalClear(timer); + Bun.sleep=async ms=>{if(ms!==60000)return originalSleep(ms);const file=${JSON.stringify(apiState)},state=JSON.parse(readFileSync(file,"utf8"));state.now+=ms;writeFileSync(file,JSON.stringify(state));for(const callback of callbacks.values())await callback();};`); + const child = Bun.spawn([process.execPath, ...(mode.startsWith("confirm-") ? ["--preload", preload] : []), resolve(import.meta.dir, "../scripts/integrate.ts"), mode.startsWith("confirm-") ? "confirm" : "merge", "7", ...(mode.startsWith("confirm-") ? [] : ["--lease", mode === "wrong-id" ? "2".repeat(32) : id]), "--issues", "odysseus/brain-kit#9"], { + cwd: root, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, out, err, dispatched: existsSync(sentinel), state: store.read().state }; +} + +test("shared merge executable rejects missing, stale, wrong-owner and changed-candidate authority before merge", async () => { + for (const mode of ["missing", "wrong-id", "wrong-owner", "expired", "changed-base", "proof-failure", "wrong-checkout", "wrong-milestone", "revoked-during-proof", "changed-head", "missing-selection", "unexpected-skip"]) { + const result = await scenario(mode); + expect(result.dispatched, `NO_MERGE_WITH_INVALID_AUTHORITY:${mode}`).toBe(false); + expect(result.code, result.err).toBe(1); + const expected = mode === "changed-base" ? "ordinary exact-main candidate" : mode === "proof-failure" ? "Complete current-candidate CI and contract proof required" + : mode === "changed-head" ? "mismatched candidate reservation" : mode === "missing-selection" ? "aggregate policy" : mode === "unexpected-skip" ? "Actual selected job inventory differs" : mode === "wrong-checkout" ? "Selected job ran a different checkout" : mode === "wrong-milestone" ? "Release milestone mismatch" : "Missing, expired, superseded"; + expect(result.err).toContain(expected); + } +}); + +test("shared merge executable preserves actual proof and squash identities and releases after delivery", async () => { + const result = await scenario("valid"); + expect(result.code, result.err).toBe(2); expect(result.dispatched).toBe(true); + expect(result.out).toContain('"merged":true'); expect(result.out).toContain("pending; inspect actual main jobs"); + expect(result.state.active).toBeNull(); expect(result.state.last?.action).toBe("release"); +}); + +test("confirmation executable renews automatically, releases cancellation and cannot renew beyond the cap", async () => { + for (const mode of ["confirm-cancel", "confirm-cap", "confirm-valid"]) { + const result = await scenario(mode); + expect(result.state.active, result.err).toBeNull(); + expect(result.dispatched).toBe(mode === "confirm-valid"); + expect(result.code, result.err).toBe(mode === "confirm-valid" ? 2 : 1); + if (mode === "confirm-cancel") expect(result.err).toContain("Candidate proof cancelled"); + if (mode === "confirm-cap") { + expect(result.state.last?.at).toBe(NOW + MAX_MS); + expect(result.state.last?.previous?.cap).toBe(NOW + MAX_MS); + expect(result.err).toContain("expired"); + } + } +}); + +// The delivered workflow has three browser jobs. Keep this independent fixture +// literal so a stale two-job verifier cannot bless incomplete selected proof. +test("shared merge executable requires all three delivered browser shards", async () => { + const complete = await scenario("browser-complete"); + expect(complete.code, `COMPLETE_SELECTED_BROWSER_INVENTORY: ${complete.err}`).toBe(2); + expect(complete.dispatched).toBe(true); + const missing = await scenario("browser-missing-third"); + expect(missing.dispatched, "NO_MERGE_WITH_MISSING_THIRD_BROWSER_SHARD").toBe(false); + expect(missing.code).toBe(1); + expect(missing.err).toContain("Actual selected job inventory differs"); +}); diff --git a/tests/integration-reservation.test.ts b/tests/integration-reservation.test.ts new file mode 100644 index 000000000..d869c0d16 --- /dev/null +++ b/tests/integration-reservation.test.ts @@ -0,0 +1,90 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { assertLease, emptyReservation, GitReservationStore, LEASE_MS, MAX_MS, RENEW_MS, + parseReservation, reservationServerTime, reservationTransition } from "../scripts/integration-reservation"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const candidate = { repository: "https://github.com/odysseus/brain-kit", pr: 7, head: "a".repeat(40), base: "b".repeat(40) }; +const acquire = (id = "1".repeat(32)) => ({ kind: "acquire" as const, candidate, id }); + +test("renewal preserves the hard cap, missed heartbeats expire, and stale owners cannot revive or alter successors", () => { + expect(RENEW_MS).toBe(60_000); expect(LEASE_MS).toBe(300_000); expect(MAX_MS).toBe(3_600_000); + let state = reservationTransition(emptyReservation(), acquire(), 101, 0); + expect(() => reservationTransition(state, acquire("2".repeat(32)), 202, 1)).toThrow("Integration busy"); + state = reservationTransition(state, { kind: "renew", id: "1".repeat(32) }, 101, RENEW_MS); + expect(state.active!.expires).toBe(RENEW_MS + LEASE_MS); expect(state.active!.cap).toBe(MAX_MS); + expect(() => reservationTransition(state, { kind: "renew", id: "1".repeat(32) }, 101, state.active!.expires)).toThrow("expired"); + state = reservationTransition(state, acquire("2".repeat(32)), 202, state.active!.expires); + for (const kind of ["renew", "release"] as const) expect(() => reservationTransition(state, { kind, id: "1".repeat(32) }, 101, state.updated)).toThrow(); + expect(() => assertLease(state, "1".repeat(32), 101, candidate, state.updated)).toThrow("superseded"); + expect(() => assertLease(state, state.active!.id, 202, { ...candidate, head: "c".repeat(40) }, state.updated)).toThrow("mismatched"); + const start = state.active!.acquired; + expect(() => reservationTransition(state, { kind: "renew", id: state.active!.id }, 202, start + MAX_MS - 1)).toThrow("expired"); + // The old lease expired long before this renewal; no activity revives it. +}); + +test("continuous minute heartbeats reach but never reset the sixty-minute cap", () => { + let state = reservationTransition(emptyReservation(), acquire(), 101, 0); + for (let now = RENEW_MS; now < MAX_MS; now += RENEW_MS) state = reservationTransition(state, { kind: "renew", id: state.active!.id }, 101, now); + expect(state.active!.cap).toBe(MAX_MS); expect(state.active!.expires).toBe(MAX_MS); + expect(() => reservationTransition(state, { kind: "renew", id: state.active!.id }, 101, MAX_MS)).toThrow("expired"); + expect(() => assertLease(state, state.active!.id, 101, candidate, MAX_MS)).toThrow("expired"); +}); + +test("revocation records actor, time, reason and identity and immediately fences the old owner", () => { + let state = reservationTransition(emptyReservation(), acquire(), 101, 0); + expect(() => reservationTransition(state, { kind: "revoke", maintainer: false, reason: "Release abandoned proof" }, 202, 1)).toThrow("Maintainer"); + expect(() => reservationTransition(state, { kind: "revoke", maintainer: true, reason: "" }, 202, 1)).toThrow("reason"); + state = reservationTransition(state, { kind: "revoke", maintainer: true, reason: "Release abandoned proof" }, 202, 1); + expect(state.last).toMatchObject({ actor: 202, at: 1, action: "revoke", reason: "Release abandoned proof", previous: { id: "1".repeat(32) } }); + expect(() => assertLease(state, "1".repeat(32), 101, candidate, 1)).toThrow(); + state = reservationTransition(state, acquire("2".repeat(32)), 202, 2); + expect(() => reservationTransition(state, { kind: "release", id: "1".repeat(32) }, 101, 3)).toThrow("does not own"); +}); + +test("the authoritative clock rejects missing, stale, malformed and failed HTTP evidence", () => { + const date = "Sun, 12 Jul 2026 00:00:00 GMT"; + expect(reservationServerTime(`HTTP/2.0 200 OK\r\nDate: ${date}\r\n\r\n{}`)).toBe(Date.parse(date)); + for (const response of ["", `HTTP/2.0 200 OK\n\n{}`, `HTTP/2.0 403 Forbidden\nDate: ${date}\n\n{}`, `HTTP/2.0 200 OK\nDate: invalid\n\n{}`, `HTTP/2.0 200 OK\nDate: ${date}\nAge: 1\n\n{}`]) expect(() => reservationServerTime(response)).toThrow("clock unavailable"); +}); + +test("real concurrent acquisition snapshots cannot both publish ownership through the remote CAS", async () => { + const dir = mkdtempSync(join(tmpdir(), "odysseus-integration-cas-")); dirs.push(dir); + const remote = join(dir, "remote.git"); + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { cwd: dir, stdout: "pipe", stderr: "pipe" }); + expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); + }; + git("init", "--bare", remote); + for (const name of ["one", "two"]) { mkdirSync(join(dir, name)); git("init", join(dir, name)); const result = Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: join(dir, name), stdout: "pipe", stderr: "pipe" }); expect(result.exitCode).toBe(0); } + const script = join(dir, "contender.ts"), source = resolve(import.meta.dir, "../scripts/integration-reservation.ts"); + writeFileSync(script, `import { existsSync, writeFileSync } from "node:fs"; + import { GitReservationStore, reservationTransition } from ${JSON.stringify(source)}; + const [root, number, barrier]=process.argv.slice(2), store=new GitReservationStore(root), snapshot=store.read(); + writeFileSync(barrier+number,"ready");while(!existsSync(barrier+"1")||!existsSync(barrier+"2"))await Bun.sleep(5); + if(number==="2")while(!existsSync(barrier+"published"))await Bun.sleep(5); + const state=reservationTransition(snapshot.state,{kind:"acquire",candidate:${JSON.stringify(candidate)},id:number.repeat(32)},Number(number),0); + const published=store.publish(snapshot,state);if(number==="1")writeFileSync(barrier+"published","done"); + console.log(JSON.stringify({published}));`); + const children = ["1", "2"].map((id, index) => Bun.spawn([process.execPath, script, join(dir, index ? "two" : "one"), id, join(dir, "barrier-")], { stdout: "pipe", stderr: "pipe" })); + const results = await Promise.all(children.map(async child => { + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, err).toBe(0); return JSON.parse(out).published as boolean; + })); + expect(results, "ONLY_ONE_REMOTE_ACQUISITION_SUCCEEDS").toEqual([true, false]); + expect(new GitReservationStore(join(dir, "two")).read().state.active?.actor).toBe(1); +}); + +test("shared state refuses malformed ownership and missing or corrupt revocation audit", () => { + const acquired = reservationTransition(emptyReservation(), acquire(), 101, 0); + const revoked = reservationTransition(acquired, { kind: "revoke", maintainer: true, reason: "Abandon proof" }, 202, 1); + for (const state of [ { ...acquired, active: false }, { ...acquired, last: null }, + { ...revoked, last: { ...revoked.last, actor: 0 } }, { ...revoked, last: { ...revoked.last, at: 2 } }, + { ...revoked, last: { ...revoked.last, reason: "" } }, { ...revoked, last: { ...revoked.last, previous: null } } ]) { + expect(() => parseReservation(JSON.stringify(state))).toThrow(); + } + expect(parseReservation(JSON.stringify(revoked))).toEqual(revoked); +});