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
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,16 +510,13 @@ quest merge 128 --land <sha> # auto-merge bound to that head; takes no label
A taken PR carries the `quest:active` label while an agent works on it.
Sessions often share a GitHub account, so taking it prints a hold token: the
ID of the timeline event that added the label. Every command refuses while a
`quest:*` label it holds no token for is on the PR, including another
session's `quest:active`; a draft its starter still holds, one without a
`quest:active` or `quest:kix` label it holds no token for is on the PR,
including another session's `quest:active`; a draft its starter still holds, one without a
`## Waiting` (or older `## Decisions needed`) section; and a merged or closed PR, since a push after
the merge brings the branch back. The refusal names the holder's label, age,
and token, so you can decide whether to release a stale hold. When two
sessions add labels at once, the first label added keeps the PR and the other
backs off. Older quest versions add `quest:iterate` and `quest:ready`, which
hold a PR like any `quest:*` label; passing their token to `quest iterate`
swaps them for `quest:active`. Their `quest:merge` holds nothing and is
ignored.
backs off.

`quest merge` takes no label: the head-pinned auto-merge or merge-queue entry
it creates is the queue signal, so a queued PR shows no hold. It refuses while
Expand Down
10 changes: 1 addition & 9 deletions cloudflare/src/github/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash, createPrivateKey, sign } from "node:crypto";
import {
HOLD_PREFIX,
InitRefused,
KIX_LABEL,
isHoldLabel,
latestHold,
heldToken,
Expand Down Expand Up @@ -2595,14 +2595,6 @@ type Holds = {
events: { id: string; label: string | null }[];
};

/**
* The label a merge job holds its pull request with, from the pick until the
* job ends without landing or a landing it started ends unmerged. It is kix's
* own, apart from local sessions' `quest:active`, since it stays on while the
* job's landing is queued; as a `quest:*` label, local sessions respect it.
*/
export const KIX_LABEL = `${HOLD_PREFIX}kix`;

function heldBy(label: string) {
return `Another session holds this pull request with ${label}; pick it again once it is released`;
}
Expand Down
1 change: 0 additions & 1 deletion quest/m2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,4 @@ additional login providers. This milestone does not gate the public release.
- [Donor isolation](/quest/m2/donor-isolation.md) - stronger boundaries for untrusted shared jobs
- [ChatGPT hosted runs](/quest/m2/chatgpt-hosted.md) - conditional on provider approval and current terms
- [ChatGPT approval](/quest/m2/chatgpt-waitlist.md) - resolve hosted-use approval before implementation
- [Delete the legacy hold labels](/quest/m2/legacy-labels.md) - once every gated repository pins quest past #326, only `quest:active` and `quest:kix` are holds
- [Worktree hook PATH error](/quest/m2/hook-path-error.md) - a worktree hook without `quest` on `PATH` says so and names the dev-shell launch route
24 changes: 0 additions & 24 deletions quest/m2/legacy-labels.md

This file was deleted.

10 changes: 6 additions & 4 deletions src/cli/hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ export async function release(pr: PullRequest, token: string, deps: GitHubDepend
/**
* Take `pr` with `quest:active`, refusing while it is merged or closed, while
* another session holds a hold label, or while it is a draft its starter still
* holds. With `token`, the hold it names is kept, or swapped for `quest:active`
* when it names another label, such as one an older quest version added.
* holds. With `token`, the hold it names must be this session's own `quest:active`,
* which is kept; another hold label, such as kix's, is refused rather than taken over.
*/
async function acquire(labels: Labels, token: string | undefined): Promise<{ hold: Hold; snapshot: Snapshot }> {
const label = ACTIVE_LABEL;
Expand All @@ -392,22 +392,24 @@ async function acquire(labels: Labels, token: string | undefined): Promise<{ hol
const mine = token === undefined ? undefined : held(snapshot, token);
unheld(snapshot, mine, labels.deps.now());
if (mine === label) return { hold: { label, token: token! }, snapshot };
if (mine !== undefined) {
throw new Refusal(`${token} names the ${mine} hold on ${snapshot.url}, which this session does not own`);
}

await labels.add(label);
const after = await labels.read();
const ours = latestHold(after.events, label);
if (!ours) throw new Error(`added ${label} to ${after.url}, but its timeline does not show it`);
// Another session took the PR at the same time: the first label added keeps it.
const rival = after.labels.find((name) => {
if (name === label || name === mine) return false;
if (name === label) return false;
const added = latestHold(after.events, name);
return !added || after.events.indexOf(added) < after.events.indexOf(ours);
});
if (rival !== undefined) {
await labels.remove(label);
throw new Refusal(holder(after, rival, labels.deps.now()));
}
if (mine !== undefined) await labels.remove(mine);
return { hold: { label, token: ours.id }, snapshot: { ...after, labels: [label] } };
}

Expand Down
8 changes: 3 additions & 5 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,15 +527,13 @@ export function main(argv: string[], out: Output): number | Promise<number> {
const OWNERSHIP =
"Before a session pushes to an existing PR, it takes the PR with iterate, and hands it back with " +
"release. A taken PR carries the quest:active label; iterate prints a hold token, the ID of the timeline " +
"event that added it, since sessions often share an account. A command refuses while a quest:* label " +
"it holds no token for is on the PR, and while the PR is merged, closed, or a draft its starter still " +
"event that added it, since sessions often share an account. A command refuses while a quest:active or " +
"quest:kix label it holds no token for is on the PR, and while the PR is merged, closed, or a draft its starter still " +
"holds (one whose body has no Waiting heading). A refusal names the holder's label, age, and " +
"token; releasing a stale hold with that token is the user's choice. Given a token, a command refuses " +
"unless the token is still the latest add of its label. When two sessions add the label at once, the " +
"first added keeps the PR; GitHub records no event when a label already on the PR is added again, so two " +
"takes within the same moment are not told apart. Older quest versions' quest:iterate and quest:ready " +
"labels hold a PR like any quest:* label, and taking it with their token swaps them for quest:active; " +
"their quest:merge label holds nothing.\n\n" +
"takes within the same moment are not told apart.\n\n" +
"Uses GH_TOKEN, GITHUB_TOKEN, or gh auth token. Exits 0 when done, 3 when refused, 2 for invalid " +
"arguments, and 1 for other failures.";
const printToken = (work: Promise<Hold>) => settle(work, (hold) => print(hold.token));
Expand Down
13 changes: 6 additions & 7 deletions src/core/hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ export const HOLD_PREFIX = "quest:";
export const ACTIVE_LABEL = `${HOLD_PREFIX}active`;

/**
* Compatibility with older quest versions, which repositories pin: delete once
* they have upgraded. Their `quest:iterate` and `quest:ready` still hold a PR,
* as every other `quest:*` label does, so nothing here names them. Their
* `quest:merge` stayed on a queued PR after its session ended, and the pinned
* auto-merge is the queue signal, so that label holds nothing.
* The label a merge job holds its pull request with, from the pick until the
* job ends without landing or a landing it started ends unmerged. It is kix's
* own, apart from local sessions' `quest:active`, since it stays on while the
* job's landing is queued; local sessions respect it.
*/
const LEGACY_IGNORED = `${HOLD_PREFIX}merge`;
export const KIX_LABEL = `${HOLD_PREFIX}kix`;

export interface HoldEvent {
id: string;
Expand All @@ -25,7 +24,7 @@ export interface HoldSnapshot {
}

export function isHoldLabel(label: string): boolean {
return label.startsWith(HOLD_PREFIX) && label !== LEGACY_IGNORED;
return label === ACTIVE_LABEL || label === KIX_LABEL;
}

/** Preserve a caller's extra event fields, such as its timestamp. */
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveL
export {
HOLD_PREFIX,
ACTIVE_LABEL,
KIX_LABEL,
type HoldEvent,
type HoldSnapshot,
isHoldLabel,
Expand Down
21 changes: 14 additions & 7 deletions test/hold-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { describe, expect, test } from "vitest";
import { ACTIVE_LABEL, HOLD_PREFIX, heldToken, isHoldLabel, latestHold } from "../src/core";
import { ACTIVE_LABEL, HOLD_PREFIX, KIX_LABEL, heldToken, isHoldLabel, latestHold } from "../src/core";

const added = { id: "first", label: ACTIVE_LABEL, time: 1 };

describe("shared hold policy", () => {
test("recognizes all hold labels without treating ordinary labels as holds", () => {
test("recognizes only the hold labels, not other quest:* or ordinary labels", () => {
expect(HOLD_PREFIX).toBe("quest:");
expect(ACTIVE_LABEL).toBe("quest:active");
// Older quest versions' quest:iterate and quest:ready still hold a PR.
for (const label of [ACTIVE_LABEL, "quest:kix", "quest:ready", "quest:iterate", "quest:custom"])
expect(isHoldLabel(label)).toBe(true);
// Their quest:merge only shadowed a queued landing.
for (const label of ["quest", "bug", "other:quest:active", "quest:merge"])
expect(KIX_LABEL).toBe("quest:kix");
for (const label of [ACTIVE_LABEL, KIX_LABEL]) expect(isHoldLabel(label)).toBe(true);
// Labels older quest versions added, and quest:merge, hold nothing.
for (const label of [
"quest",
"bug",
"other:quest:active",
"quest:merge",
"quest:iterate",
"quest:ready",
"quest:custom",
])
expect(isHoldLabel(label)).toBe(false);
});

Expand Down
56 changes: 31 additions & 25 deletions test/hold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,18 +236,15 @@ describe("taking a PR", () => {
expect(pull.labels).toEqual(["quest:active"]);
});

test.each(["quest:iterate", "quest:ready", "quest:kix"])(
"refuses while %s holds it, as an older quest version or kix adds",
async (label) => {
const pull = new FakePull();
const token = pull.label(label);
expect(await refused(take(PR, undefined, pull.deps()))).toContain(`held by ${label}`);
expect(pull.labels).toEqual([label]);
// Its token releases it.
expect(await release(PR, token!, pull.deps())).toBe(label);
expect((await take(PR, undefined, pull.deps())).label).toBe("quest:active");
},
);
test("refuses while kix holds it, and its token releases it", async () => {
const pull = new FakePull();
const token = pull.label("quest:kix");
expect(await refused(take(PR, undefined, pull.deps()))).toContain("held by quest:kix");
expect(pull.labels).toEqual(["quest:kix"]);
// Its token releases it.
expect(await release(PR, token!, pull.deps())).toBe("quest:kix");
expect((await take(PR, undefined, pull.deps())).label).toBe("quest:active");
});

test("ignores the quest:merge label older quest versions left on queued PRs", async () => {
const pull = new FakePull();
Expand All @@ -260,35 +257,44 @@ describe("taking a PR", () => {
const pull = new FakePull();
pull.beforeAdd = () => {
pull.beforeAdd = undefined;
pull.label("quest:iterate");
pull.label("quest:kix");
};
expect(await refused(take(PR, undefined, pull.deps()))).toContain("held by quest:iterate");
expect(pull.labels).toEqual(["quest:iterate"]);
expect(await refused(take(PR, undefined, pull.deps()))).toContain("held by quest:kix");
expect(pull.labels).toEqual(["quest:kix"]);
expect(pull.writes).toEqual(["add quest:active", "remove quest:active"]);
});

test("keeps the PR when another session's label lands after its own", async () => {
const pull = new FakePull();
pull.afterAdd = () => {
pull.afterAdd = undefined;
pull.label("quest:iterate");
pull.label("quest:kix");
};
expect(await take(PR, undefined, pull.deps())).toEqual({ label: "quest:active", token: "LabeledEvent_1" });
// The later session sees the earlier label and backs off.
expect(pull.labels).toEqual(["quest:active", "quest:iterate"]);
expect(pull.labels).toEqual(["quest:active", "quest:kix"]);
});

test("keeps the hold its token names, and swaps an older version's label for quest:active", async () => {
test("keeps the hold its token names", async () => {
const pull = new FakePull();
const hold = await take(PR, undefined, pull.deps());
expect(await take(PR, hold.token, pull.deps())).toEqual(hold);
expect(pull.writes).toEqual(["add quest:active"]);
});

const legacy = new FakePull();
const token = legacy.label("quest:ready")!;
expect(await take(PR, token, legacy.deps())).toEqual({ label: "quest:active", token: "LabeledEvent_2" });
expect(legacy.labels).toEqual(["quest:active"]);
expect(legacy.writes).toEqual(["add quest:active", "remove quest:ready"]);
test("refuses a token naming a label that holds nothing", async () => {
const pull = new FakePull();
const token = pull.label("quest:ready")!;
expect(await refused(take(PR, token, pull.deps()))).toContain("quest:ready is off");
expect(pull.writes).toEqual([]);
});

test("refuses a token naming kix's hold rather than taking it over", async () => {
const pull = new FakePull();
const token = pull.label("quest:kix")!;
expect(await refused(take(PR, token, pull.deps()))).toContain("names the quest:kix hold");
expect(pull.labels).toEqual(["quest:kix"]);
expect(pull.writes).toEqual([]);
});

test("refuses a token once another session holds the label", async () => {
Expand Down Expand Up @@ -343,9 +349,9 @@ describe("merge --land", () => {
expect(await refused(land(PR, HEAD, "merge", pull.deps()))).toContain(
`held by quest:active, added just now as token ${token}`,
);
pull.label("quest:iterate");
pull.label("quest:kix");
await release(PR, token, pull.deps());
expect(await refused(land(PR, HEAD, "merge", pull.deps()))).toContain("held by quest:iterate");
expect(await refused(land(PR, HEAD, "merge", pull.deps()))).toContain("held by quest:kix");
pull.labels = ["quest:merge"];
await land(PR, HEAD, "merge", pull.deps());
expect(pull.writes.at(-1)).toBe(`enablePullRequestAutoMerge ${HEAD} MERGE`);
Expand Down
4 changes: 2 additions & 2 deletions test/land-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,7 @@ test("an armed pick withdrawn, retargeted, or held while the pass reads it is no
if (pull.number === 33) pull.base = "release";
// Disabled and armed again: a new pick, which the next pass weighs on its own.
if (pull.number === 34) pull.armed = at(3);
if (pull.number === 36) pull.labels = ["quest:iterate"];
if (pull.number === 36) pull.labels = ["quest:kix"];
};
expect(await watchPass(REPOSITORY, { state }, deps(github))).toEqual([]);
expect(github.mutations).toEqual([]);
Expand Down Expand Up @@ -1867,7 +1867,7 @@ git commit -qm 'disable render_timeout'`;
reads.set(pull.number, read);
if (takeAt.get(pull.number) === read) pull.labels = [...(pull.labels ?? []), "quest:active"];
};
pulls[3].labels = ["quest:iterate"];
pulls[3].labels = ["quest:kix"];
pulls[4].labels = ["quest:active"];
github.refuse = ["#7 quest land watch: Another session holds"];
github.now = Date.parse(at(7));
Expand Down
8 changes: 4 additions & 4 deletions test/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ const holdEvent = (id: string, label: string, at = "2026-10-07T11:00:00Z") => ({
});

test("reports the latest acquisition, age, and verified paged event URL for each hold label", async () => {
const label = "quest:iterate";
const label = "quest:kix";
const body = pull({
checks: null,
reviews: page([]),
Expand Down Expand Up @@ -596,11 +596,11 @@ test("reports the latest acquisition, age, and verified paged event URL for each

test("retains a hold fact when its acquisition is outside the available timeline", async () => {
const fixture = setup([
pull({ checks: null, reviews: page([]), threads: page([]), labels: { nodes: [{ name: "quest:ready" }] } }),
pull({ checks: null, reviews: page([]), threads: page([]), labels: { nodes: [{ name: "quest:kix" }] } }),
]);
const status = await pullRequestStatus(pr, fixture.deps);
expect(status.holds).toEqual([{ label: "quest:ready", token: null, takenAt: null, age: null, eventUrl: null }]);
expect(statusLines(status)).toContain("hold quest:ready - - - unknown age");
expect(status.holds).toEqual([{ label: "quest:kix", token: null, takenAt: null, age: null, eventUrl: null }]);
expect(statusLines(status)).toContain("hold quest:kix - - - unknown age");
expect(fixture.eventRequests).toEqual([]);
});

Expand Down
Loading