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
6 changes: 3 additions & 3 deletions docs/reference/specs/agent-review.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/specs/tracing.md

Large diffs are not rendered by default.

106 changes: 91 additions & 15 deletions src/core/dispatch/authorize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,17 +547,22 @@ describe("authorizeAttachedHead — every review workspace is at the PR head bef
const review = getAgent("review");
const repoCtx: RepoContext = { repo: "acme/api", pr: 41, ref: "feature/x", headSha: SHA_A };

function selection(over: { sha?: string; resident?: boolean; observed?: string; seeded?: boolean } = {}): {
function selection(over: { sha?: string; resident?: boolean; observed?: string | string[]; seeded?: boolean } = {}): {
selection: ExecutorSelection;
releases: string[];
commands: string[];
} {
const releases: string[] = [];
const commands: string[] = [];
const observed = Array.isArray(over.observed) ? [...over.observed] : [over.observed ?? ""];
const executor = {
exec: async (command: string) => {
commands.push(command);
return command.includes("rev-parse HEAD") ? (over.observed ?? "") : "provisioned";
return command.includes("rev-parse HEAD")
? observed.length > 1
? observed.shift()!
: (observed[0] ?? "")
: "provisioned";
},
release: async (mode: string) => {
releases.push(mode);
Expand Down Expand Up @@ -602,7 +607,7 @@ describe("authorizeAttachedHead — every review workspace is at the PR head bef

it("the worktree attached at the PR head: verified, the repo context unchanged", async () => {
const s = setup();
const { selection: sel } = selection({ sha: SHA_A });
const { selection: sel } = selection({ sha: SHA_A, observed: `${SHA_A}\n` });
expect(await authorizeAttachedHead({ ...s.deps, fetchPrHead: async () => SHA_C }, ctx(s, sel))).toEqual({
kind: "allowed",
repoCtx,
Expand All @@ -612,10 +617,23 @@ describe("authorizeAttachedHead — every review workspace is at the PR head bef
expect(s.replies).toEqual([]);
});

it("a resident binding SHA cannot verify a checkout whose observed HEAD differs", async () => {
const s = setup();
const { selection: sel, commands, releases } = selection({ sha: SHA_A, observed: `${SHA_B}\n` });

expect(await authorizeAttachedHead({ ...s.deps, fetchPrHead: async () => SHA_C }, ctx(s, sel))).toEqual({
kind: "refused",
reason: "workspace_head_mismatch",
});
expect(commands.filter((command) => command === "git rev-parse HEAD")).toHaveLength(2);
expect(releases).toEqual(["always"]);
expect(s.replies[0]).toContain(`workspace-observed HEAD for feature/x is at ${SHA_B}`);
});

it("a push raced the request and the worktree sits at the PR's current head: adopted — the repo context takes that head and the caller re-publishes the run meta", async () => {
const s = setup();
const asked: unknown[] = [];
const { selection: sel } = selection({ sha: SHA_B });
const { selection: sel } = selection({ sha: SHA_B, observed: `${SHA_B}\n` });
const out = await authorizeAttachedHead(
{
...s.deps,
Expand All @@ -635,19 +653,75 @@ describe("authorizeAttachedHead — every review workspace is at the PR head bef
expect(asked).toEqual([{ repo: "acme/api", number: 41 }]);
});

it("the branch moved while the worktree was being attached: refused — the pool user released, the card closed, one named reply, no model turn", async () => {
it("the workspace stays mismatched after one reprovision: refused — the pool user released, the card closed, one evidence-only reply, no model turn", async () => {
const s = setup();
const { selection: sel, releases } = selection({ sha: SHA_B });
const { selection: sel, releases } = selection({ sha: SHA_B, observed: `${SHA_B}\n` });
const out = await authorizeAttachedHead({ ...s.deps, fetchPrHead: async () => SHA_C }, ctx(s, sel));
expect(out).toEqual({ kind: "refused", reason: "branch_moved" });
expect(s.refusals).toEqual(["branch_moved"]);
expect(out).toEqual({ kind: "refused", reason: "workspace_head_mismatch" });
expect(s.refusals).toEqual(["workspace_head_mismatch"]);
expect(releases).toEqual(["always"]);
expect(closedReasons(s.closes).join("\n")).toContain("branch moved");
expect(s.replies[0]).toContain(
`🔀 Review of acme/api#41 not started: the resident binding for feature/x is at ${SHA_B}, but the PR head is ${SHA_A}`,
expect(closedReasons(s.closes).join("\n")).toContain("workspace head mismatch");
expect(s.replies[0]).toContain(SHA_B);
expect(s.replies[0]).toContain(`expected reviewed head is ${SHA_A}`);
expect(s.replies[0]).not.toMatch(/branch moved|push|force-push|bug/i);
});

it("a resident moveTo failure refuses without describing the initial binding as the retry observation", async () => {
const s = setup();
const { selection: sel, releases } = selection({ sha: SHA_B, observed: `${SHA_B}\n` });
sel.executor.moveTo = async () => {
throw new Error("resident move failed");
};

expect(await authorizeAttachedHead({ ...s.deps, fetchPrHead: async () => SHA_C }, ctx(s, sel))).toEqual({
kind: "refused",
reason: "workspace_head_mismatch",
});
expect(releases).toEqual(["always"]);
expect(s.replies[0]).toContain(`initial workspace-observed HEAD for feature/x was at ${SHA_B}`);
expect(s.replies[0]).toContain("automatic reprovision failed before a retry HEAD could be observed");
expect(s.replies[0]).not.toContain(
`after one automatic reprovision, the resident binding for feature/x is at ${SHA_B}`,
);
});

it("resident moveTo metadata cannot verify a retry whose observed HEAD is still mismatched", async () => {
const s = setup();
const { selection: sel, commands, releases } = selection({ sha: SHA_B, observed: `${SHA_B}\n` });
sel.executor.moveTo = async () => ({ sha: SHA_A });

expect(await authorizeAttachedHead({ ...s.deps, fetchPrHead: async () => SHA_C }, ctx(s, sel))).toEqual({
kind: "refused",
reason: "workspace_head_mismatch",
});
expect(commands.filter((command) => command === "git rev-parse HEAD")).toHaveLength(2);
expect(releases).toEqual(["always"]);
expect(s.replies[0]).toContain(`workspace-observed HEAD for feature/x is at ${SHA_B}`);
});

it("a successful retry publishes the observed checkout HEAD instead of moveTo metadata", async () => {
const s = setup();
const {
selection: sel,
commands,
releases,
} = selection({
sha: SHA_B,
observed: [`${SHA_B}\n`, `${SHA_A}\n`],
});
sel.executor.moveTo = async () => ({ sha: SHA_C });

expect(await authorizeAttachedHead({ ...s.deps, fetchPrHead: async () => SHA_C }, ctx(s, sel))).toEqual({
kind: "allowed",
repoCtx,
verifiedAtAttach: true,
headAdopted: false,
});
expect(commands.filter((command) => command === "git rev-parse HEAD")).toHaveLength(2);
expect(sel.binding?.sha).toBe(SHA_A);
expect(releases).toEqual([]);
});

it("an unseeded non-resident backend provisions the PR checkout at the resolved head before verifying it; a mismatch is refused and released before the model", async () => {
const verified = setup();
const atHead = selection({ resident: false, observed: `${SHA_A}\n` });
Expand Down Expand Up @@ -687,21 +761,23 @@ describe("authorizeAttachedHead — every review workspace is at the PR head bef
{ ...mismatched.deps, fetchPrHead: async () => SHA_C },
ctx(mismatched, elsewhere.selection),
),
).toEqual({ kind: "refused", reason: "branch_moved" });
).toEqual({ kind: "refused", reason: "workspace_head_mismatch" });
expect(elsewhere.releases).toEqual(["always"]);
expect(mismatched.replies[0]).toContain(`workspace-observed HEAD for feature/x is at ${SHA_B}`);
expect(mismatched.replies[0]).toContain(`PR head is ${SHA_A}`);
expect(mismatched.replies[0]).toContain(`expected reviewed head is ${SHA_A}`);
});

it("an unreadable workspace head is refused fail-closed; only non-review and resumed runs skip the first-turn guard", async () => {
const unknown = setup();
const noSha = selection({ resident: false, observed: "exit 128: not a git repository" });
expect(await authorizeAttachedHead(unknown.deps, ctx(unknown, noSha.selection))).toEqual({
kind: "refused",
reason: "branch_moved",
reason: "workspace_head_mismatch",
});
expect(noSha.releases).toEqual(["always"]);
expect(unknown.replies[0]).toContain("workspace-observed HEAD for feature/x could not be read");
expect(unknown.replies[0]).toContain(
"workspace-observed HEAD for feature/x could not be read after one automatic reprovision",
);

const asked: unknown[] = [];
const deps: AuthorizeDeps = {
Expand Down
117 changes: 89 additions & 28 deletions src/core/dispatch/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ export async function authorizePrHead(
* the attach verified the worktree is at that head — or it was refused. */
export type AttachedHeadGate =
| { kind: "allowed"; repoCtx: RepoContext; verifiedAtAttach: boolean; headAdopted: boolean }
| { kind: "refused"; reason: "branch_moved" };
| { kind: "refused"; reason: "workspace_head_mismatch" };

/**
* A cold review starts in an empty per-thread workspace. Provision the exact
Expand All @@ -399,14 +399,24 @@ export type AttachedHeadGate =
* resolved base are fetched beside the PR head because the review prompt diffs
* against those remote-tracking refs.
*/
function reviewCheckoutFacts(repoCtx: RepoContext & { repo: string; pr: number }): {
remote: string;
helper: string;
refspecs: string[];
} {
return {
remote: `https://github.com/${repoCtx.repo}.git`,
helper: `!f() { test -n "$GH_TOKEN" || exit 1; printf '%s\\n' 'username=x-access-token' "password=$GH_TOKEN"; }; f`,
refspecs: [
"+HEAD:refs/remotes/origin/HEAD",
`+refs/pull/${repoCtx.pr}/head:refs/remotes/origin/pull/${repoCtx.pr}/head`,
...(repoCtx.baseRef ? [`+refs/heads/${repoCtx.baseRef}:refs/remotes/origin/${repoCtx.baseRef}`] : []),
],
};
}

function coldReviewCheckoutCommand(repoCtx: RepoContext & { repo: string; pr: number; headSha: string }): string {
const remote = `https://github.com/${repoCtx.repo}.git`;
const helper = `!f() { test -n "$GH_TOKEN" || exit 1; printf '%s\\n' 'username=x-access-token' "password=$GH_TOKEN"; }; f`;
const refspecs = [
"+HEAD:refs/remotes/origin/HEAD",
`+refs/pull/${repoCtx.pr}/head:refs/remotes/origin/pull/${repoCtx.pr}/head`,
...(repoCtx.baseRef ? [`+refs/heads/${repoCtx.baseRef}:refs/remotes/origin/${repoCtx.baseRef}`] : []),
];
const { remote, helper, refspecs } = reviewCheckoutFacts(repoCtx);
return [
"set -eu",
"find . -mindepth 1 -maxdepth 1 ! -name attachments -exec rm -rf -- {} +",
Expand All @@ -417,13 +427,27 @@ function coldReviewCheckoutCommand(repoCtx: RepoContext & { repo: string; pr: nu
].join("\n");
}

/** Refresh an already-seeded review checkout at the same expected head. */
function seededReviewCheckoutCommand(
repoCtx: RepoContext & { repo: string; pr: number; headSha: string },
workspace: string,
): string {
const { helper, refspecs } = reviewCheckoutFacts(repoCtx);
const git = `git -C ${shellQuote(workspace)}`;
return [
"set -eu",
`${git} -c credential.helper= -c credential.helper=${shellQuote(helper)} fetch --force --no-tags origin ${refspecs.map(shellQuote).join(" ")}`,
`${git} checkout --detach --force ${shellQuote(repoCtx.headSha)}`,
].join("\n");
}

/**
* The attached-head guard (docs/reference/specs/agent-review.md item 10): every
* PR review is at its resolved head before any model turn. Cold backends are
* provisioned first; seeded backends are observed at their named checkout; a
* resident's attach binding is its proof. Verified; adopted (a push raced the
* request and the workspace is at the PR's head now); or refused (mismatch or
* unreadable): the workspace is released and one named reply is sent.
* provisioned first; seeded and resident backends are observed through their
* executor. Verified; adopted (a push raced the request and the workspace is
* at the PR's head now); or refused (mismatch or unreadable): the workspace is
* released and one named reply is sent.
*/
export async function authorizeAttachedHead(
deps: AuthorizeDeps,
Expand All @@ -441,17 +465,16 @@ export async function authorizeAttachedHead(
const { executor, resident, binding } = selection;
let repoCtx = ctx.repoCtx;
// Attach-head check (docs/reference/specs/agent-review.md item 10): every PR
// review proves the workspace's HEAD before any model turn. A resident's
// attach binding is the proof it just returned; every other backend is
// observed directly in the checkout. Unknown is a refusal, not permission
// for the model to turn an infrastructure failure into a finding.
// review proves the workspace's HEAD before any model turn. Every backend,
// including a resident whose attach returned SHA metadata, is observed
// directly in the checkout. Unknown is a refusal, not permission for the
// model to turn an infrastructure failure into a finding.
let verifiedAtAttach = false;
let headAdopted = false;
if (!resume && agent.name === "review" && repoCtx.pr !== undefined && repoCtx.repo) {
const pr = { repo: repoCtx.repo, number: repoCtx.pr };
const expectedHeadSha = repoCtx.headSha;
const guard = await root.span("dispatch.gate.attached_head", async (span) => {
const source = resident && binding?.sha !== undefined ? "resident binding" : "workspace-observed";
const command = selection.seeded?.workspace
? `git -C ${shellQuote(selection.seeded.workspace)} rev-parse HEAD`
: "git rev-parse HEAD";
Expand All @@ -461,19 +484,52 @@ export async function authorizeAttachedHead(
{ timeoutMs: BASH_TIMEOUT_MAX_MS },
);
}
const sha =
source === "resident binding"
? binding?.sha
: await executor
.exec(command, { timeoutMs: 30_000 })
.then(parseRevParseOutput)
.catch(() => undefined);
const observeHead = () =>
executor
.exec(command, { timeoutMs: 30_000, span })
.then(parseRevParseOutput)
.catch(() => undefined);
const sha = await observeHead();
const g = await guardAttachedHead({
pr,
expectedHeadSha,
attached: { sha, ref: binding?.ref ?? repoCtx.ref, source },
attached: { sha, ref: binding?.ref ?? repoCtx.ref, source: "workspace-observed" },
fallbackRef: repoCtx.ref,
fetchPrHead: deps.fetchPrHead ?? currentPrHeadSha,
reprovision: async (headSha) => {
if (executor.moveTo) {
await executor.moveTo(headSha, { span });
const observedSha = await observeHead();
if (selection.binding && observedSha !== undefined) {
selection.binding = {
...selection.binding,
ref: repoCtx.ref ?? selection.binding.ref,
sha: observedSha,
};
}
return {
sha: observedSha,
ref: repoCtx.ref,
source: "workspace-observed" as const,
};
}
if (selection.seeded?.workspace) {
await executor.exec(
seededReviewCheckoutCommand(
{ ...repoCtx, repo: pr.repo, pr: pr.number, headSha },
selection.seeded.workspace,
),
{ timeoutMs: BASH_TIMEOUT_MAX_MS, span },
);
} else {
await executor.exec(coldReviewCheckoutCommand({ ...repoCtx, repo: pr.repo, pr: pr.number, headSha }), {
timeoutMs: BASH_TIMEOUT_MAX_MS,
span,
});
}
const retried = await observeHead();
return { sha: retried, ref: repoCtx.ref, source: "workspace-observed" as const };
},
logKey: msg.threadKey,
});
span.setAttrs({ outcome: g.outcome });
Expand All @@ -486,13 +542,18 @@ export async function authorizeAttachedHead(
verifiedAtAttach = true;
headAdopted = true;
} else if (guard.outcome === "refused") {
await refuse(refusalOf("branch_moved", guard.reply), async () => {
await refuse(refusalOf("workspace_head_mismatch", guard.reply), async () => {
if (executor.release) await executor.release("always").catch(() => {});
await card.done(
shell.close({ kind: "not_started", icon: "🔀", reason: "branch moved", ...closeLines(clock(), false) }),
shell.close({
kind: "not_started",
icon: "🔀",
reason: "workspace head mismatch",
...closeLines(clock(), false),
}),
);
});
return { kind: "refused", reason: "branch_moved" };
return { kind: "refused", reason: "workspace_head_mismatch" };
}
}
return { kind: "allowed", repoCtx, verifiedAtAttach, headAdopted };
Expand Down
6 changes: 5 additions & 1 deletion src/core/dispatch/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,11 @@ async function attachRound(
): Promise<RoundWorkspace> {
const { threadKey, agent, profile, repoCtx, root, clock, reattach, stopSignal, remainingMs, requester } = ctx;
const { onSetupNote } = ctx;
const ownPr = ownPrOf(repoCtx);
// A review target's PR-derived ref is authoritative. Passing `ownPr` asks
// the resident to preserve or conditionally move a sticky thread binding;
// that is right for a coding follow-up, but can keep a plan unit's branch
// when this round is reviewing an adopted pull request on another branch.
const ownPr = agent.name === "review" ? undefined : ownPrOf(repoCtx);
return root.span("dispatch.workspace.attach", async (span) => {
// The resident's own steps (clone, install, the mutex wait…) graft under
// this span, rebased to its start (docs/reference/specs/tracing.md item 19) — on a
Expand Down
4 changes: 2 additions & 2 deletions src/core/dispatch/reply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ describe("renderRefusal — the one rendering of a Refusal", () => {
// lines, the references' one line — and the inventory's quote as a
// literal, so a drifted builder fails here instead of shipping. Codes
// whose text another module builds from live data (`pr_head_unknown` from
// `checkPrHeadPreflight`, `branch_moved` from `guardAttachedHead`,
// `checkPrHeadPreflight`, `workspace_head_mismatch` from `guardAttachedHead`,
// `ship_preflight` from the ship preflight) are proven byte-identical by
// those modules' own tests; the silent codes (`coordinator_thread_live`,
// `workspace_lost`, `setup_failed`) and `uncaught` render nothing.
Expand Down Expand Up @@ -414,7 +414,7 @@ describe("renderRefusal — the one rendering of a Refusal", () => {
// another module's tested builder, or silent by design.
const provenElsewhere: RefusalCode[] = [
"pr_head_unknown",
"branch_moved",
"workspace_head_mismatch",
// (record 0054): each producer's own test proves its sentences
// byte-identical — the ship preflight's ten (preflight.test.ts), the
// plan hand-off's fifteen (handOff.test.ts), the resolve parser
Expand Down
2 changes: 1 addition & 1 deletion src/core/dispatch/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ export async function renderConfirmationOffer(io: ChannelIO, offer: Confirmation
* Codes missing here carry producer-built text on the `Refusal` instead:
* `profile_bounded` (`profileRefusalReply`), `follow_up_refused` and its
* `elsewhere_` twin (`refusalReply` in admission.ts), `pr_head_unknown`
* (`checkPrHeadPreflight`), `branch_moved` (`guardAttachedHead`),
* (`checkPrHeadPreflight`), `workspace_head_mismatch` (`guardAttachedHead`),
* `ship_preflight` (the preflight's own reply), the reference codes (the one
* `REFERENCE_REFUSAL` line), the click codes (confirm.ts's lines), and the
* silent codes (`coordinator_thread_live`, `workspace_lost`, `setup_failed`,
Expand Down
Loading