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
32 changes: 32 additions & 0 deletions claude-plugins/fabrika/skills/operate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,38 @@ drifted back into the driver's checkout is caught at the one step every publicat
rather than after the fact. The remedy is never a flag: place the run's worktree with
`lane assembly` and push from there.

**A push of the assembly branch leaves every open child PR on it reporting checks over the old
base, so retrigger them in the same breath.** GitHub rebuilds `refs/pull/<n>/merge` when a base
moves — a merge ref fetched over a PR whose head had not been pushed for eleven days carried that
morning's trunk tip as its first parent — but it emits no `pull_request` event for a base push:
the event fires on `opened`, `synchronize` and `reopened`, and a base push is none of them. So
nothing schedules a run, and the red the child inherited from the base you just fixed stays on the
PR until something moves its head:

```bash
node packages/fabrika-cli/src/bin.ts lane retrigger $lane_key
```

It sweeps every OPEN pull request based on `epic/<n>` and moves the head of each one that is behind
the branch, through GitHub's own branch update — a merge of the base into the head branch, which is
a `synchronize`, so the run is scheduled against a merge ref computed now. It is safe to run after
every push: a child whose head already carries the base tip is read, reported `current` and never
written to, so a second call schedules nothing. `RETRIGGER-VERDICT: NONE` says no open PR sits on
the branch at all, which is the ordinary answer under this shape — the run opens one PR and its
children open none — and it costs one board read.

**Never close and reopen a PR to force this.** A reopen does rebuild the merge ref, and it also
tears the PR's preview stage down mid-deploy — which is the reason it is forbidden rather than
merely discouraged, and `lane retrigger`'s own reference row carries the report that fallout was
filed under. It raced the rebuild the one time it was used, so the guard re-ran against the stale
base anyway and took two rounds. Re-running the workflow is no route either: a re-run replays the original event's
`GITHUB_SHA` and `GITHUB_REF`, which is the stale merge commit — the very red you are clearing.
Exit `42` is a child the assembly branch does not merge into, and that is a repair round on that
child rather than anything to retry here; exit `8` says the sweep has already moved a child's head
and then lost its read — an accepted update whose head had not moved inside its window, or a read
that failed after that write — so re-read before writing again. Exit `11` is the sweep that wrote to
nothing, which is the one you can simply re-run.

**The first of those pushes also opens the run's one PR**, as a draft — a draft carries the CI
signal and the board's view of the run without inviting a review the machine has not asked for.
Open it yourself; no shell owns this branch. Its body carries three
Expand Down
1 change: 1 addition & 0 deletions packages/fabrika-cli/docs/verb-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ board thread stands behind it
| `lane refresh` | the trunk merged into an epic run's assembly branch, in the worktree `lane assembly` placed — nothing else in this package touches trunk after the first cut, so the branch drifts behind `main` while the children build and the queue ejects the tail for it. Refuse a dirty seat, `git fetch origin`, resolve `--base` to a commit, answer `CURRENT` when the branch already carries it, else `git merge --no-ff` and re-read HEAD; on exit 0 the last stdout line is `REFRESH-VERDICT: MERGED`, `CURRENT` or `DECLINED` and the line above it the head. A conflict aborts, resets through `ORIG_HEAD` and **proves** the reset by re-reading HEAD, and names `--cause assembly-conflict` as the park to record; a reset that will not take is `8`, never the clean `42`. Two callers reach it automatically and each is gated by its own `assemblyRefresh` arm, both shipped `off`: `--on-review`, typed by the driver on the tail's way into review (`onReview`), and the pre-dispatch call `lane dispatch` makes itself before it cuts a child's worktree off the branch (`onDispatch`), which is never typed. Both arms are read from the `.fabrika.jsonc` of the repository that OWNS the cwd, so a linked worktree reads the primary checkout's copy rather than its own tracked one (#9159). A driver typing the verb is never gated. It pushes nothing and records nothing |
| `lane dispatch` | run one active task through Codex in a verified worktree; [contract](./codex-dispatch.md). On an epic run's child it first refreshes the assembly branch the worktree is about to be cut from — `lane refresh`'s own code, before the brief is emitted, under `assemblyRefresh.onDispatch`, read from the `.fabrika.jsonc` of the repository that OWNS the cwd while the worktree spawning below keeps reading the cwd itself. That gate refuses only `11`, a repository identity it cannot read; a cwd in no repository at all is not a refusal there and reads the shipped arm at itself. The `39` this verb still exits is the lanes-root resolution ahead of every read, on either of its two arms: `--root` absent and no repository derivable off the cwd, or a present `--root` that is relative and a cwd that itself holds neither `.fabrika` nor `.git`. A conflict there is `42` with the branch proven back where it stood and no worktree created: record the park it names rather than dispatching over the unrefreshed branch |
| `lane assembly` / `push` | an epic run's assembly worktree, and its published branch. Every resume fetches and asks whether `origin/HEAD` already carries the content of the branch it found — the state a multi-phase epic lands in the moment it ships an intermediate tail. The read is squash-aware, because every landing here is a squash and ancestry alone answers "not contained" for every branch there is: ancestry is the fast path, and what survives it is settled by cumulative patch id — the branch's net diff against the trunk, matched against the patches the trunk took since their merge base, limited to the paths the branch touches. A contained branch is re-cut off the trunk in one `worktree add --no-track -B` and the note says which proof opened it; its seat is dropped first without `--force`, so git refusing a dirty tree keeps unlanded bytes out of the re-cut. Anything less than containment resumes as it always did, no board is read, and an unreadable answer is `11` |
| `lane retrigger` | fresh checks scheduled on every OPEN pull request based on an epic run's assembly branch, after `lane push` moved it. The platform was read rather than assumed: GitHub DOES recompute `refs/pull/<n>/merge` when a base moves — PR #8063's merge ref carried that morning's trunk tip as its first parent while its head had not been pushed for eleven days — but a base push emits no `pull_request` event (the event fires on `opened`, `synchronize`, `reopened`), so nothing schedules a run and every child keeps reporting checks over the tree its base held before the push; #8063's check runs were all stamped the day its head was pushed, hundreds of trunk commits ago. A re-run cannot serve, because it replays the original event's `GITHUB_SHA` and `GITHUB_REF`, which is the stale merge commit, and close/reopen is forbidden — it tears the pull request's preview stage down mid-deploy ([#8881](https://github.com/kamp-us/phoenix/issues/8881)). So the head is moved instead, through `PUT /pulls/{n}/update-branch`: the base is merged into the head branch, which IS a `synchronize`, so the run is scheduled against a merge ref computed now, with nothing closed, nothing force-pushed and no commit rewritten. Idempotent by construction — a child whose head already carries the base tip is read, reported `current` and never written to, so a second call writes nothing — and the staleness read is the platform's own comparison against the base as it stands, never the pull request's frozen `base.sha`. Each write carries `expected_head_sha`, so a head a sibling moved first refuses the write rather than misaddressing it; the 422 that refusal shares with a conflict is split by re-reading the head, and a head that moved is reported as the move it was going to buy. On exit 0 the last stdout line is `RETRIGGER-VERDICT: RETRIGGERED`, `CURRENT` (children exist, all already carried the base) or `NONE` (no open pull request on the branch), and the lines above it one row per child: `#<pr> current`, or `#<pr> <commits behind> behind, <head before> -> <head after>`, the count read from the comparison's own `behind_by` so a driver sees whether the base drifted by one commit or a hundred. `8` an accepted update whose head did not move inside its 60s window, or a read that failed after this sweep had already moved a child's head — UNKNOWN, so re-read before writing again; `11` the list or a comparison unread while this sweep had written to no child, so the sweep is untouched and safe to re-run — never an empty sweep; `42` the assembly branch does not merge into a head branch, which is that child's repair round and not this verb's. It touches no working tree, pushes nothing and records nothing |
| `lane assembly-pr` | the assembly PR's title and `## About this epic` section, derived from the epic issue — `--field title` prints `feat(epic): <the epic's own title>`, whose conventional type is `build/pr-title.ts`'s map rather than a second copy of the release-please rule, and `--field about` prints the section derived from the epic's `## Pitch` **Problem** paragraph — a closing keyword swapped for a word GitHub's documented keyword list does not carry (the reference left as written), cut to its opening sentences under a word budget with `[…]` marking what a triage-length Problem left behind, and lifted as a block quote so a label or a control-plane phrase in the epic's own sentence is reproduced rather than reworded, which is the shape `build pr`'s body guard exempts. The assembled section is then re-read through that guard's own predicates so it cannot be one it refuses. Bare values, one per call, because the operate skill's `gh pr create` fence interpolates them and derives nothing itself. An unpitched epic is an ANSWER — empty stdout, the reason on stderr — so a run still publishes its PR with no section. It opens and edits no pull request |
| `lane assembly-body` | the guard the epic run's single assembly PR body passes through on its way to `gh pr create` — the body arrives on STDIN and leaves on stdout unchanged when a closing keyword aims at the epic — the same bytes, plus a trailing newline when the body lacked one — so the fence pipes rather than deriving. A body reaching the epic through `Part of #<epic>`, or closing only its landed children, is refused on `58` with nothing printed, and a pipe carrying nothing opens no PR: one epic run is one branch and one PR, so a tail that does not close its epic folds the lane to `shipped` and then `complete` over an epic the board still calls open (ADR 0382). The link reader is `issueRefsOf`, the very one `lane/closure.ts` judges the merged PR with, so this refuses exactly the bodies that reader would later call partial or leave unreadable and the two seams cannot disagree. Membership, not first match — a tail carries one closing keyword per landed child, and those are not judged. Reads no board and takes no `--repo`: the number's epic-ness was established one command earlier by `lane assembly-pr`'s `56`. It opens, edits and reads back no pull request |
| `lane integrate` | one reviewed child merged into that worktree, its dependencies reconciled from the merged lockfile, then judged by the repo's `codeValidators` — last stdout line on exit 0 is `INTEGRATE-VERDICT: MERGED`, the line above it the merged head; every refusal below the merge resets the branch and pushes nothing. Under `assemblyReplay.onCollision` (shipped `off`) a colliding child is replayed onto the tip instead of stopping the run: the plain keep-both hunks are kept both ways, the child's own branch is moved onto the replayed range so its next integrate is up to date rather than a second collision, and that range is merged `--no-ff` like any other landing; the verdict is `INTEGRATE-VERDICT: REPLAYED` over a machinery event naming the moved range the child owes one review round over. A hunk that is not a plain keep-both restores, proves the reset, and parks on `--cause replay-conflict`; a child branch that will not move is exit `54` with nothing merged, and a working tree standing on it is the usual reason. A refusal below a replayed merge puts the child branch back on the revision its reviewer graded as well as resetting the assembly branch, so a replay the verb did not keep moves no range — and a branch that will not go back is `8`, never the red |
Expand Down
153 changes: 153 additions & 0 deletions packages/fabrika-cli/src/io/pulls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,156 @@ export const pullsForBranch = (
return ok(out);
}),
);

/**
* One open pull request sitting on a base branch: exactly what judging its staleness and moving it
* needs, which is the number to address and the head to compare and to guard the write with.
*/
export interface BasePull {
readonly number: number;
readonly headSha: string;
}

/**
* Every OPEN pull request whose base ref is `base`, paged.
*
* The mirror of {@link pullsForBranch}, which asks the other question: that one takes a head and
* reads any state, because "what pull request does this work sit on" is often about a closed one.
* This one takes a base and reads open only — a closed pull request schedules nothing and a merged
* one has nothing left to run, so widening the state would hand a caller rows it must drop again.
*/
export const openPullsForBase = (
repo: string,
base: string,
): Shell<Attempt<ReadonlyArray<BasePull>>> =>
authed((token) =>
Effect.gen(function* () {
const page = yield* pagedWithLinkProof(
token,
`repos/${repo}/pulls?state=open&base=${encodeURIComponent(base)}`,
);
if (page._tag === "Failure") return page;
if (!page.value.exhausted) {
return fail(`the open pull request list for base \`${base}\` was not read to its end`);
}
const out: BasePull[] = [];
for (const value of page.value.entries) {
const headNode = isRecord(value) ? value.head : null;
const sha = isRecord(headNode) && typeof headNode.sha === "string" ? headNode.sha : null;
if (!isRecord(value) || typeof value.number !== "number" || sha === null) {
return fail("GitHub answered 200 but one entry is not a pull request");
}
out.push({number: value.number, headSha: sha});
}
return ok(out);
}),
);

/** How a head stands to a base right now, in the platform's vocabulary and its own count. */
export interface BaseStanding {
readonly status: CompareStatus;
/**
* Commits the base holds and the head does not — what makes the head's last CI run stale.
*
* Read by `lane retrigger`, which prints it on the row for each child it moved: the status alone
* says a child was behind, and this says by how much, which is what tells a driver whether the
* base drifted by one commit or by a hundred.
*/
readonly behindBy: number;
}

/**
* Where a pull request's head stands against its base, with no file list in the answer.
*
* Separate from {@link compareFiles} because it asks a different question and pays a different
* price: a staleness read wants the envelope's `status` and `behind_by` and nothing else, so it
* takes `per_page=1` rather than walking up to {@link COMPARE_FILE_CAP} entries per pull request.
* Both fields sit on the envelope and do not page, so the narrowed page changes no answer here.
*
* The pull request record's own `base.sha` cannot serve: the platform freezes it at the commit the
* PR was opened against, so a PR whose base has since moved a hundred commits still reads its
* original sha there. This comparison is the read that answers against the base as it stands now.
*/
export const compareStanding = (
repo: string,
base: string,
head: string,
): Shell<Attempt<BaseStanding>> =>
authed((token) =>
restCall(token, {
method: "GET",
path: `repos/${repo}/compare/${base}...${head}?per_page=1`,
}).pipe(
Effect.map((outcome) =>
attemptOf(outcome, (body) => {
if (!isRecord(body)) {
return fail("GitHub answered 200 but its output is not a comparison");
}
if (typeof body.status !== "string" || !COMPARE_STATUSES.includes(body.status)) {
return fail(
"GitHub answered 200 but its comparison declares no status, so where the head stands against the base is unknown",
);
}
if (typeof body.behind_by !== "number") {
return fail(
"GitHub answered 200 but its comparison declares no behind_by, so how far the head trails the base is unknown",
);
}
return ok({status: body.status as CompareStatus, behindBy: body.behind_by});
}),
),
),
);

/**
* What the platform did with a branch-update request: accepted it, declined it, or never answered.
*
* `Accepted` is the documented 202 and it is not a landing — the endpoint serves "Updating pull
* request branch." and performs the merge asynchronously, so a caller proves the merge by watching
* the head move, never by reading this tag. `Declined` is the 422 the endpoint documents for every
* validation failure it has: a conflict and a stale `expected_head_sha` share that one status and
* the page names no field that tells them apart, so the reason travels verbatim and the split is
* the caller's to prove by re-reading the head ([REST, "Update a pull request
* branch"](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#update-a-pull-request-branch)).
*/
export type BranchUpdate =
| {readonly _tag: "Accepted"}
| {readonly _tag: "Declined"; readonly reason: string}
| {readonly _tag: "Unreadable"; readonly reason: string};

/**
* Merge a pull request's base into its head branch through the platform's own update endpoint.
*
* `expectedHeadSha` is the concurrency guard that endpoint documents: the update is refused rather
* than applied when the head is no longer the commit the caller read. Passing it keeps the write
* addressed to the pull request the caller judged stale, instead of to whatever landed since.
*/
export const updatePullBranch = (
repo: string,
pr: number,
expectedHeadSha: string,
): Shell<BranchUpdate> =>
Effect.map(
authed((token) =>
Effect.map(
restCall(token, {
method: "PUT",
path: `repos/${repo}/pulls/${pr}/update-branch`,
body: {expected_head_sha: expectedHeadSha},
}),
(outcome): Attempt<BranchUpdate> => {
if (outcome._tag === "Unreachable") {
return ok({_tag: "Unreadable", reason: outcome.reason});
}
if (outcome.status === 202) return ok({_tag: "Accepted"});
return ok(
outcome.status === 422
? {_tag: "Declined", reason: refusalText(outcome)}
: {_tag: "Unreadable", reason: refusalText(outcome)},
);
},
),
),
(attempt): BranchUpdate =>
attempt._tag === "Failure" ? {_tag: "Unreadable", reason: attempt.reason} : attempt.value,
);
Loading
Loading