From 7335c6951a0ecb560b6dea94373aa8699cf19dd8 Mon Sep 17 00:00:00 2001 From: Tejas Shah Date: Sun, 20 Sep 2026 13:27:27 +0700 Subject: [PATCH 1/4] feat(spawn): add a per-spawn --base branch for ship and scout tasks Supersedes #3, which cannot be revalidated: that branch's head was replaced outside the pipeline, and no-mistakes then refuses to reconcile its private mirror record for the branch. Same content, clean branch from main. A task worktree's base branch comes from the project's own declaration, so honouring the captain's integration-branch ruling means editing shared state at the start of an effort and remembering to restore it at the end - and a forgotten restore bases the next unrelated fix on a dead integration branch with nothing reporting it. bin/fm-spawn.sh now accepts --base , which governs one spawn only: it takes precedence over bin/fm-project-base.sh's resolution and the repository default when a pooled worktree is freshened, writes nothing back, refuses the spawn naming the source rather than falling back when the branch does not resolve, and is refused alongside --relaunch and --secondmate. The resolved base is recorded as base= in the task's metadata so a relaunch keeps the branch the task was created against. --- .agents/skills/project-management/SKILL.md | 2 + bin/fm-spawn.sh | 123 +++++++++++--- docs/architecture.md | 3 + tests/fm-control-relaunch.test.sh | 5 + tests/fm-spawn-base-branch.test.sh | 180 ++++++++++++++++++++- 5 files changed, 283 insertions(+), 30 deletions(-) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 206ad13115e..c370f0e5219 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -72,6 +72,8 @@ Firstmate never writes that file itself, because `AGENTS.md` hard rule 1 forbids For a project firstmate creates, write and commit it as part of that project's initial local content, which the captain's creation request authorizes. For a project firstmate clones, record the branch as `base=` in the registry entry so dispatch is correct immediately, and land `.firstmate-base` through the project's own delivery path as its own small task. +The resolved branch is the project's standing base, so never redirect one effort by editing it: `bin/fm-spawn.sh --base ` names the base for a single spawn, which is how an effort accumulating on one integration branch leaves shared state untouched. + ## Add or clone an existing project Confirm the source URL, local project name, delivery posture, autonomy posture, and development branch, stating the resolved default for each rather than asking the captain to invent one. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 36422519902..4a937046087 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] -# fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] +# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--base ] +# fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--base ] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate # --mode and --yolo are this task's delivery contract, REQUIRED for every ship # spawn and refused on --scout and --secondmate spawns. Firstmate resolves both @@ -73,6 +73,20 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. +# --base is the explicit base branch for this exact ship or scout +# spawn. It governs that spawn alone and is never written back to +# data/projects.md, so an effort that must accumulate on one integration +# branch never edits the shared registry and never depends on remembering to +# restore it. Precedence is --base, then the project's registry base= record, +# then the repository default branch, unchanged from before this flag. It +# resolves exactly as a registry base= does: origin/ for a shared +# clone, the local branch in local-only mode or when no origin is configured. +# A base that does not resolve refuses the spawn naming the branch rather than +# quietly falling back to the registry value or the default, so a worker never +# launches on a base other than the one it was dispatched for. The resolved +# base is recorded as base= in state/.meta; --relaunch reuses that record +# like every other identity axis and refuses --base alongside it. --secondmate +# has no task worktree and refuses the flag too. # A herdr crewmate or scout is placed in the exact workspace of the firstmate # or secondmate process launching it, resolved from that process's own herdr # pane rather than from a workspace label (herdr enforces no label uniqueness, @@ -521,6 +535,7 @@ HARNESS_ARG= MODEL= EFFORT= BACKEND_ARG= +BASE_ARG= MODE= YOLO= TRACEPARENT_ARG= @@ -528,6 +543,7 @@ HARNESS_SET=0 MODEL_SET=0 EFFORT_SET=0 BACKEND_SET=0 +BASE_SET=0 MODE_SET=0 YOLO_SET=0 TRACEPARENT_SET=0 @@ -559,6 +575,10 @@ for a in "$@"; do BACKEND_ARG=$a BACKEND_SET=1 ;; + base) + BASE_ARG=$a + BASE_SET=1 + ;; mode) MODE=$a MODE_SET=1 @@ -609,6 +629,11 @@ for a in "$@"; do BACKEND_ARG=${a#--backend=} BACKEND_SET=1 ;; + --base) want_value=base ;; + --base=*) + BASE_ARG=${a#--base=} + BASE_SET=1 + ;; --mode) want_value=mode ;; --mode=*) MODE=${a#--mode=} @@ -647,6 +672,10 @@ done echo "error: --backend requires a non-empty value" >&2 exit 1 } +[ "$BASE_SET" -eq 0 ] || [ -n "$BASE_ARG" ] || { + echo "error: --base requires a non-empty value" >&2 + exit 1 +} [ "$MODE_SET" -eq 0 ] || [ -n "$MODE" ] || { echo "error: --mode requires a non-empty value" >&2 exit 1 @@ -689,6 +718,10 @@ if [ "$RELAUNCH" -eq 1 ]; then echo "error: --relaunch reuses the task's recorded backend; --backend cannot override it" >&2 exit 1 } + [ "$BASE_SET" -eq 0 ] || { + echo "error: --relaunch reuses the task's recorded base branch; --base cannot override it" >&2 + exit 1 + } [ "$KIND_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded kind; --scout/--secondmate cannot override it" >&2 exit 1 @@ -743,6 +776,12 @@ else exit 1 } fi + # A secondmate launches in its own firstmate home rather than a task worktree + # of some project, so it has no base branch to be dispatched against. + [ "$KIND" != secondmate ] || [ "$BASE_SET" -eq 0 ] || { + echo "error: --base applies only to ship and scout spawns; a secondmate launches in its own home, not a task worktree" >&2 + exit 1 + } fi spawn_remote_secondmate() { @@ -1300,6 +1339,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * [ -z "$MODEL" ] || shared_args+=(--model "$MODEL") [ -z "$EFFORT" ] || shared_args+=(--effort "$EFFORT") [ -z "$BACKEND_ARG" ] || shared_args+=(--backend "$BACKEND_ARG") + [ -z "$BASE_ARG" ] || shared_args+=(--base "$BASE_ARG") # One delivery contract applies to every pair in a batch, exactly like the shared # harness. Each pair still re-validates it against its own brief, so a batch # spanning several modes is two invocations rather than a silent mixed dispatch. @@ -2804,8 +2844,10 @@ spawn_worktree_has_origin_config() { # return 1 } -freshen_spawn_worktree_base() { # - local worktree=$1 default target expected actual status +# The base branch this spawn actually resolved, empty when no base applied. +SPAWN_RESOLVED_BASE= +freshen_spawn_worktree_base() { # + local worktree=$1 default base_source target expected actual status has_origin=1 posture status=$(git -C "$worktree" -c core.quotePath=false status --porcelain) || { echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2 return 1 @@ -2819,44 +2861,65 @@ freshen_spawn_worktree_base() { # return 1 fi if ! spawn_worktree_has_origin_config "$worktree"; then - return 0 - fi - if ! git -C "$worktree" fetch --quiet origin; then - echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then - echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 + # With no origin there is nothing to freshen against, so a spawn that was + # given no base keeps launching from the clean worktree's own HEAD. An + # explicit --base is the branch this exact task was dispatched for, so it + # still governs here and still refuses a branch this copy cannot reach. + [ -n "$BASE_ARG" ] || return 0 + has_origin=0 + else + if ! git -C "$worktree" fetch --quiet origin; then + echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi + if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then + echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi fi # A freshly allocated pool slot lands on the repo's DEFAULT branch, which is # silently the wrong base for a project that develops elsewhere: a task once # audited a tree 1036 commits behind origin/develop and correctly reported - # that nothing in its brief existed. bin/fm-project-base.sh owns which branch - # the project declares; absent a declaration, the default stands. - default=$("$FM_ROOT/bin/fm-project-base.sh" "$worktree" "$PROJ_NAME" 2>/dev/null || true) - base_source=recorded - if [ -z "$default" ]; then - base_source=default - default=$(default_branch "$worktree") || { - echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - } + # that nothing in its brief existed. An explicit --base names the branch THIS + # task was dispatched against and governs this spawn alone, which is what lets + # an effort accumulate on one integration branch without editing shared state + # and having to remember to put it back. Absent that, bin/fm-project-base.sh + # owns which branch the project declares; absent a declaration, the default + # stands. + if [ -n "$BASE_ARG" ]; then + default=$BASE_ARG + base_source=explicit + else + default=$("$FM_ROOT/bin/fm-project-base.sh" "$worktree" "$PROJ_NAME" 2>/dev/null || true) + base_source=recorded + if [ -z "$default" ]; then + base_source=default + default=$(default_branch "$worktree") || { + echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + } + fi fi # origin/ is the fetched truth for a shared clone. A local-only project # inverts that: it lands with bin/fm-merge-local.sh, which fast-forwards the # LOCAL branch and never pushes, so there origin/ is the stale one. - if [ "$MODE" = local-only ]; then + # $MODE alone cannot decide that: a scout is refused --mode by design, so it is + # always empty there and every scout on a local-only project would resolve + # against the stale origin ref. The project's registered posture answers for + # the spawns that carry no mode of their own. + posture=$MODE + [ -n "$posture" ] || posture=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1 || true) + if [ "$posture" = local-only ] || [ "$has_origin" = 0 ]; then target=$default else target="origin/$default" if ! git -C "$worktree" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then - echo "error: could not fetch '$target' for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + echo "error: could not fetch $base_source base '$target' for pooled worktree '$worktree'; refusing to launch rather than falling back to another base" >&2 return 1 fi fi expected=$(git -C "$worktree" rev-parse --verify --quiet "$target^{commit}" 2>/dev/null) || { - echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + echo "error: $base_source base '$target' is not a commit for pooled worktree '$worktree'; refusing to launch rather than falling back to another base" >&2 return 1 } if ! git -C "$worktree" reset --hard "$target" >/dev/null; then @@ -2868,6 +2931,10 @@ freshen_spawn_worktree_base() { # echo "error: pooled worktree '$worktree' is at '${actual:-unknown}', not current $base_source base '$target' ('$expected'); refusing to launch" >&2 return 1 fi + # Recorded below as base= so the task record names the branch it was actually + # created against rather than a record that may have moved on since; a + # relaunch carries the line forward unchanged with the reused worktree. + SPAWN_RESOLVED_BASE=$default } herdr_projection_meta_field_exact() { # @@ -4227,6 +4294,10 @@ preserve_relaunch_meta() { echo "kind=$KIND" [ -z "$MODE" ] || echo "mode=$MODE" [ -z "$YOLO" ] || echo "yolo=$YOLO" + # A fresh ship or scout records the base it was created against; a relaunch + # has none to resolve and keeps the task's own recorded line through + # preserve_relaunch_meta, exactly like every other unowned field. + [ -z "$SPAWN_RESOLVED_BASE" ] || echo "base=$SPAWN_RESOLVED_BASE" echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" diff --git a/docs/architecture.md b/docs/architecture.md index c8e8a9e58ef..825653c58e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -337,6 +337,9 @@ A project's development branch is declared in the project repository itself, in That branch is what a task worktree must start from, because a freshly allocated pool worktree otherwise lands on the repo's default branch; `bin/fm-spawn.sh` checks the resolved branch out and refuses to launch a worker onto a base it cannot confirm. It is also what `bin/fm-fleet-sync.sh` keeps each clone current against, so a clone of a project developed off its repository default cannot report itself current while falling months behind. For a `local-only` project it is equally where work lands: `bin/fm-merge-local.sh` fast-forwards the resolved branch and `bin/fm-teardown.sh` measures merged work against that same branch, so the landing and the cleanup check can never disagree about which branch holds the work. +That resolved branch is the project's standing base, not one task's: `bin/fm-spawn.sh --base ` overrides it for a single ship or scout spawn without writing anything back, so an effort that must accumulate on one integration branch never edits shared state it then has to remember to restore. +The spawn records the base it resolved in the task's metadata and a relaunch reuses that record, so a task keeps the branch it was created against even after the project's own record moves on. +Either base resolves to `origin/` for a shared clone and to the local branch for a `local-only` project, which lands through `bin/fm-merge-local.sh` and never pushes; a spawn that carries no mode of its own - every scout, since `--mode` is refused there - reads the project's registered posture for that decision rather than treating the missing mode as a shared clone. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. 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. diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index f4cde27b896..1e6ab4c94ed 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -417,6 +417,7 @@ test_relaunch_preserves_durable_task_metadata() { printf '%s\n' 'pr_head=feature/relaunch' printf '%s\n' 'x_request=request-19' printf '%s\n' 'decisions_reviewed=1' + printf '%s\n' 'base=integration/multicurrency' } >> "$dir/home/state/rl19.meta" out=$(run_control "$dir" rl19 relaunch --note "continuing review work"); rc=$? @@ -429,6 +430,10 @@ test_relaunch_preserves_durable_task_metadata() { || fail "the task X request must survive relaunch" [ "$(meta_field "$dir" rl19 decisions_reviewed)" = 1 ] \ || fail "the task decision state must survive relaunch" + # A replacement worker must keep the branch the task was created against: a + # relaunch resolves no base of its own and never re-reads the project registry. + [ "$(meta_field "$dir" rl19 base)" = "integration/multicurrency" ] \ + || fail "the task base branch must survive relaunch" pass "fm-control relaunch: durable task metadata survives replacement launch publication" } diff --git a/tests/fm-spawn-base-branch.test.sh b/tests/fm-spawn-base-branch.test.sh index d7967b682af..2230af9f61e 100755 --- a/tests/fm-spawn-base-branch.test.sh +++ b/tests/fm-spawn-base-branch.test.sh @@ -12,6 +12,13 @@ # work happens on develop, plus a cold worktree detached at main - and assert # the recorded base is checked out, an unconfirmable base is refused instead of # launched, and a project with no base= record still spawns as it does today. +# +# The same fixtures cover fm-spawn.sh's per-spawn --base flag, which exists so an +# effort can accumulate on one integration branch without editing the shared +# registry and having to remember to restore it: the flag wins over the registry, +# the registry still governs when the flag is absent, an explicit base that does +# not resolve refuses instead of falling back, and a relaunch reuses the task's +# own recorded base rather than accepting a new one. set -u # shellcheck source=tests/lib.sh @@ -109,15 +116,20 @@ EOF # at intake rather than the spawn re-reading the registry, so the caller says so. run_base_spawn() { local id=$1 mode=${2:-no-mistakes} kind=${3:-ship} - local kindflags=(--mode "$mode" --yolo off) - [ "$kind" != scout ] || kindflags=(--scout) + local -a kindargs + shift $(( $# < 3 ? $# : 3 )) + if [ "$kind" = scout ]; then + kindargs=(--scout) + else + kindargs=(--mode "$mode" --yolo off) + fi FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" \ FM_FAKE_PANE_PATH="$WT_DIR" \ PATH="$FAKEBIN_DIR:$PATH" \ - "$SPAWN" "$id" "$PROJ_DIR" "${kindflags[@]}" 2>&1 + "$SPAWN" "$id" "$PROJ_DIR" "${kindargs[@]}" "$@" 2>&1 } head_sha() { git -C "$1" rev-parse HEAD; } @@ -406,6 +418,158 @@ test_declaration_resolves_under_stock_bash() { pass "the resolver reads a declaration from an unchecked-out branch under stock /bin/bash" } +# The per-spawn flag wins over the project's registry record. This is the whole +# point of the flag: an effort that must accumulate on one integration branch +# says so at dispatch instead of editing shared state it then has to restore. +test_explicit_base_beats_the_registry() { + local rec id out status + id=base-explicit-b8 + rec=$(make_base_case base-explicit "$id" main \ + '- base-explicit [no-mistakes base=main] - registry says main (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id" no-mistakes ship --base develop) + status=$? + expect_code 0 "$status" "an explicit --base should spawn: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "explicit --base did not win over the registry record (HEAD $(head_sha "$WT_DIR"))" + assert_grep "base=develop" "$HOME_DIR/state/$id.meta" "resolved base was not recorded" + pass "an explicit --base wins over the project's registry base record" +} + +# The flag governs its own spawn only: nothing is written back to the registry, +# so the next unrelated task still reads the captain's standing base. +test_explicit_base_is_not_written_back() { + local rec id out status before + id=base-noleak-b9 + rec=$(make_base_case base-noleak "$id" main \ + '- base-noleak [no-mistakes base=main] - registry says main (added 2026-09-17)') + read_base_record "$rec" + before=$(cat "$HOME_DIR/data/projects.md") + + out=$(run_base_spawn "$id" no-mistakes ship --base develop) + status=$? + expect_code 0 "$status" "an explicit --base should spawn: $out" + [ "$(cat "$HOME_DIR/data/projects.md")" = "$before" ] \ + || fail "an explicit --base rewrote the shared project registry" + pass "an explicit --base is never written back to the shared registry" +} + +# Precedence below the flag is unchanged: with no --base the registry still wins. +test_registry_base_applies_without_the_flag() { + local rec id out status + id=base-noflag-b10 + rec=$(make_base_case base-noflag "$id" main \ + '- base-noflag [no-mistakes base=develop] - develop-based (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id") + status=$? + expect_code 0 "$status" "a spawn with no --base should still use the registry: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "the registry base stopped applying when --base was absent" + assert_grep "base=develop" "$HOME_DIR/state/$id.meta" "registry base was not recorded" + pass "the registry base still applies when --base is absent" +} + +# The fail-closed half. A base the project cannot resolve must stop the spawn +# naming the branch, never quietly fall back to the registry value: a worker +# silently based on the wrong branch is exactly the failure the flag prevents. +test_unresolvable_explicit_base_refuses() { + local rec id out status + id=base-badflag-b11 + rec=$(make_base_case base-badflag "$id" main \ + '- base-badflag [no-mistakes base=develop] - develop-based (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id" no-mistakes ship --base integration/never-created) + status=$? + [ "$status" -ne 0 ] || fail "an unresolvable --base should refuse the spawn: $out" + assert_contains "$out" "integration/never-created" "refusal did not name the requested base branch" + assert_contains "$out" "refusing to launch" "refusal did not say it was refusing to launch" + [ "$(head_sha "$WT_DIR")" != "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "an unresolvable --base silently fell back to the registry base" + pass "an explicit base the project cannot resolve refuses instead of falling back" +} + +# A scout audits the tree a ship would build on, so it takes the flag too. +test_scout_accepts_an_explicit_base() { + local rec id out status + id=base-scoutflag-b12 + rec=$(make_base_case base-scoutflag "$id" main \ + '- base-scoutflag [no-mistakes base=main] - registry says main (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id" no-mistakes scout --base develop) + status=$? + expect_code 0 "$status" "a scout should accept an explicit base: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "scout did not start from the explicit base (HEAD $(head_sha "$WT_DIR"))" + pass "a scout also starts from an explicit --base" +} + +# A scout is refused --mode by design, so its local-vs-origin choice has to come +# from the project's registered posture: a local-only project lands with +# bin/fm-merge-local.sh and never pushes, so origin/ is the stale ref and a +# scout resolving against it would audit an old tree. +test_scout_on_a_local_only_project_uses_the_local_branch() { + local rec id out status + id=base-scoutlocal-b15 + rec=$(make_base_case base-scoutlocal "$id" main \ + '- base-scoutlocal [local-only] - merged locally, never pushed (added 2026-09-17)') + read_base_record "$rec" + git -C "$PROJ_DIR" checkout -q -b integration/x origin/main + printf 'landed locally\n' > "$PROJ_DIR/landed.txt" + git -C "$PROJ_DIR" add landed.txt + git -C "$PROJ_DIR" commit -qm "landed locally on the integration branch" + git -C "$PROJ_DIR" checkout -q main + + out=$(run_base_spawn "$id" no-mistakes scout --base integration/x) + status=$? + expect_code 0 "$status" "a scout on a local-only project should spawn: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse integration/x)" ] \ + || fail "scout did not start from the local integration branch (HEAD $(head_sha "$WT_DIR"))" + pass "a scout on a local-only project starts from the local base branch" +} + +# A relaunch keeps every identity axis the task was created with, and the base is +# one of them: a replacement worker must not be handed a different branch. +test_relaunch_refuses_an_explicit_base() { + local rec id out status + id=base-relaunch-b13 + rec=$(make_base_case base-relaunch "$id" main \ + '- base-relaunch [no-mistakes base=develop] - develop-based (added 2026-09-17)') + read_base_record "$rec" + + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" --relaunch --base develop 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "--relaunch should refuse --base: $out" + assert_contains "$out" "--base" "refusal did not name the rejected flag" + pass "--relaunch refuses --base and keeps the task's own recorded base" +} + +# A secondmate launches in its own home, not a task worktree, so it has no base. +test_secondmate_refuses_an_explicit_base() { + local rec out status + rec=$(make_base_case base-secondmate sm-base-b14 main \ + '- base-secondmate [no-mistakes] - secondmate refusal fixture (added 2026-09-17)') + read_base_record "$rec" + + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" sm-base-b14 --secondmate --base develop 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "--secondmate should refuse --base: $out" + assert_contains "$out" "--base" "refusal did not name the rejected flag" + pass "--secondmate refuses --base" +} + # CI's stock macOS Bash lane sets FM_TEST_ONLY to run just the resolver's # bash-3.2 regression. The rest of this file is not a 3.2 snapshot suite. if [ -n "${FM_TEST_ONLY:-}" ]; then @@ -420,8 +584,16 @@ test_default_base_passes_assertion test_unregistered_project_spawns_as_today test_missing_base_branch_is_refused test_local_only_prefers_local_branch - test_scout_also_starts_from_the_recorded_base +test_explicit_base_beats_the_registry +test_explicit_base_is_not_written_back +test_registry_base_applies_without_the_flag +test_unresolvable_explicit_base_refuses +test_scout_accepts_an_explicit_base +test_scout_on_a_local_only_project_uses_the_local_branch +test_relaunch_refuses_an_explicit_base +test_secondmate_refuses_an_explicit_base + test_declaration_is_read_from_the_branch_that_carries_it test_declaration_beats_the_private_registry test_registry_is_the_fallback_when_nothing_is_declared From c808f591b6aff097701b311525e391c2681e5d33 Mon Sep 17 00:00:00 2001 From: Tejas Shah Date: Sun, 20 Sep 2026 13:48:53 +0700 Subject: [PATCH 2/4] no-mistakes(review): honour a task's recorded base at landing and teardown --- bin/fm-merge-local.sh | 18 +++++++++++-- bin/fm-spawn.sh | 12 +++++---- bin/fm-teardown.sh | 13 +++++++++- docs/architecture.md | 3 ++- tests/fm-spawn-base-branch.test.sh | 41 ++++++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 35 +++++++++++++++++++++++-- 6 files changed, 111 insertions(+), 11 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index a96ff8e96c7..4d36a506cf4 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -95,8 +95,22 @@ git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { e # The captain's ruling is that the recorded development branch governs the merge # as well as the worktree, and a local-only project lands on the LOCAL branch. -DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) -[ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +# The task's own recorded base outranks the project's standing one: a task +# dispatched with an explicit --base was created against that branch, and +# landing it anywhere else would fast-forward the standing branch over every +# commit the effort has accumulated - the one-merge-checked-as-a-whole rule the +# per-task base exists to keep. A recorded base that no longer exists refuses +# rather than silently landing on another branch. +DEFAULT=$(grep '^base=' "$META" | tail -n 1 | cut -d= -f2- || true) +if [ -n "$DEFAULT" ]; then + git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$DEFAULT" >/dev/null || { + echo "error: task $ID was created against base branch '$DEFAULT', which no longer exists in $PROJ; refusing to land it on another branch" >&2 + exit 1 + } +else + DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) + [ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +fi # The project's main checkout must be on its default branch and clean, so the # fast-forward lands predictably (firstmate never writes here otherwise). diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4a937046087..f4f7fd8296b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2903,12 +2903,14 @@ freshen_spawn_worktree_base() { # # origin/ is the fetched truth for a shared clone. A local-only project # inverts that: it lands with bin/fm-merge-local.sh, which fast-forwards the # LOCAL branch and never pushes, so there origin/ is the stale one. - # $MODE alone cannot decide that: a scout is refused --mode by design, so it is - # always empty there and every scout on a local-only project would resolve - # against the stale origin ref. The project's registered posture answers for - # the spawns that carry no mode of their own. + # $MODE alone cannot decide that for an explicit --base: a scout is refused + # --mode by design, so it is always empty there and a scout dispatched against + # a local integration branch would resolve it as a stale origin ref. The + # project's registered posture answers for that spawn; without --base the + # existing resolution is left exactly as it was. posture=$MODE - [ -n "$posture" ] || posture=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1 || true) + [ -z "$BASE_ARG" ] || [ -n "$posture" ] \ + || posture=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1 || true) if [ "$posture" = local-only ] || [ "$has_origin" = 0 ]; then target=$default else diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e0b9c339be0..8ff45a1ef35 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1740,7 +1740,18 @@ validate_worktree_teardown_safety() { unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) if [ -n "$unpushed" ] && [ "$MODE" = local-only ]; then - DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) + # Landed means merged into the branch this task was dispatched against. + # A task created with an explicit --base lands on that branch (see + # bin/fm-merge-local.sh), so measuring its work against the project's + # standing base would report already-merged commits as unmerged and refuse + # cleanup. A recorded base this copy cannot resolve falls back to the + # standing one, which only ever refuses more. + DEFAULT=$(meta_value "$META" base) + if [ -n "$DEFAULT" ] \ + && ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" >/dev/null 2>&1; then + DEFAULT= + fi + [ -n "$DEFAULT" ] || DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) [ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; return 1; } if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$DEFAULT" -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on $DEFAULT"; then diff --git a/docs/architecture.md b/docs/architecture.md index 825653c58e6..9911796fe10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -339,7 +339,8 @@ It is also what `bin/fm-fleet-sync.sh` keeps each clone current against, so a cl For a `local-only` project it is equally where work lands: `bin/fm-merge-local.sh` fast-forwards the resolved branch and `bin/fm-teardown.sh` measures merged work against that same branch, so the landing and the cleanup check can never disagree about which branch holds the work. That resolved branch is the project's standing base, not one task's: `bin/fm-spawn.sh --base ` overrides it for a single ship or scout spawn without writing anything back, so an effort that must accumulate on one integration branch never edits shared state it then has to remember to restore. The spawn records the base it resolved in the task's metadata and a relaunch reuses that record, so a task keeps the branch it was created against even after the project's own record moves on. -Either base resolves to `origin/` for a shared clone and to the local branch for a `local-only` project, which lands through `bin/fm-merge-local.sh` and never pushes; a spawn that carries no mode of its own - every scout, since `--mode` is refused there - reads the project's registered posture for that decision rather than treating the missing mode as a shared clone. +That record also governs the landing and the cleanup: `bin/fm-merge-local.sh` fast-forwards the task's own recorded base and `bin/fm-teardown.sh` measures its merged work against it, falling back to the project's standing base only for a task that recorded none - otherwise a single task would land the whole integration branch into the standing branch, and cleanup would call already-merged work unmerged. +Either base resolves to `origin/` for a shared clone and to the local branch for a `local-only` project, which lands through `bin/fm-merge-local.sh` and never pushes; an explicit `--base` on a spawn that carries no mode of its own - a scout, since `--mode` is refused there - reads the project's registered posture for that decision rather than treating the missing mode as a shared clone. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. 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. diff --git a/tests/fm-spawn-base-branch.test.sh b/tests/fm-spawn-base-branch.test.sh index 2230af9f61e..f3dba54b532 100755 --- a/tests/fm-spawn-base-branch.test.sh +++ b/tests/fm-spawn-base-branch.test.sh @@ -570,6 +570,46 @@ test_secondmate_refuses_an_explicit_base() { pass "--secondmate refuses --base" } +# --- the recorded base governs landing and cleanup too ---------------------- +# Recording base= would be theatre if the landing path ignored it: a local-only +# task dispatched against an integration branch would have bin/fm-merge-local.sh +# fast-forward the project's STANDING branch over every commit the effort has +# accumulated, which is precisely the one-merge-checked-as-a-whole rule the flag +# exists to keep. +test_local_merge_lands_on_the_tasks_recorded_base() { + local dir home proj wt id out status main_before + id=base-landing-b16 + dir="$TMP_ROOT/landing" + home="$dir/home" + proj="$dir/projects/base-landing" + wt="$dir/projects/$id" + mkdir -p "$home/state" "$home/data" "$home/config" "$dir/projects" + fm_git_init_commit "$proj" + git -C "$proj" branch integration/x main + git -C "$proj" worktree add --quiet -b "fm/$id" "$wt" integration/x + printf 'effort work\n' > "$wt/effort.txt" + git -C "$wt" add effort.txt + git -C "$wt" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + commit -qm 'effort work' + git -C "$proj" checkout -q integration/x + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$wt" \ + "project=$proj" "kind=ship" "mode=local-only" "base=integration/x" \ + "spawn_gen=fixture-$id" + main_before=$(git -C "$proj" rev-parse main) + + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-merge-local.sh" "$id" 2>&1) + status=$? + expect_code 0 "$status" "the local landing should fast-forward the task's recorded base: $out" + [ "$(git -C "$proj" rev-parse integration/x)" = "$(git -C "$wt" rev-parse HEAD)" ] \ + || fail "the task's recorded base branch did not receive the work" + [ "$(git -C "$proj" rev-parse main)" = "$main_before" ] \ + || fail "the landing moved the project's standing branch instead of the task's base" + pass "a local-only task lands on the base it was created against, not the standing one" +} + # CI's stock macOS Bash lane sets FM_TEST_ONLY to run just the resolver's # bash-3.2 regression. The rest of this file is not a 3.2 snapshot suite. if [ -n "${FM_TEST_ONLY:-}" ]; then @@ -593,6 +633,7 @@ test_scout_accepts_an_explicit_base test_scout_on_a_local_only_project_uses_the_local_branch test_relaunch_refuses_an_explicit_base test_secondmate_refuses_an_explicit_base +test_local_merge_lands_on_the_tasks_recorded_base test_declaration_is_read_from_the_branch_that_carries_it test_declaration_beats_the_private_registry diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index e348d9630fe..77915d1ec5e 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -190,7 +190,9 @@ SH # Write a meta file for the task. Args: case_dir mode kind write_meta() { - local case_dir=$1 mode=$2 kind=$3 + local case_dir=$1 mode=$2 kind=$3 base=${4:-} + local -a extra=() + [ -z "$base" ] || extra=("base=$base") fm_write_meta "$case_dir/state/task-x1.meta" \ "window=firstmate:fm-task-x1" \ "endpoint_task_id=task-x1" \ @@ -198,7 +200,8 @@ write_meta() { "project=$case_dir/project" \ "kind=$kind" \ "mode=$mode" \ - "spawn_gen=teardown-test-task-x1" + "spawn_gen=teardown-test-task-x1" \ + ${extra[@]+"${extra[@]}"} } # Commit something on the worktree's task branch. Args: case_dir [message] @@ -816,6 +819,33 @@ test_local_only_merged_to_declared_branch_allows() { pass "local-only worktree merged into the project's declared development branch is torn down" } +# A task dispatched with an explicit --base records that branch as base= and +# lands THERE (bin/fm-merge-local.sh), so its landed work must be measured +# against that branch too. Measured against the project's standing base this +# work is unmerged and cleanup refuses - the mirror of the landing fault. +test_local_only_merged_to_task_base_allows() { + local case_dir rc wt_head main_before + case_dir=$(make_case merged-task-base) + write_meta "$case_dir" local-only ship integration/x + git -C "$case_dir/project" branch integration/x main + git -C "$case_dir/wt" reset -q --hard integration/x + wt_commit "$case_dir" "work on the task's own base branch" + wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" update-ref refs/heads/integration/x "$wt_head" + main_before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "merged-task-base: teardown should succeed when work is merged into the task's recorded base" + ! grep -q REFUSED "$case_dir/stderr" || fail "merged-task-base: teardown printed a REFUSED line" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$main_before" ] \ + || fail "merged-task-base: the project's standing base branch was moved" + pass "local-only worktree merged into the task's own recorded base branch is torn down" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -3705,6 +3735,7 @@ test_teardown_manual_backend_leaves_the_backlog_to_the_operator test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_local_only_merged_to_declared_branch_allows +test_local_only_merged_to_task_base_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From 9b8522f45fae09b9f544ae10758bb5f61d9cb5c1 Mon Sep 17 00:00:00 2001 From: Tejas Shah Date: Sun, 20 Sep 2026 14:07:06 +0700 Subject: [PATCH 3/4] no-mistakes(review): record a task base only when explicitly dispatched --- bin/fm-merge-local.sh | 18 +++--- bin/fm-spawn.sh | 13 ++-- docs/architecture.md | 4 +- tests/fm-spawn-base-branch.test.sh | 95 ++++++++++++++++++++++++++---- 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 4d36a506cf4..e0741002a69 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -99,15 +99,15 @@ git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { e # dispatched with an explicit --base was created against that branch, and # landing it anywhere else would fast-forward the standing branch over every # commit the effort has accumulated - the one-merge-checked-as-a-whole rule the -# per-task base exists to keep. A recorded base that no longer exists refuses -# rather than silently landing on another branch. -DEFAULT=$(grep '^base=' "$META" | tail -n 1 | cut -d= -f2- || true) -if [ -n "$DEFAULT" ]; then - git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$DEFAULT" >/dev/null || { - echo "error: task $ID was created against base branch '$DEFAULT', which no longer exists in $PROJ; refusing to land it on another branch" >&2 - exit 1 - } -else +# per-task base exists to keep. A recorded branch that has since disappeared +# falls back to the project's current declaration, exactly as cleanup does, +# rather than stranding the work with no command that lands it. +TASK_BASE=$(grep '^base=' "$META" | tail -n 1 | cut -d= -f2- || true) +DEFAULT= +if [ -n "$TASK_BASE" ] && git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$TASK_BASE" >/dev/null; then + DEFAULT=$TASK_BASE +fi +if [ -z "$DEFAULT" ]; then DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) [ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index f4f7fd8296b..9c35c7b014b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2933,10 +2933,15 @@ freshen_spawn_worktree_base() { # echo "error: pooled worktree '$worktree' is at '${actual:-unknown}', not current $base_source base '$target' ('$expected'); refusing to launch" >&2 return 1 fi - # Recorded below as base= so the task record names the branch it was actually - # created against rather than a record that may have moved on since; a - # relaunch carries the line forward unchanged with the reused worktree. - SPAWN_RESOLVED_BASE=$default + # Only a base this spawn was explicitly dispatched against is recorded below + # as base=, so the task record names the branch it was created against rather + # than a record that may have moved on since; a relaunch carries the line + # forward unchanged with the reused worktree. A spawn that named no base owns + # no base: its landing and cleanup keep resolving the project's standing + # declaration, which is free to change under an in-flight task. + if [ "$base_source" = explicit ]; then + SPAWN_RESOLVED_BASE=$default + fi } herdr_projection_meta_field_exact() { # diff --git a/docs/architecture.md b/docs/architecture.md index 9911796fe10..e0109db1ae9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -338,8 +338,8 @@ That branch is what a task worktree must start from, because a freshly allocated It is also what `bin/fm-fleet-sync.sh` keeps each clone current against, so a clone of a project developed off its repository default cannot report itself current while falling months behind. For a `local-only` project it is equally where work lands: `bin/fm-merge-local.sh` fast-forwards the resolved branch and `bin/fm-teardown.sh` measures merged work against that same branch, so the landing and the cleanup check can never disagree about which branch holds the work. That resolved branch is the project's standing base, not one task's: `bin/fm-spawn.sh --base ` overrides it for a single ship or scout spawn without writing anything back, so an effort that must accumulate on one integration branch never edits shared state it then has to remember to restore. -The spawn records the base it resolved in the task's metadata and a relaunch reuses that record, so a task keeps the branch it was created against even after the project's own record moves on. -That record also governs the landing and the cleanup: `bin/fm-merge-local.sh` fast-forwards the task's own recorded base and `bin/fm-teardown.sh` measures its merged work against it, falling back to the project's standing base only for a task that recorded none - otherwise a single task would land the whole integration branch into the standing branch, and cleanup would call already-merged work unmerged. +The spawn records an explicitly dispatched base in the task's metadata and a relaunch reuses that record, so such a task keeps the branch it was created against even after the project's own record moves on; a spawn that named no base records none and keeps resolving the project's standing declaration, which stays free to change under it. +That record also governs the landing and the cleanup: `bin/fm-merge-local.sh` fast-forwards the task's own recorded base and `bin/fm-teardown.sh` measures its merged work against it, falling back to the project's standing base when the task recorded none or when the branch it recorded has since disappeared - otherwise a single task would land the whole integration branch into the standing branch, and cleanup would call already-merged work unmerged. Either base resolves to `origin/` for a shared clone and to the local branch for a `local-only` project, which lands through `bin/fm-merge-local.sh` and never pushes; an explicit `--base` on a spawn that carries no mode of its own - a scout, since `--mode` is refused there - reads the project's registered posture for that decision rather than treating the missing mode as a shared clone. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. diff --git a/tests/fm-spawn-base-branch.test.sh b/tests/fm-spawn-base-branch.test.sh index f3dba54b532..08a76399be1 100755 --- a/tests/fm-spawn-base-branch.test.sh +++ b/tests/fm-spawn-base-branch.test.sh @@ -468,7 +468,8 @@ test_registry_base_applies_without_the_flag() { expect_code 0 "$status" "a spawn with no --base should still use the registry: $out" [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ || fail "the registry base stopped applying when --base was absent" - assert_grep "base=develop" "$HOME_DIR/state/$id.meta" "registry base was not recorded" + assert_no_grep "base=develop" "$HOME_DIR/state/$id.meta" \ + "a spawn that named no base still recorded one, freezing its landing target" pass "the registry base still applies when --base is absent" } @@ -576,31 +577,50 @@ test_secondmate_refuses_an_explicit_base() { # fast-forward the project's STANDING branch over every commit the effort has # accumulated, which is precisely the one-merge-checked-as-a-whole rule the flag # exists to keep. -test_local_merge_lands_on_the_tasks_recorded_base() { - local dir home proj wt id out status main_before - id=base-landing-b16 - dir="$TMP_ROOT/landing" +# make_landing_case [base=]: +# a local-only project with main plus an integration branch, a task worktree +# holding one commit on top of , and the project +# checkout left on that branch so the landing can fast-forward it. Extra args +# are appended to the task's meta. Prints home|project|worktree. +make_landing_case() { + local name=$1 id=$2 on=$3 + shift 3 + local dir home proj wt + dir="$TMP_ROOT/landing/$name" home="$dir/home" - proj="$dir/projects/base-landing" + proj="$dir/projects/$name" wt="$dir/projects/$id" mkdir -p "$home/state" "$home/data" "$home/config" "$dir/projects" fm_git_init_commit "$proj" git -C "$proj" branch integration/x main - git -C "$proj" worktree add --quiet -b "fm/$id" "$wt" integration/x + git -C "$proj" worktree add --quiet -b "fm/$id" "$wt" "$on" printf 'effort work\n' > "$wt/effort.txt" git -C "$wt" add effort.txt git -C "$wt" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ commit -qm 'effort work' - git -C "$proj" checkout -q integration/x + git -C "$proj" checkout -q "$on" fm_write_meta "$home/state/$id.meta" \ "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$wt" \ - "project=$proj" "kind=ship" "mode=local-only" "base=integration/x" \ - "spawn_gen=fixture-$id" + "project=$proj" "kind=ship" "mode=local-only" "spawn_gen=fixture-$id" "$@" + printf '%s|%s|%s\n' "$home" "$proj" "$wt" +} + +run_local_merge() { # + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$1" FM_STATE_OVERRIDE="$1/state" \ + FM_DATA_OVERRIDE="$1/data" FM_CONFIG_OVERRIDE="$1/config" \ + "$ROOT/bin/fm-merge-local.sh" "$2" 2>&1 +} + +test_local_merge_lands_on_the_tasks_recorded_base() { + local rec home proj wt id out status main_before + id=base-landing-b16 + rec=$(make_landing_case base-landing "$id" integration/x "base=integration/x") + IFS='|' read -r home proj wt <&1) + out=$(run_local_merge "$home" "$id") status=$? expect_code 0 "$status" "the local landing should fast-forward the task's recorded base: $out" [ "$(git -C "$proj" rev-parse integration/x)" = "$(git -C "$wt" rev-parse HEAD)" ] \ @@ -610,6 +630,53 @@ test_local_merge_lands_on_the_tasks_recorded_base() { pass "a local-only task lands on the base it was created against, not the standing one" } +# A task dispatched without --base owns no base, so its landing keeps resolving +# the project's CURRENT declaration - which is free to change under an in-flight +# task. Freezing the target at spawn time would leave such a task landable only +# by hand-editing its record. +test_local_merge_without_a_recorded_base_follows_the_declaration() { + local rec home proj wt id out status + id=base-nolanding-b17 + rec=$(make_landing_case base-nolanding "$id" integration/x) + IFS='|' read -r home proj wt < "$proj/.firstmate-base" + git -C "$proj" add .firstmate-base + git -C "$proj" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + commit -qm 'declare integration/x' + git -C "$proj" checkout -q integration/x + + out=$(run_local_merge "$home" "$id") + status=$? + expect_code 0 "$status" "a task with no recorded base should land on the project's current declaration: $out" + [ "$(git -C "$proj" rev-parse integration/x)" = "$(git -C "$wt" rev-parse HEAD)" ] \ + || fail "the declared branch did not receive the work" + pass "a task dispatched without --base lands on the project's current declaration" +} + +# A recorded branch that has since disappeared is a reason to fall back to the +# project's current declaration, not to refuse forever: refusing there strands +# the work with no command that lands it. +test_local_merge_falls_back_when_the_recorded_base_is_gone() { + local rec home proj wt id out status + id=base-gonebase-b18 + rec=$(make_landing_case base-gonebase "$id" main "base=integration/gone") + IFS='|' read -r home proj wt < Date: Sun, 20 Sep 2026 14:24:16 +0700 Subject: [PATCH 4/4] no-mistakes(document): correct local-landing docs for per-task base --- bin/fm-merge-local.sh | 4 +++- docs/scripts.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index e0741002a69..9a0221940c8 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Perform the approved local merge for a local-only ship task: fast-forward the -# project's default branch to the crewmate's fm/ branch. +# branch the task was dispatched against - its own recorded base when it has +# one, otherwise the project's development branch - to the crewmate's fm/ +# branch. # # This is firstmate's merge gate-action (the captain's merge authority applied # locally instead of via a GitHub PR). It is the one sanctioned exception to hard diff --git a/docs/scripts.md b/docs/scripts.md index 636d93f4e16..575b5db33dd 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -70,7 +70,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | | `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding, plus the legacy `base=` fallback tier that `fm-project-base.sh` consults | | `fm-project-base.sh` | Resolve a project's development branch from its committed `.firstmate-base`, falling back to the registry record, for spawns, fleet sync, local landing, and teardown | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local development branch after approval | +| `fm-merge-local.sh` | Fast-forward a `local-only` task's own local base branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder |