Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ae19f94
fix(bin): let fm-pr-merge land a pushed stacked PR while the copy wor…
anconina Oct 3, 2026
8dfaab2
no-mistakes(review): Make PR branch checks authoritative before merging
anconina Oct 3, 2026
64ebbcf
no-mistakes(review): Verify PR branch ancestry against the live forge…
anconina Oct 3, 2026
38b06fc
no-mistakes(review): Bind PR branch containment to the verified merge…
anconina Oct 3, 2026
7595b5a
no-mistakes(document): Document stacked PR merge publication checks
anconina Oct 3, 2026
263410c
no-mistakes(ci): Captain, fixed both Greptile findings: PR-ref fetchi…
anconina Oct 3, 2026
cbc991c
Merge remote-tracking branch 'origin/main' into fm/fm-merge-stacked-head
anconina Oct 6, 2026
2949c31
no-mistakes(review): Trust no-mistakes heads, branch-check GitLab, dr…
anconina Oct 6, 2026
a343f82
no-mistakes(review): Restore merge-time registration before live veri…
anconina Oct 6, 2026
f8b343b
no-mistakes(review): Suppress parent PR-ready line on merge-time re-r…
anconina Oct 6, 2026
2d6a5f0
no-mistakes(document): Point parent-channel ready-line coverage at cu…
anconina Oct 6, 2026
da4158d
no-mistakes(ci): I fixed the Greptile P1 at bin/fm-pr-merge.sh:1114 (…
anconina Oct 6, 2026
d7d9b9b
no-mistakes(ci): Fixed the Greptile P1 ("Stacked fork merges still re…
anconina Oct 6, 2026
0ca990f
no-mistakes(ci): I finished the upstream maintainer's GitLab fork cha…
anconina Oct 6, 2026
523d4e9
no-mistakes(ci): I fixed the Greptile P1 at bin/fm-pr-merge.sh:1144 (…
anconina Oct 6, 2026
c579a30
no-mistakes(ci): I fixed the real failure in "Behavior portable paral…
anconina Oct 7, 2026
0d26806
Merge remote-tracking branch 'origin/main' into fm/fm-merge-stacked-head
anconina Oct 10, 2026
64beef8
fix(bin): merge upstream main without changing publication guards
anconina Oct 12, 2026
304afd3
no-mistakes(review): test: pin no-mistakes merges to the verified for…
anconina Oct 12, 2026
fb2a5ca
no-mistakes(review): test: correct stale ready-line comment in failed…
anconina Oct 12, 2026
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
17 changes: 16 additions & 1 deletion bin/fm-dod-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# --base-branch; empty means the repository default. A named base is the branch
# the worker starts from, never pushes to, and targets with its pull request, and
# fm_base_branch_valid refuses it where no pull request carries the work.
# Callers of the gate are bin/fm-crew-state.sh (current-state done),
# Ready-report callers of the gate are bin/fm-crew-state.sh (current-state done),
# bin/fm-pr-check.sh (PR registration), and bin/fm-inactive-reconcile.sh
# (secondmate ledger-first publish of a child done). A ship `done:` is not
# accepted while the named head exists only in the worker's disposable copy.
Expand Down Expand Up @@ -685,6 +685,21 @@ fm_dod_named_head_reachable_outside_worktree() { # <worktree> <project> <mode>
[ "$mode" = local-only ] && fm_dod_ref_contains "$project" refs/heads "$sha"
}

# Merge-time publication check; bin/fm-pr-merge.sh's header owns its contract.
# Returns 0 for verified containment, nonzero otherwise.
fm_dod_pr_branch_pushed() { # <worktree> <branch> <forge-head>
local wt=$1 branch=$2 forge_head=$3 tip
[ -n "$wt" ] && [ -d "$wt" ] || return 1
fm_pr_head_valid "$forge_head" || return 1
git check-ref-format --branch "$branch" >/dev/null 2>&1 || return 1
tip=$(git -C "$wt" rev-parse --verify --quiet "refs/heads/$branch^{commit}" 2>/dev/null) || return 1
[ "$tip" = "$forge_head" ] && return 0
if ! git -C "$wt" cat-file -e "$forge_head^{commit}" 2>/dev/null; then
git -C "$wt" fetch --quiet --no-tags -- origin "$forge_head" >/dev/null 2>&1 || return 1
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
fi
git -C "$wt" merge-base --is-ancestor "$tip" "$forge_head" 2>/dev/null
}

# 0 when <line> is not a ship done: to gate, when it names the task's recorded
# PR whose head the forge holds, when it names a Gerrit change whose current
# patch set carries the worker copy's HEAD tree, or otherwise when its named
Expand Down
36 changes: 20 additions & 16 deletions bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# Record a PR-ready task: store one validated canonical pr=<url> and the forge's
# exact pr_head=<sha> when available, then atomically arm a static merge poll.
# Refuses when bin/fm-dod-lib.sh will not accept the named head as reachable
# outside the worker's disposable copy; in no-mistakes mode a forge-reported
# head is that named head and is already stored on the forge.
# PR-ready registration uses the named-head gate owned by bin/fm-dod-lib.sh.
# The watcher check source is byte-for-byte bin/fm-pr-poll.sh; task and PR data
# live only in a private sidecar and are never interpolated into shell source.
# A GitHub pull request URL, a GitLab merge request URL, and a Gerrit change URL
Expand All @@ -16,7 +14,8 @@
# draft on purpose declares a wait instead of reporting done. An unreadable
# draft state does not refuse, matching how the head read below is optional.
# bin/fm-pr-merge.sh records through this script with FM_PR_CHECK_MERGE=1 and
# skips this refusal, because its own merge-time draft refusal is authoritative.
# skips this refusal and the named-head gate below, because its own merge-time
# draft and named-head checks are authoritative.
# Usage: fm-pr-check.sh <task-id> <pr-url>
set -eu

Expand Down Expand Up @@ -142,7 +141,8 @@ case "$PROVIDER:$MODE" in
*:no-mistakes|*:) DONE_LINE="done: PR $URL checks green" ;;
*) DONE_LINE="done: PR $URL" ;;
esac
if { [ -z "$PR_HEAD" ] || ! fm_dod_forge_head_is_named_head "$MODE"; } \
if [ "${FM_PR_CHECK_MERGE:-}" != 1 ] \
&& { [ -z "$PR_HEAD" ] || ! fm_dod_forge_head_is_named_head "$MODE"; } \
&& ! GATE_REASON=$(fm_dod_accept_ship_done "${KIND:-ship}" "$MODE" "$WT" "$PROJECT" "$DONE_LINE" "$STATE" "$ID" "$META"); then
echo "error: $GATE_REASON" >&2
exit 1
Expand Down Expand Up @@ -236,15 +236,19 @@ fi
# silent no-op there. The poll is armed either way; a channel that cannot be
# written is reported as actionable, and bin/fm-inactive-reconcile.sh still
# delivers the child's own ready line on the next supervision poll.
READY_LINE="done [key=child-pr-$ID]: child $ID PR ready: $URL"
PR_MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true)
PR_YOLO=$(grep '^yolo=' "$META" | tail -1 | cut -d= -f2- || true)
[ -z "$PR_MODE" ] || READY_LINE="$READY_LINE mode=$(fm_parent_channel_clean_note "$PR_MODE")"
[ -z "$PR_YOLO" ] || READY_LINE="$READY_LINE yolo=$(fm_parent_channel_clean_note "$PR_YOLO")"
READY_RC=0
fm_parent_channel_report "$FM_HOME" "$STATE" "$READY_LINE" || READY_RC=$?
case "$READY_RC" in
0|1) ;;
*) printf 'actionable: PR %s is registered but its ready line did not reach the parent channel (rc=%s)\n' "$URL" "$READY_RC" >&2 ;;
esac
# The merge-time re-record publishes no ready line, as with the ledger above,
# because bin/fm-pr-merge.sh can still refuse the merge after it.
if [ "${FM_PR_CHECK_MERGE:-}" != 1 ]; then
READY_LINE="done [key=child-pr-$ID]: child $ID PR ready: $URL"
PR_MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true)
PR_YOLO=$(grep '^yolo=' "$META" | tail -1 | cut -d= -f2- || true)
[ -z "$PR_MODE" ] || READY_LINE="$READY_LINE mode=$(fm_parent_channel_clean_note "$PR_MODE")"
[ -z "$PR_YOLO" ] || READY_LINE="$READY_LINE yolo=$(fm_parent_channel_clean_note "$PR_YOLO")"
READY_RC=0
fm_parent_channel_report "$FM_HOME" "$STATE" "$READY_LINE" || READY_RC=$?
case "$READY_RC" in
0|1) ;;
*) printf 'actionable: PR %s is registered but its ready line did not reach the parent channel (rc=%s)\n' "$URL" "$READY_RC" >&2 ;;
esac
fi
printf 'armed: state/%s.check.sh\n' "$ID"
75 changes: 71 additions & 4 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,31 @@
# recorded value stale. Reading that state needs glab and jq, and either one
# absent stops the merge before any state is recorded.
#
# Right before either forge merge, require_pr_branch_published checks the
# task's named head against the head verified above, after any retries. In
# no-mistakes mode that verified head is the named head, as bin/fm-dod-lib.sh's
# fm_dod_forge_head_is_named_head owns: the pipeline pushes from its own
# checkout and may rebase the branch, so the worker copy's branch can be stale.
# In direct-PR mode the forge's head branch, GitHub's headRefName or GitLab's
# source_branch from the verified view, must be readable and valid. If that
# branch exists in the worker copy, its tip must equal or be an ancestor of the
# verified head. A later unpushed HEAD on another local branch does not block
# the merge, but commits on the PR branch missing from that head do, even when
# another remote branch contains them. bin/fm-dod-lib.sh's
# fm_dod_pr_branch_pushed fetches a missing verified head from origin by SHA
# without switching branches or moving local or remote-tracking refs, and
# failure to prove containment refuses. If the branch is absent from the copy,
# or the GitLab merge request comes from a fork, whose source branch a
# same-named branch in the copy is not, fm_dod_accept_ship_done gates the
# copy's HEAD instead. Ordinary PR-ready registration still follows
# bin/fm-dod-lib.sh's contract. The merge-time
# registration before the live reads skips that gate, because this later check
# is authoritative, so a publication refusal leaves pr= recorded and the merge
# poll armed as a failed live verification does. That re-record sends no
# PR-ready line to a parent channel, so a refused merge never reports the PR
# ready. tests/fm-pr-check-security.test.sh and tests/fm-pr-merge.test.sh cover
# these merge-time publication guards.
#
# Before either forge merge, the task's existing per-task control lock
# serializes the captain-hold check through the forge command. A still-held or
# unreadable row refuses before that command, so a captain approval must be
Expand Down Expand Up @@ -152,6 +177,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-dod-lib.sh
. "$SCRIPT_DIR/fm-dod-lib.sh"
# shellcheck source=bin/fm-backlog-transition-lib.sh
. "$SCRIPT_DIR/fm-backlog-transition-lib.sh"
# shellcheck source=bin/fm-merge-outcome-lib.sh
Expand Down Expand Up @@ -448,15 +475,19 @@ if [ "$PROVIDER" = gitlab ]; then
fi

# Pre-merge conditions for a GitLab merge request, read from one live view of
# the merge request. Sets FM_PR_MERGE_HEAD to the verified head on success and
# returns non-zero after reporting every condition that failed.
# the merge request. Sets FM_PR_MERGE_HEAD to the verified head,
# FM_PR_MERGE_BRANCH to its source branch, and FM_PR_MERGE_FORK to whether that
# branch lives in another project on success, and returns non-zero after
# reporting every condition that failed.
FM_PR_MERGE_HEAD=
FM_PR_MERGE_BRANCH=
FM_PR_MERGE_FORK=false
FM_PR_GITLAB_ASYNC_CONFIGURED=false
gitlab_verify_mergeable() {
local json fields line
local total=0 named=0 refusals=''
local state='' detail='' conflicts='' discussions=''
local live_head='' pipeline_sha='' pipeline_status='' async_configured=''
local live_head='' source_branch='' fork='' pipeline_sha='' pipeline_status='' async_configured=''

# GITLAB_HOST is set to the same host the project URL already carries, so the
# instance is taken from the parsed URL by both signals and never from the
Expand All @@ -477,6 +508,8 @@ gitlab_verify_mergeable() {
"conflicts=" + (.has_conflicts | tostring),
"discussions=" + (.blocking_discussions_resolved | tostring),
"head=" + ((.sha // "") | tostring),
"branch=" + ((.source_branch // "") | tostring),
"fork=" + (.source_project_id != .target_project_id | tostring),
"pipeline_sha=" + ((.head_pipeline.sha // "") | tostring),
"pipeline_status=" + ((.head_pipeline.status // "") | tostring),
"async_configured=" + (if .merge_when_pipeline_succeeds == true or (.merge_after != null) then "true" else "false" end)
Expand All @@ -494,6 +527,8 @@ gitlab_verify_mergeable() {
conflicts=*) conflicts=${line#conflicts=} ;;
discussions=*) discussions=${line#discussions=} ;;
head=*) live_head=${line#head=} ;;
branch=*) source_branch=${line#branch=} ;;
fork=*) fork=${line#fork=} ;;
pipeline_sha=*) pipeline_sha=${line#pipeline_sha=} ;;
pipeline_status=*) pipeline_status=${line#pipeline_status=} ;;
async_configured=*) async_configured=${line#async_configured=} ;;
Expand All @@ -506,7 +541,7 @@ FIELDS
# Every field named exactly once and no unnamed line: a value carrying a
# newline would split into a line no name matches, so it is refused here
# rather than silently truncated into a value a check could accept.
if [ "$named" -ne 8 ] || [ "$total" -ne 8 ]; then
if [ "$named" -ne 10 ] || [ "$total" -ne 10 ]; then
echo "error: could not read the GitLab merge request state before merging" >&2
return 1
fi
Expand Down Expand Up @@ -549,6 +584,8 @@ FIELDS
printf 'verified: %s is open and mergeable, with a successful pipeline at head %s\n' \
"$URL" "$live_head" >&2
FM_PR_MERGE_HEAD=$live_head
FM_PR_MERGE_BRANCH=$source_branch
FM_PR_MERGE_FORK=$fork
FM_PR_GITLAB_ASYNC_CONFIGURED=$async_configured
}

Expand Down Expand Up @@ -1072,6 +1109,34 @@ record_pr_metadata() {
}
}

# The merge-time publication check this file's header owns, against
# FM_PR_MERGE_HEAD. Returns non-zero after reporting why it refused.
require_pr_branch_published() {
local mode wt branch kind tip reason
mode=$(fm_dod_meta_value "$META" mode)
fm_dod_forge_head_is_named_head "$mode" && return 0
wt=$(fm_dod_meta_value "$META" worktree)
case "$PROVIDER" in
github) branch=$(cd "$wt" && gh pr view "$URL" --json headRefName -q .headRefName 2>/dev/null) || branch= ;;
*) branch=$FM_PR_MERGE_BRANCH ;;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
esac
if ! git check-ref-format --branch "$branch" >/dev/null 2>&1; then
echo "error: pull request head branch could not be verified" >&2
return 1
fi
if [ "$FM_PR_MERGE_FORK" != true ] \
&& tip=$(git -C "$wt" rev-parse --verify --quiet "refs/heads/$branch^{commit}" 2>/dev/null); then
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
fm_dod_pr_branch_pushed "$wt" "$branch" "$FM_PR_MERGE_HEAD" && return 0
echo "error: named head $tip could not be verified in pull request head $FM_PR_MERGE_HEAD" >&2
return 1
fi
kind=$(fm_dod_meta_value "$META" kind)
reason=$(fm_dod_accept_ship_done "${kind:-ship}" "$mode" "$wt" "$(fm_dod_meta_value "$META" project)" \
"done: PR $URL" "$STATE" "$ID" "$META") && return 0
echo "error: $reason" >&2
return 1
}

require_released_captain_hold() {
local hold_status=0
FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" \
Expand Down Expand Up @@ -1384,6 +1449,7 @@ case "$PROVIDER" in
require_current_away_authority || away_status=$?
[ "$away_status" -eq 0 ] || exit "$away_status"
refuse_github_queue_while_away || exit 2
require_pr_branch_published || exit 1
merge_status=0
merge_output=$(gh pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" \
--match-head-commit "$FM_PR_MERGE_HEAD" \
Expand Down Expand Up @@ -1438,6 +1504,7 @@ case "$PROVIDER" in
away_status=0
require_current_away_authority || away_status=$?
[ "$away_status" -eq 0 ] || exit "$away_status"
require_pr_branch_published || exit 1
merge_status=0
gitlab_merge_args=()
if [ "$FM_PR_AWAY_POSTURE" = true ]; then
Expand Down
3 changes: 1 addition & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,7 @@ Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-v
This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting.
PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI.
The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state.
A `https://github.com/<owner>/<repo>/pull/<n>` URL requires `gh` and `jq`, is merged only after live reads confirm the pull request is open, not a draft, mergeable, conflict-free, every unwaived check is green at the current head, and every unwaived check the base branch requires has reported at that head, then `gh pr merge` binds that verified head with `--match-head-commit`.
A required check that never reported is absent from the checks list rather than red; [`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns required-context sources, producer identity, partial-read refusals, and attended check waivers.
[`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns the GitHub pre-merge checks, including required-context sources, producer identity, unreadable-state refusals, attended check waivers, and binding the merge to the verified head, as well as the PR-branch publication check both forges run before merging.
A check run is green when its current run is green, because GitHub leaves a cancelled run in the rollup beside the passing re-run it triggered when the base branch advanced; `bin/fm-pr-merge.sh`'s `github_checks_not_green` owns the rule, which uses `startedAt` to clear only an older completed check run that a passing run with the same name provably replaced, while unfinished check runs and non-green status contexts stay red.
`--auto`, `--admin`, and branch-deletion flags are refused unless `--attended-override` is passed for an explicit captain instruction; that override never skips the live green check, the away-record read, or a captain hold.
Because away merge authority is read from that record and then acted on by the forge, the authority read and synchronous forge command share the record's cross-subsystem lock, closing the common live-owner TOCTOU.
Expand Down
6 changes: 3 additions & 3 deletions docs/secondmate-parent-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Every captain-facing outcome that leaves durable evidence in the mate home is pu

| Outcome | Durable evidence in the mate home | Published by |
|---|---|---|
| Ship child PR ready | the child's `done:` PR ready line, whose accepted spellings the publisher below owns; `pr=` in the child's record once registered | `bin/fm-inactive-reconcile.sh` on the next poll with the child's line; `bin/fm-pr-check.sh` at registration with the canonical URL |
| Ship child PR ready | the child's `done:` PR ready line, whose accepted spellings the publisher below owns; `pr=` in the child's record once registered | `bin/fm-inactive-reconcile.sh` on the next poll with the child's line; `bin/fm-pr-check.sh` at registration with the canonical URL, but not on `bin/fm-pr-merge.sh`'s merge-time re-record |
| Scout child findings | the child's `done:` line plus `data/<child>/report.md` | `bin/fm-inactive-reconcile.sh` on the next poll, with the report pointer |
| Child failed | the child's `failed:` line | `bin/fm-inactive-reconcile.sh` on the next poll |
| Child decision escalated to the captain | the task held for the captain in the mate backlog | `bin/fm-captain-hold.sh hold`, and its answer by `answer` |
Expand Down Expand Up @@ -53,8 +53,8 @@ A missed-reply escalation includes the complete first sighting path and line num

`tests/fm-inactive-reconcile.test.sh` covers the ledger delivery against real ledgers with no harness: immediate done and failed delivery with note, PR, mode, posture, and report pointer, once-only delivery across polls, a ship `done:` withheld while its named head exists only in the worker copy, a pending one still delivered after teardown removes that copy, a line still being appended, later routine status prose not minting a fresh parent event because the inactive receipt identity binds structured fields only, the remote route, the yield of the inactive path to a terminal ledger, and the real watcher poll driving it.
`tests/fm-captain-hold-lifecycle.test.sh` covers a mate home publishing a hold, its answer, and a distinct occurrence on re-hold, and a main home publishing nothing.
`tests/fm-pr-merge.test.sh` covers the PR-ready line at registration and the merge outcome's upward report.
`tests/fm-teardown.test.sh` covers teardown delivering a child's final line and refusing when the channel cannot be written.
`tests/fm-pr-merge.test.sh` covers the merge outcome's upward report and the merge-time re-record publishing no PR-ready line.
`tests/fm-teardown.test.sh` covers the PR-ready line at registration, teardown delivering a child's final line, and refusing when the channel cannot be written.
`tests/fm-brief.test.sh` pins the charter's channel rule.
`tests/fm-pending-reply.test.sh` covers helper-selected local routing, remote-channel classification, same-basename restatement before false escalation, readable wrong-home diagnostics, and the rule that arbitrary mate-home sightings never acknowledge a reply.
`tests/fm-parent-channel-scan-exclusion.test.sh` covers the home-shape-aware scan exclusion against real remote, main-home, and local-mate fixtures: the watcher signal scan, both heartbeat backstops, the fleet-wide folds, and the real `fm-wake-drain.sh` end to end.
Expand Down
Loading