From 9b384bcb525bfd6da7f9d61eb3504781a2257a9c Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Tue, 23 Jun 2026 14:36:50 +0400 Subject: [PATCH 01/18] docs: record pnpm native-dep build-approval gotchas (#1) Co-authored-by: Waleed Arafa --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eb9851850..533b22865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ Do not hand off `local-only` items: that work stays with the main firstmate (sec Firstmate keeps project knowledge split by ownership. **Project-intrinsic knowledge** belongs to the project. -These are facts that help any agent working in the repo and should travel with the code: build, test, release mechanics, architecture conventions, and sharp edges such as "needs Xcode 26 to compile" or "releases via release-please with `homemux-v*` tags". +These are facts that help any agent working in the repo and should travel with the code: build, test, release mechanics, architecture conventions, and sharp edges such as "needs Xcode 26 to compile", "releases via release-please with `homemux-v*` tags", or "pnpm projects with native deps need `pnpm approve-builds` once, or `pnpm install`/`start`/`test` hard-fail with `ERR_PNPM_IGNORED_BUILDS`". This knowledge lives in the project's committed `AGENTS.md`. A project's `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it. @@ -301,6 +301,7 @@ Do not eagerly backfill every project. Orthogonal to mode is an optional `+yolo` flag (`[direct-PR +yolo]`), default off and **not recommended**: with `yolo` on, firstmate makes the approval decisions itself instead of asking the captain (section 7). When the captain adds a project without saying, default to `no-mistakes` with yolo off; only set a faster mode or `+yolo` on the captain's explicit say-so. **Clone existing:** `git clone projects/`, add its registry line with the chosen mode, then initialize only if the mode is `no-mistakes`. +A freshly cloned (or fleet-synced) pooled clone has no installed dependencies of its own - crewmate worktrees each get their own. So to run or preview a project directly from its pooled clone (e.g. to answer "is the dev server up"), install deps there first (`pnpm install`, etc.); do not assume the bare clone can start. **Create new:** for `no-mistakes` and `direct-PR` modes a new project needs a GitHub repo first (they push to an `origin` remote); a `local-only` project needs no remote at all - a purely local git repo is fine. Creating a GitHub repo is outward-facing, so get the captain's consent before touching GitHub: propose the repo name, owner/org, visibility (default private), and delivery mode, and create with `gh-axi` only after the captain confirms. From b11822d95179273a84445e9904efbdd0c16dd90a Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 02:04:11 +0400 Subject: [PATCH 02/18] Add guarded local merge target support --- bin/fm-merge-local.sh | 130 +++++++++++++---- docs/scripts.md | 2 +- tests/fm-merge-local.test.sh | 273 +++++++++++++++++++++++++++++++++++ 3 files changed, 379 insertions(+), 26 deletions(-) create mode 100755 tests/fm-merge-local.test.sh diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index fdc801148..b4e4ba0b7 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -1,23 +1,93 @@ #!/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. +# Perform the approved local landing for a local-only ship task by fast-forwarding +# a clean, checked-out local target branch to the task'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 -# rule #1 "never run state-changing git in projects/", and it is narrow: it only -# runs for mode=local-only tasks, only after the captain approves (or yolo=on -# auto-approves), and only as a clean fast-forward - it refuses a diverged branch -# and tells you to have the crewmate rebase. See AGENTS.md prime directives, +# This is firstmate's sanctioned local merge action after captain approval or +# routine yolo authority. Without --target, the target is the project's default +# branch, preserving the original behavior. An explicit --target must name an +# existing local branch and the project's main local copy must already be clean +# and checked out on it. This command never changes branches or contacts a remote. +# It only runs for mode=local-only tasks and only performs a clean +# `git merge --ff-only` after all guards pass. See AGENTS.md prime directives, # project management, and task lifecycle. -# Usage: fm-merge-local.sh +# +# Usage: fm-merge-local.sh [--target ] set -eu +usage() { + cat <<'EOF' +Usage: fm-merge-local.sh [--target ] + +Fast-forward a clean, checked-out local branch to fm/ for an approved +local-only task. Without --target, the project's default branch is the target. +--target never checks out or creates the requested local branch. +EOF +} + +fail_usage() { + echo "error: $1" >&2 + usage >&2 + exit 2 +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$FM_ROOT/bin/fm-guard.sh" || true -ID=${1:?usage: fm-merge-local.sh } + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; + '') + fail_usage "missing task id" + ;; + -*) + fail_usage "unknown option '$1'" + ;; +esac + +ID=$1 +shift +REQUESTED_TARGET= +if [ "$#" -gt 0 ]; then + case "$1" in + --target) + [ "$#" -ge 2 ] || fail_usage "--target requires a local branch name" + [ "$#" -eq 2 ] || fail_usage "unexpected extra argument '${3:-}'" + [ -n "$2" ] || fail_usage "--target requires a non-empty local branch name" + REQUESTED_TARGET=$2 + ;; + --*) + fail_usage "unknown option '$1'" + ;; + *) + fail_usage "unexpected positional argument '$1'" + ;; + esac +fi + +if [ -n "$REQUESTED_TARGET" ]; then + case "$REQUESTED_TARGET" in + -*|*[[:space:]]*) + fail_usage "unsafe or option-like target branch '$REQUESTED_TARGET'" + ;; + esac + single_quote=$(printf '\047') + backslash=$(printf '\134') + for unsafe_char in '$' '`' ';' '|' '&' '<' '>' "$backslash" "$single_quote" '"' '(' ')' '{' '}'; do + case "$REQUESTED_TARGET" in + *"$unsafe_char"*) + fail_usage "unsafe or option-like target branch '$REQUESTED_TARGET'" + ;; + esac + done + git check-ref-format --branch "$REQUESTED_TARGET" >/dev/null 2>&1 \ + || fail_usage "invalid target branch '$REQUESTED_TARGET'" +fi + META="$STATE/$ID.meta" [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } @@ -41,28 +111,38 @@ default_branch() { return 1 } +if [ -n "$REQUESTED_TARGET" ]; then + TARGET=$REQUESTED_TARGET +else + TARGET=$(default_branch) || { echo "error: cannot determine target default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +fi + BRANCH="fm/$ID" -git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { echo "error: branch $BRANCH does not exist in $PROJ" >&2; exit 1; } +TARGET_REF="refs/heads/$TARGET" +BRANCH_REF="refs/heads/$BRANCH" -DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +[ "$TARGET" != "$BRANCH" ] || { echo "error: target branch '$TARGET' is the task branch; refusing to merge a branch into itself" >&2; exit 1; } +git -C "$PROJ" show-ref --verify --quiet "$TARGET_REF" || { echo "error: target branch '$TARGET' does not exist locally in $PROJ" >&2; exit 1; } +git -C "$PROJ" show-ref --verify --quiet "$BRANCH_REF" || { echo "error: task branch '$BRANCH' does not exist in $PROJ; target '$TARGET' was not changed" >&2; exit 1; } -# The project's main checkout must be on its default branch and clean, so the -# fast-forward lands predictably (firstmate never writes here otherwise). -cur=$(git -C "$PROJ" symbolic-ref --short HEAD 2>/dev/null || echo "") -[ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected default branch '$DEFAULT'; cannot merge safely" >&2; exit 1; } +# The project's main local copy must already be on the target and clean, so the +# fast-forward lands predictably without checkout, stash, reset, or force. +cur=$(git -C "$PROJ" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") +[ "$cur" = "$TARGET" ] || { echo "error: $PROJ is on '$cur', expected target branch '$TARGET'; cannot merge safely" >&2; exit 1; } if [ -n "$(git -C "$PROJ" status --porcelain 2>/dev/null | head -1)" ]; then - echo "error: $PROJ has a dirty working tree; refusing to merge into it" >&2 + echo "error: $PROJ has a dirty working tree; refusing to merge into target '$TARGET'" >&2 exit 1 fi -# Clean fast-forward only: DEFAULT must be an ancestor of BRANCH. -if ! git -C "$PROJ" merge-base --is-ancestor "$DEFAULT" "$BRANCH"; then - echo "REFUSED: $BRANCH is not a fast-forward of $DEFAULT (it has diverged)." >&2 - echo "Have the crewmate rebase $BRANCH onto $DEFAULT, then retry." >&2 +# Clean fast-forward only: the fully qualified target must be an ancestor of the +# fully qualified task branch. +if ! git -C "$PROJ" merge-base --is-ancestor "$TARGET_REF" "$BRANCH_REF"; then + echo "REFUSED: task branch '$BRANCH' is not a fast-forward of target '$TARGET' (it has diverged)." >&2 + echo "Have the worker rebase '$BRANCH' onto '$TARGET', then retry." >&2 exit 1 fi -before=$(git -C "$PROJ" rev-parse --short "$DEFAULT") -git -C "$PROJ" merge --ff-only "$BRANCH" >/dev/null -after=$(git -C "$PROJ" rev-parse --short "$DEFAULT") -echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ" +before=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") +git -C "$PROJ" merge --ff-only "$BRANCH_REF" >/dev/null +after=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") +echo "merged $BRANCH into local target $TARGET ($before -> $after) in $PROJ" diff --git a/docs/scripts.md b/docs/scripts.md index 63dc13850..7d436c847 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -40,7 +40,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `fm-config-push.sh` | Push declared inherited local material to live secondmate homes mid-session | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | +| `fm-merge-local.sh` | Fast-forward a `local-only` project's checked-out local target after approval | | `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base | | `fm-marker-lib.sh` | Shared from-firstmate request marker, detector, and idempotent transformation | | `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh new file mode 100755 index 000000000..df91b517f --- /dev/null +++ b/tests/fm-merge-local.test.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Regression tests for bin/fm-merge-local.sh's guarded local-only landing. +# +# The legacy path lands only on the checked-out default branch. +# An explicit --target path lands only on the named clean, checked-out local +# branch, and every refusal leaves all local branch refs and working state intact. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +MERGE_LOCAL="$ROOT/bin/fm-merge-local.sh" +TMP_ROOT=$(fm_test_tmproot fm-merge-local-tests) + +make_case() { + local name=$1 target=${2:-main} mode=${3:-local-only} + local case_dir="$TMP_ROOT/$name" + mkdir -p "$case_dir/state" "$case_dir/fm-root/bin" "$case_dir/project" + + cat > "$case_dir/fm-root/bin/fm-guard.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$case_dir/fm-root/bin/fm-guard.sh" + + git -C "$case_dir/project" init -q + git -C "$case_dir/project" symbolic-ref HEAD refs/heads/main + printf '%s\n' baseline > "$case_dir/project/README.md" + git -C "$case_dir/project" add README.md + git -C "$case_dir/project" commit -qm baseline + + if [ "$target" != main ]; then + git -C "$case_dir/project" branch "$target" + git -C "$case_dir/project" checkout -q "$target" + fi + + git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/task-wt" "$target" + printf '%s\n' task > "$case_dir/task-wt/task.txt" + git -C "$case_dir/task-wt" add task.txt + git -C "$case_dir/task-wt" commit -qm 'task change' + + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/task-wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=$mode" + touch "$case_dir/state/.last-watcher-beat" + printf '%s\n' "$case_dir" +} + +run_merge() { + local case_dir=$1 + shift + FM_ROOT_OVERRIDE="$case_dir/fm-root" \ + FM_HOME="$case_dir" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + "$MERGE_LOCAL" "$@" +} + +snapshot_repo() { + local case_dir=$1 output=$2 + { + git -C "$case_dir/project" for-each-ref \ + --format='%(refname) %(objectname)' | sort + printf 'HEAD %s\n' "$(git -C "$case_dir/project" symbolic-ref --quiet --short HEAD 2>/dev/null || printf detached)" + git -C "$case_dir/project" status --porcelain=v1 --untracked-files=all + } > "$output" +} + +assert_refusal_without_mutation() { + local case_dir=$1 label=$2 rc + shift 2 + snapshot_repo "$case_dir" "$case_dir/$label.before" + set +e + run_merge "$case_dir" "$@" > "$case_dir/$label.stdout" 2> "$case_dir/$label.stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$label: command unexpectedly succeeded" + snapshot_repo "$case_dir" "$case_dir/$label.after" + cmp -s "$case_dir/$label.before" "$case_dir/$label.after" \ + || fail "$label: refusal mutated local refs, checkout, or working state" +} + +test_legacy_default_target() { + local case_dir main_before task_head main_after + case_dir=$(make_case legacy-default main) + main_before=$(git -C "$case_dir/project" rev-parse refs/heads/main) + task_head=$(git -C "$case_dir/project" rev-parse refs/heads/fm/task-x1) + + run_merge "$case_dir" task-x1 > "$case_dir/stdout" 2> "$case_dir/stderr" \ + || fail "legacy-default: guarded landing failed" + main_after=$(git -C "$case_dir/project" rev-parse refs/heads/main) + + [ "$main_before" != "$main_after" ] || fail "legacy-default: main did not move" + [ "$main_after" = "$task_head" ] || fail "legacy-default: main did not fast-forward to the task branch" + assert_grep 'local target main' "$case_dir/stdout" \ + "legacy-default: success output did not name main" + pass "fm-merge-local preserves the no-option default-branch fast-forward" +} + +test_explicit_feature_target() { + local case_dir main_before feature_before task_head + case_dir=$(make_case explicit-feature feature/for-you-feed) + main_before=$(git -C "$case_dir/project" rev-parse refs/heads/main) + feature_before=$(git -C "$case_dir/project" rev-parse refs/heads/feature/for-you-feed) + task_head=$(git -C "$case_dir/project" rev-parse refs/heads/fm/task-x1) + + run_merge "$case_dir" task-x1 --target feature/for-you-feed \ + > "$case_dir/stdout" 2> "$case_dir/stderr" \ + || fail "explicit-feature: guarded landing failed" + + [ "$(git -C "$case_dir/project" rev-parse refs/heads/main)" = "$main_before" ] \ + || fail "explicit-feature: main changed" + [ "$(git -C "$case_dir/project" rev-parse refs/heads/feature/for-you-feed)" = "$task_head" ] \ + || fail "explicit-feature: feature target did not fast-forward to the task branch" + [ "$feature_before" != "$task_head" ] || fail "explicit-feature: fixture did not require a fast-forward" + assert_grep 'local target feature/for-you-feed' "$case_dir/stdout" \ + "explicit-feature: success output did not name the actual target" + pass "fm-merge-local fast-forwards an explicitly requested clean feature target without changing main" +} + +test_wrong_checked_out_branch_refuses() { + local case_dir + case_dir=$(make_case wrong-checkout feature/for-you-feed) + git -C "$case_dir/project" checkout -q main + assert_refusal_without_mutation "$case_dir" wrong-checkout \ + task-x1 --target feature/for-you-feed + assert_grep "expected target branch 'feature/for-you-feed'" "$case_dir/wrong-checkout.stderr" \ + "wrong-checkout: refusal did not name the requested target" + pass "fm-merge-local refuses when the main local copy is checked out on another branch" +} + +test_dirty_target_refuses() { + local case_dir + case_dir=$(make_case dirty-target feature/for-you-feed) + printf '%s\n' dirty > "$case_dir/project/untracked.txt" + assert_refusal_without_mutation "$case_dir" dirty-target \ + task-x1 --target feature/for-you-feed + assert_grep "target 'feature/for-you-feed'" "$case_dir/dirty-target.stderr" \ + "dirty-target: refusal did not name the requested target" + pass "fm-merge-local refuses a dirty explicit target without mutation" +} + +test_nonexistent_target_refuses() { + local case_dir task_head + case_dir=$(make_case nonexistent-target main) + task_head=$(git -C "$case_dir/project" rev-parse refs/heads/fm/task-x1) + git -C "$case_dir/project" update-ref refs/remotes/origin/feature/remote-only "$task_head" + + assert_refusal_without_mutation "$case_dir" nonexistent-target \ + task-x1 --target feature/missing + assert_grep "target branch 'feature/missing' does not exist locally" "$case_dir/nonexistent-target.stderr" \ + "nonexistent-target: refusal did not identify the missing local branch" + + assert_refusal_without_mutation "$case_dir" remote-only-target \ + task-x1 --target feature/remote-only + assert_grep "target branch 'feature/remote-only' does not exist locally" "$case_dir/remote-only-target.stderr" \ + "remote-only-target: a remote-tracking ref satisfied the local target guard" + pass "fm-merge-local refuses nonexistent and remote-only explicit targets without mutation" +} + +test_invalid_and_injection_targets_refuse() { + local case_dir marker + case_dir=$(make_case invalid-targets main) + marker="$case_dir/injected" + + assert_refusal_without_mutation "$case_dir" invalid-ref \ + task-x1 --target bad..branch + assert_grep "invalid target branch 'bad..branch'" "$case_dir/invalid-ref.stderr" \ + "invalid-ref: refusal did not identify the invalid target" + + assert_refusal_without_mutation "$case_dir" injection-target \ + task-x1 --target "feature/\$(touch $marker)" + assert_grep 'unsafe or option-like target branch' "$case_dir/injection-target.stderr" \ + "injection-target: refusal did not identify unsafe input" + assert_absent "$marker" "injection-target: target input was evaluated" + pass "fm-merge-local rejects invalid and shell-injection-shaped targets without evaluation or mutation" +} + +test_task_branch_as_target_refuses() { + local case_dir + case_dir=$(make_case task-as-target main) + assert_refusal_without_mutation "$case_dir" task-as-target \ + task-x1 --target fm/task-x1 + assert_grep "target branch 'fm/task-x1' is the task branch" "$case_dir/task-as-target.stderr" \ + "task-as-target: refusal did not identify the self-target" + pass "fm-merge-local refuses the task branch as its own target without mutation" +} + +test_diverged_target_refuses() { + local case_dir + case_dir=$(make_case diverged-target feature/for-you-feed) + printf '%s\n' target > "$case_dir/project/target.txt" + git -C "$case_dir/project" add target.txt + git -C "$case_dir/project" commit -qm 'target divergence' + + assert_refusal_without_mutation "$case_dir" diverged-target \ + task-x1 --target feature/for-you-feed + assert_grep "not a fast-forward of target 'feature/for-you-feed'" "$case_dir/diverged-target.stderr" \ + "diverged-target: refusal did not name the diverged target" + pass "fm-merge-local refuses a diverged explicit target without mutation" +} + +test_missing_task_branch_refuses() { + local case_dir + case_dir=$(make_case missing-task feature/for-you-feed) + git -C "$case_dir/project" worktree remove --force "$case_dir/task-wt" + git -C "$case_dir/project" branch -D fm/task-x1 >/dev/null + + assert_refusal_without_mutation "$case_dir" missing-task \ + task-x1 --target feature/for-you-feed + assert_grep "task branch 'fm/task-x1' does not exist" "$case_dir/missing-task.stderr" \ + "missing-task: refusal did not identify the missing task branch" + assert_grep "target 'feature/for-you-feed' was not changed" "$case_dir/missing-task.stderr" \ + "missing-task: refusal did not name the unchanged target" + pass "fm-merge-local refuses a missing task branch without mutation" +} + +test_non_local_only_mode_refuses() { + local case_dir + case_dir=$(make_case wrong-mode feature/for-you-feed no-mistakes) + assert_refusal_without_mutation "$case_dir" wrong-mode \ + task-x1 --target feature/for-you-feed + assert_grep 'not local-only' "$case_dir/wrong-mode.stderr" \ + "wrong-mode: refusal did not enforce local-only mode" + pass "fm-merge-local preserves the local-only mode guard without mutation" +} + +test_option_parsing_refuses() { + local case_dir + case_dir=$(make_case option-parsing main) + + assert_refusal_without_mutation "$case_dir" missing-target-value task-x1 --target + assert_grep '--target requires a local branch name' "$case_dir/missing-target-value.stderr" \ + "missing-target-value: refusal did not explain the missing value" + + assert_refusal_without_mutation "$case_dir" empty-target-value task-x1 --target '' + assert_grep '--target requires a non-empty local branch name' "$case_dir/empty-target-value.stderr" \ + "empty-target-value: refusal did not explain the empty value" + + assert_refusal_without_mutation "$case_dir" unknown-option task-x1 --bogus + assert_grep "unknown option '--bogus'" "$case_dir/unknown-option.stderr" \ + "unknown-option: refusal did not identify the option" + + assert_refusal_without_mutation "$case_dir" extra-positional task-x1 extra + assert_grep "unexpected positional argument 'extra'" "$case_dir/extra-positional.stderr" \ + "extra-positional: refusal did not identify the extra argument" + + assert_refusal_without_mutation "$case_dir" extra-after-target \ + task-x1 --target main extra + assert_grep "unexpected extra argument 'extra'" "$case_dir/extra-after-target.stderr" \ + "extra-after-target: refusal did not identify the extra argument" + + assert_refusal_without_mutation "$case_dir" option-like-target \ + task-x1 --target --feature + assert_grep "unsafe or option-like target branch '--feature'" "$case_dir/option-like-target.stderr" \ + "option-like-target: refusal did not identify the option-like branch" + pass "fm-merge-local rejects malformed option shapes without mutation" +} + +test_legacy_default_target +test_explicit_feature_target +test_wrong_checked_out_branch_refuses +test_dirty_target_refuses +test_nonexistent_target_refuses +test_invalid_and_injection_targets_refuse +test_task_branch_as_target_refuses +test_diverged_target_refuses +test_missing_task_branch_refuses +test_non_local_only_mode_refuses +test_option_parsing_refuses From 0a3fdc22df3f18f11e822dc497d8c119e5073e15 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 02:16:04 +0400 Subject: [PATCH 03/18] no-mistakes(review): Captain, persist local merge targets for safe teardown --- bin/fm-merge-local.sh | 4 ++++ bin/fm-teardown.sh | 34 ++++++++++++++++++++++----------- tests/fm-merge-local.test.sh | 11 ++++++++++- tests/fm-teardown.test.sh | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index b4e4ba0b7..152e295a2 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -10,6 +10,8 @@ # It only runs for mode=local-only tasks and only performs a clean # `git merge --ff-only` after all guards pass. See AGENTS.md prime directives, # project management, and task lifecycle. +# After a successful fast-forward it appends `local_merge_target=` to +# the task metadata so teardown can verify the branch that actually received it. # # Usage: fm-merge-local.sh [--target ] set -eu @@ -21,6 +23,7 @@ Usage: fm-merge-local.sh [--target ] Fast-forward a clean, checked-out local branch to fm/ for an approved local-only task. Without --target, the project's default branch is the target. --target never checks out or creates the requested local branch. +Successful landing records local_merge_target in the task metadata. EOF } @@ -145,4 +148,5 @@ fi before=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") git -C "$PROJ" merge --ff-only "$BRANCH_REF" >/dev/null after=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") +printf 'local_merge_target=%s\n' "$TARGET" >> "$META" echo "merged $BRANCH into local target $TARGET ($before -> $after) in $PROJ" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9d84f4d6c..a6582cd50 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -22,9 +22,9 @@ # A gh lookup error falls back to the content check; if that is also inconclusive, # teardown refuses rather than risk discarding unlanded work. # Uncommitted changes are never landed. -# local-only projects additionally accept work merged into the local default -# branch (firstmate performs that merge after configured approval) as a fallback -# for the common case where there is no remote at all. +# local-only projects additionally accept work merged into the recorded +# `local_merge_target` branch (firstmate records it after the approved merge), +# or into the local default branch for tasks landed before that metadata existed. # Scout tasks (kind=scout in meta) carve out of that check: their worktree is # declared scratch and the report at data//report.md is the work # product. Teardown proceeds only once the report exists and the shared @@ -132,6 +132,7 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes +LOCAL_MERGE_TARGET=$(fm_meta_get "$META" local_merge_target) default_branch() { local ref branch @@ -638,7 +639,7 @@ teardown_treehouse_return() { } validate_worktree_teardown_safety() { - local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch + local dirty_raw dirty unpushed_raw unpushed landing_target landing_ref unmerged_raw unmerged branch [ -d "$WT" ] || return 0 [ "$FORCE" != "--force" ] || return 0 case "$KIND" in @@ -666,21 +667,32 @@ validate_worktree_teardown_safety() { unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) if [ -n "$unpushed" ] && [ "$MODE" = local-only ]; then - 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 + if [ -n "$LOCAL_MERGE_TARGET" ]; then + landing_target=$LOCAL_MERGE_TARGET + git check-ref-format --branch "$landing_target" >/dev/null 2>&1 \ + || { echo "REFUSED: recorded local merge target '$landing_target' is invalid." >&2; return 1; } + [ "$landing_target" != "fm/$ID" ] \ + || { echo "REFUSED: recorded local merge target is the task branch." >&2; return 1; } + else + landing_target=$(default_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; return 1; } + fi + landing_ref="refs/heads/$landing_target" + git -C "$PROJ" show-ref --verify --quiet "$landing_ref" \ + || { echo "REFUSED: recorded local merge target '$landing_target' does not exist locally." >&2; return 1; } + if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$landing_ref" -- 2>/dev/null); then + if worktree_safety_blocked_by_lock "commits not on $landing_target"; then return "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" fi - echo "REFUSED: cannot inspect worktree $WT for commits not on $DEFAULT." >&2 + echo "REFUSED: cannot inspect worktree $WT for commits not on $landing_target." >&2 echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2 return 1 fi unmerged=$(printf '%s\n' "$unmerged_raw" | head -5) if [ -n "$dirty" ] || [ -n "$unmerged" ]; then - echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2 + echo "REFUSED: local-only worktree $WT has work not yet merged into $landing_target and not on any remote." >&2 [ -n "$dirty" ] && echo "uncommitted changes present" >&2 - [ -n "$unmerged" ] && printf 'commits not yet on %s:\n%s\n' "$DEFAULT" "$unmerged" >&2 - echo "Merge the branch into local $DEFAULT first (bin/fm-merge-local.sh after the captain approves), or push to a fork/remote, or get the captain's explicit OK to discard, then --force." >&2 + [ -n "$unmerged" ] && printf 'commits not yet on %s:\n%s\n' "$landing_target" "$unmerged" >&2 + echo "Merge the branch into local $landing_target first (bin/fm-merge-local.sh after the captain approves), or push to a fork/remote, or get the captain's explicit OK to discard, then --force." >&2 return 1 fi elif [ -n "$dirty" ]; then diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index df91b517f..56bbdd12e 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -45,7 +45,8 @@ SH "worktree=$case_dir/task-wt" \ "project=$case_dir/project" \ "kind=ship" \ - "mode=$mode" + "mode=$mode" \ + "sentinel=preserved" touch "$case_dir/state/.last-watcher-beat" printf '%s\n' "$case_dir" } @@ -97,6 +98,10 @@ test_legacy_default_target() { [ "$main_after" = "$task_head" ] || fail "legacy-default: main did not fast-forward to the task branch" assert_grep 'local target main' "$case_dir/stdout" \ "legacy-default: success output did not name main" + assert_grep 'local_merge_target=main' "$case_dir/state/task-x1.meta" \ + "legacy-default: successful landing did not record main" + assert_grep 'sentinel=preserved' "$case_dir/state/task-x1.meta" \ + "legacy-default: successful landing did not preserve existing metadata" pass "fm-merge-local preserves the no-option default-branch fast-forward" } @@ -118,6 +123,10 @@ test_explicit_feature_target() { [ "$feature_before" != "$task_head" ] || fail "explicit-feature: fixture did not require a fast-forward" assert_grep 'local target feature/for-you-feed' "$case_dir/stdout" \ "explicit-feature: success output did not name the actual target" + assert_grep 'local_merge_target=feature/for-you-feed' "$case_dir/state/task-x1.meta" \ + "explicit-feature: successful landing did not record the actual target" + assert_grep 'sentinel=preserved' "$case_dir/state/task-x1.meta" \ + "explicit-feature: successful landing did not preserve existing metadata" pass "fm-merge-local fast-forwards an explicitly requested clean feature target without changing main" } diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 95b7c006c..dfaa896c6 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -56,6 +56,7 @@ set -u fm_git_identity fmtest fmtest@example.invalid TEARDOWN="$ROOT/bin/fm-teardown.sh" +MERGE_LOCAL="$ROOT/bin/fm-merge-local.sh" PR_CHECK="$ROOT/bin/fm-pr-check.sh" TMP_ROOT=$(fm_test_tmproot fm-teardown-tests) REAL_GIT_FOR_TEST=$(command -v git) @@ -497,6 +498,14 @@ run_teardown() { "$TEARDOWN" task-x1 "$@" } +run_merge_local() { + local case_dir=$1; shift + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + PATH="$case_dir/fakebin:$PATH" \ + "$MERGE_LOCAL" task-x1 "$@" +} + test_local_only_fork_remote_allows() { local case_dir rc case_dir=$(make_case fork-allow) @@ -588,6 +597,33 @@ test_local_only_merged_to_local_main_allows() { pass "local-only worktree with work merged into local main is torn down (no regression)" } +test_local_only_merge_to_recorded_feature_then_teardown_allows() { + local case_dir rc task_head + case_dir=$(make_case merged-feature) + write_meta "$case_dir" local-only ship + git -C "$case_dir/project" branch feature/for-you-feed main + git -C "$case_dir/project" checkout -q feature/for-you-feed + wt_commit "$case_dir" "merged feature work" + task_head=$(git -C "$case_dir/wt" rev-parse HEAD) + + run_merge_local "$case_dir" --target feature/for-you-feed \ + > "$case_dir/merge.stdout" 2> "$case_dir/merge.stderr" \ + || fail "merged-feature: guarded landing failed" + [ "$(git -C "$case_dir/project" rev-parse refs/heads/feature/for-you-feed)" = "$task_head" ] \ + || fail "merged-feature: feature target did not receive the task commit" + grep -qx 'local_merge_target=feature/for-you-feed' "$case_dir/state/task-x1.meta" \ + || fail "merged-feature: actual local merge target was not recorded" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "merged-feature: teardown should accept work on the recorded local target" + ! grep -q REFUSED "$case_dir/stderr" || fail "merged-feature: teardown printed a REFUSED line" + pass "local-only work merged to a recorded feature target is torn down" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -1268,6 +1304,7 @@ test_teardown_prompts_tasks_axi_done_when_compatible test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows +test_local_only_merge_to_recorded_feature_then_teardown_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From e281cfcb545e1accde0b5e6a2384cc8d196a825a Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 02:26:21 +0400 Subject: [PATCH 04/18] no-mistakes(review): Captain, harden merge metadata and report landing targets --- bin/fm-merge-local.sh | 46 ++++++++++++++++++++++++++++++++---- bin/fm-teardown.sh | 15 ++++++++++-- tests/fm-merge-local.test.sh | 35 +++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 6 +++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 152e295a2..bae3a77e7 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -10,8 +10,8 @@ # It only runs for mode=local-only tasks and only performs a clean # `git merge --ff-only` after all guards pass. See AGENTS.md prime directives, # project management, and task lifecycle. -# After a successful fast-forward it appends `local_merge_target=` to -# the task metadata so teardown can verify the branch that actually received it. +# After a successful fast-forward it atomically records one +# `local_merge_target=` in the task metadata for teardown verification. # # Usage: fm-merge-local.sh [--target ] set -eu @@ -37,6 +37,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true case "${1:-}" in @@ -53,6 +55,7 @@ case "${1:-}" in esac ID=$1 +fm_task_id_path_safe "$ID" || fail_usage "invalid task id" shift REQUESTED_TARGET= if [ "$#" -gt 0 ]; then @@ -92,7 +95,15 @@ if [ -n "$REQUESTED_TARGET" ]; then fi META="$STATE/$ID.meta" -[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } +STATE_DEVICE=$(fm_pr_file_device "$STATE") \ + || { echo "error: task metadata is unavailable" >&2; exit 1; } +if ! fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" || [ ! -f "$META" ]; then + echo "error: task metadata is unavailable" >&2 + exit 1 +fi +META_DEVICE=$(fm_pr_file_device "$META") || exit 1 +META_MODE=$(fm_pr_file_mode "$META") || exit 1 +META_IDENTITY=$(fm_pr_file_identity "$META") || exit 1 PROJ=$(grep '^project=' "$META" | cut -d= -f2-) MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) @@ -145,8 +156,35 @@ if ! git -C "$PROJ" merge-base --is-ancestor "$TARGET_REF" "$BRANCH_REF"; then exit 1 fi +META_TMP= +merge_local_cleanup() { + [ -z "$META_TMP" ] || rm -f -- "$META_TMP" +} +trap merge_local_cleanup EXIT +trap 'exit 1' HUP INT TERM +META_TMP=$(mktemp "$STATE/.fm-merge-local-meta.XXXXXX") || exit 1 +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + local_merge_target=*) ;; + *) printf '%s\n' "$line" >> "$META_TMP" || exit 1 ;; + esac +done < "$META" +printf 'local_merge_target=%s\n' "$TARGET" >> "$META_TMP" || exit 1 +chmod "$META_MODE" "$META_TMP" || exit 1 +fm_pr_private_file_valid "$META_TMP" "$META_MODE" "$STATE_DEVICE" || exit 1 +fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" \ + || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } +[ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ + || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } + before=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") git -C "$PROJ" merge --ff-only "$BRANCH_REF" >/dev/null after=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") -printf 'local_merge_target=%s\n' "$TARGET" >> "$META" +fm_pr_regular_destination_on_device_or_absent "$META" "$META_DEVICE" \ + || { echo "error: task metadata changed during local merge" >&2; exit 1; } +[ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ + || { echo "error: task metadata changed during local merge" >&2; exit 1; } +mv -f -- "$META_TMP" "$META" || exit 1 +META_TMP= +fm_pr_private_file_valid "$META" "$META_MODE" "$STATE_DEVICE" || exit 1 echo "merged $BRANCH into local target $TARGET ($before -> $after) in $PROJ" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index a6582cd50..638053557 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -394,7 +394,7 @@ work_is_landed() { } backlog_refresh_reminder() { - local pr done_cmd report_path + local pr done_cmd report_path local_note default_target [ "$KIND" = secondmate ] && return 0 if fm_tasks_axi_backend_available "$CONFIG"; then case "$KIND" in @@ -404,7 +404,18 @@ backlog_refresh_reminder() { ;; *) if [ "$MODE" = local-only ]; then - done_cmd="tasks-axi done $ID --note \"local main\"" + case "$LOCAL_MERGE_TARGET" in + '') local_note="local main" ;; + *) + default_target=$(default_branch || true) + if [ -n "$default_target" ] && [ "$LOCAL_MERGE_TARGET" = "$default_target" ]; then + local_note="local main" + else + local_note="local $LOCAL_MERGE_TARGET" + fi + ;; + esac + done_cmd="tasks-axi done $ID --note \"$local_note\"" else pr=$PR_URL if [ -n "$pr" ]; then diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 56bbdd12e..095012dda 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -111,6 +111,7 @@ test_explicit_feature_target() { main_before=$(git -C "$case_dir/project" rev-parse refs/heads/main) feature_before=$(git -C "$case_dir/project" rev-parse refs/heads/feature/for-you-feed) task_head=$(git -C "$case_dir/project" rev-parse refs/heads/fm/task-x1) + printf '%s\n' 'local_merge_target=stale-target' >> "$case_dir/state/task-x1.meta" run_merge "$case_dir" task-x1 --target feature/for-you-feed \ > "$case_dir/stdout" 2> "$case_dir/stderr" \ @@ -127,6 +128,8 @@ test_explicit_feature_target() { "explicit-feature: successful landing did not record the actual target" assert_grep 'sentinel=preserved' "$case_dir/state/task-x1.meta" \ "explicit-feature: successful landing did not preserve existing metadata" + [ "$(grep -c '^local_merge_target=' "$case_dir/state/task-x1.meta")" = 1 ] \ + || fail "explicit-feature: successful landing did not canonicalize target metadata" pass "fm-merge-local fast-forwards an explicitly requested clean feature target without changing main" } @@ -269,6 +272,36 @@ test_option_parsing_refuses() { pass "fm-merge-local rejects malformed option shapes without mutation" } +test_unsafe_task_metadata_refuses() { + local case_dir linked_meta original_meta + case_dir=$(make_case unsafe-meta-symlink main) + linked_meta="$case_dir/linked.meta" + mv "$case_dir/state/task-x1.meta" "$linked_meta" + original_meta=$(cat "$linked_meta") + ln -s "$linked_meta" "$case_dir/state/task-x1.meta" + assert_refusal_without_mutation "$case_dir" symlink-meta task-x1 + [ "$(cat "$linked_meta")" = "$original_meta" ] \ + || fail "symlink-meta: landing wrote through linked task metadata" + + case_dir=$(make_case unsafe-meta-hardlink main) + linked_meta="$case_dir/linked.meta" + ln "$case_dir/state/task-x1.meta" "$linked_meta" + original_meta=$(cat "$linked_meta") + assert_refusal_without_mutation "$case_dir" hardlink-meta task-x1 + [ "$(cat "$linked_meta")" = "$original_meta" ] \ + || fail "hardlink-meta: landing changed multiply linked task metadata" + pass "fm-merge-local refuses symlinked and multiply linked task metadata without mutation" +} + +test_unsafe_task_id_refuses() { + local case_dir + case_dir=$(make_case unsafe-task-id main) + assert_refusal_without_mutation "$case_dir" unsafe-task-id ../task-x1 + assert_grep 'invalid task id' "$case_dir/unsafe-task-id.stderr" \ + "unsafe-task-id: refusal did not identify the invalid task id" + pass "fm-merge-local rejects path-unsafe task ids without mutation" +} + test_legacy_default_target test_explicit_feature_target test_wrong_checked_out_branch_refuses @@ -280,3 +313,5 @@ test_diverged_target_refuses test_missing_task_branch_refuses test_non_local_only_mode_refuses test_option_parsing_refuses +test_unsafe_task_metadata_refuses +test_unsafe_task_id_refuses diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index dfaa896c6..79c05d641 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -586,6 +586,7 @@ test_local_only_merged_to_local_main_allows() { local wt_head wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) git -C "$case_dir/project" update-ref refs/heads/main "$wt_head" + add_compatible_tasks_axi "$case_dir" set +e run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -594,6 +595,8 @@ test_local_only_merged_to_local_main_allows() { expect_code 0 "$rc" "merged-main: teardown should succeed when work is merged into local main" ! grep -q REFUSED "$case_dir/stderr" || fail "merged-main: teardown printed a REFUSED line" + assert_grep 'tasks-axi done task-x1 --note "local main"' "$case_dir/stdout" \ + "merged-main: teardown did not retain the default local completion note" pass "local-only worktree with work merged into local main is torn down (no regression)" } @@ -613,6 +616,7 @@ test_local_only_merge_to_recorded_feature_then_teardown_allows() { || fail "merged-feature: feature target did not receive the task commit" grep -qx 'local_merge_target=feature/for-you-feed' "$case_dir/state/task-x1.meta" \ || fail "merged-feature: actual local merge target was not recorded" + add_compatible_tasks_axi "$case_dir" set +e run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -621,6 +625,8 @@ test_local_only_merge_to_recorded_feature_then_teardown_allows() { expect_code 0 "$rc" "merged-feature: teardown should accept work on the recorded local target" ! grep -q REFUSED "$case_dir/stderr" || fail "merged-feature: teardown printed a REFUSED line" + assert_grep 'tasks-axi done task-x1 --note "local feature/for-you-feed"' "$case_dir/stdout" \ + "merged-feature: teardown did not report the recorded local target" pass "local-only work merged to a recorded feature target is torn down" } From 020da588add16bbb157f3b5158f684ef990cfcf1 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 02:58:08 +0400 Subject: [PATCH 05/18] no-mistakes(test): Captain, align stale tests with vocabulary and optional tooling --- tests/fm-brief.test.sh | 2 +- tests/fm-cd-pretool-check.test.sh | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 4a55fa182..645e451c2 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -334,7 +334,7 @@ test_scout_and_secondmate_scaffold() { || fail "fm-brief.sh secondmate scaffold exited non-zero" brief="$BRIEF_HOME/data/brief-sm-q6/brief.md" assert_present "$brief" "secondmate charter was not scaffolded" - assert_grep "persistent domain supervisor" "$brief" \ + assert_grep "persistent second mate" "$brief" \ "secondmate charter must declare its role" pass "fm-brief: scout and secondmate code paths still scaffold well-formed briefs" } diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index d34bdda3a..f623430b1 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -435,6 +435,7 @@ test_pi_wiring() { } test_scripts_are_shellcheck_clean() { + command -v shellcheck >/dev/null 2>&1 || { pass "shellcheck not installed, skipping"; return; } shellcheck "$ROOT/bin/fm-cd-pretool-check.sh" >/dev/null 2>&1 \ || fail "bin/fm-cd-pretool-check.sh is not shellcheck-clean" pass "bin/fm-cd-pretool-check.sh is shellcheck-clean" From 9e6230668f665678c553719c341f26e5b3b40dc7 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 03:43:16 +0400 Subject: [PATCH 06/18] no-mistakes(review): Harden local merge target validation and command quoting --- bin/fm-merge-local.sh | 18 ++++++++++-------- bin/fm-teardown.sh | 8 +++++++- tests/fm-merge-local.test.sh | 16 ++++++++++++++++ tests/fm-teardown.test.sh | 27 +++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index bae3a77e7..a1029de57 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -75,24 +75,25 @@ if [ "$#" -gt 0 ]; then esac fi -if [ -n "$REQUESTED_TARGET" ]; then - case "$REQUESTED_TARGET" in +validate_target() { + local target=$1 single_quote backslash unsafe_char + case "$target" in -*|*[[:space:]]*) - fail_usage "unsafe or option-like target branch '$REQUESTED_TARGET'" + fail_usage "unsafe or option-like target branch '$target'" ;; esac single_quote=$(printf '\047') backslash=$(printf '\134') for unsafe_char in '$' '`' ';' '|' '&' '<' '>' "$backslash" "$single_quote" '"' '(' ')' '{' '}'; do - case "$REQUESTED_TARGET" in + case "$target" in *"$unsafe_char"*) - fail_usage "unsafe or option-like target branch '$REQUESTED_TARGET'" + fail_usage "unsafe or option-like target branch '$target'" ;; esac done - git check-ref-format --branch "$REQUESTED_TARGET" >/dev/null 2>&1 \ - || fail_usage "invalid target branch '$REQUESTED_TARGET'" -fi + git check-ref-format --branch "$target" >/dev/null 2>&1 \ + || fail_usage "invalid target branch '$target'" +} META="$STATE/$ID.meta" STATE_DEVICE=$(fm_pr_file_device "$STATE") \ @@ -130,6 +131,7 @@ if [ -n "$REQUESTED_TARGET" ]; then else TARGET=$(default_branch) || { echo "error: cannot determine target default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } fi +validate_target "$TARGET" BRANCH="fm/$ID" TARGET_REF="refs/heads/$TARGET" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 638053557..f0b7638b6 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -393,6 +393,12 @@ work_is_landed() { content_in_default } +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + backlog_refresh_reminder() { local pr done_cmd report_path local_note default_target [ "$KIND" = secondmate ] && return 0 @@ -415,7 +421,7 @@ backlog_refresh_reminder() { fi ;; esac - done_cmd="tasks-axi done $ID --note \"$local_note\"" + done_cmd="tasks-axi done $ID --note $(shell_quote "$local_note")" else pr=$PR_URL if [ -n "$pr" ]; then diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 095012dda..096527361 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -191,6 +191,21 @@ test_invalid_and_injection_targets_refuse() { pass "fm-merge-local rejects invalid and shell-injection-shaped targets without evaluation or mutation" } +test_injection_shaped_default_target_refuses() { + local case_dir target marker + case_dir=$(make_case injection-default main) + marker="$case_dir/state/injected" + target='release$(touch${IFS}$FM_STATE_OVERRIDE/injected)' + git -C "$case_dir/project" branch -m "$target" + git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$target" + + assert_refusal_without_mutation "$case_dir" injection-default task-x1 + assert_grep 'unsafe or option-like target branch' "$case_dir/injection-default.stderr" \ + "injection-default: refusal did not identify the unsafe derived target" + assert_absent "$marker" "injection-default: derived target was evaluated" + pass "fm-merge-local rejects an injection-shaped default target without mutation" +} + test_task_branch_as_target_refuses() { local case_dir case_dir=$(make_case task-as-target main) @@ -308,6 +323,7 @@ test_wrong_checked_out_branch_refuses test_dirty_target_refuses test_nonexistent_target_refuses test_invalid_and_injection_targets_refuse +test_injection_shaped_default_target_refuses test_task_branch_as_target_refuses test_diverged_target_refuses test_missing_task_branch_refuses diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 79c05d641..39b4ea893 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -595,7 +595,7 @@ test_local_only_merged_to_local_main_allows() { expect_code 0 "$rc" "merged-main: teardown should succeed when work is merged into local main" ! grep -q REFUSED "$case_dir/stderr" || fail "merged-main: teardown printed a REFUSED line" - assert_grep 'tasks-axi done task-x1 --note "local main"' "$case_dir/stdout" \ + assert_grep "tasks-axi done task-x1 --note 'local main'" "$case_dir/stdout" \ "merged-main: teardown did not retain the default local completion note" pass "local-only worktree with work merged into local main is torn down (no regression)" } @@ -625,11 +625,33 @@ test_local_only_merge_to_recorded_feature_then_teardown_allows() { expect_code 0 "$rc" "merged-feature: teardown should accept work on the recorded local target" ! grep -q REFUSED "$case_dir/stderr" || fail "merged-feature: teardown printed a REFUSED line" - assert_grep 'tasks-axi done task-x1 --note "local feature/for-you-feed"' "$case_dir/stdout" \ + assert_grep "tasks-axi done task-x1 --note 'local feature/for-you-feed'" "$case_dir/stdout" \ "merged-feature: teardown did not report the recorded local target" pass "local-only work merged to a recorded feature target is torn down" } +test_local_only_completion_note_shell_quotes_recorded_target() { + local case_dir rc marker target command + case_dir=$(make_case quoted-feature) + write_meta "$case_dir" local-only ship + marker="$case_dir/injected" + target='feature/$(touch${IFS}injected)' + printf 'local_merge_target=%s\n' "$target" >> "$case_dir/state/task-x1.meta" + add_compatible_tasks_axi "$case_dir" + + set +e + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "quoted-feature: forced teardown should succeed" + command=$(sed -n 's/^Backlog: .* Run \(tasks-axi done .*\), then run tasks-axi ready.*/\1/p' "$case_dir/stdout") + [ -n "$command" ] || fail "quoted-feature: completion command was not emitted" + ( cd "$case_dir" && PATH="$case_dir/fakebin:$PATH" eval "$command" ) + assert_absent "$marker" "quoted-feature: completion command evaluated recorded target content" + pass "local-only completion note shell-quotes the recorded target" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -1311,6 +1333,7 @@ test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_local_only_merge_to_recorded_feature_then_teardown_allows +test_local_only_completion_note_shell_quotes_recorded_target test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From 87e6832e7b286cd28aa2886317e6ec9f58c9a1bd Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 04:18:58 +0400 Subject: [PATCH 07/18] no-mistakes(document): Document explicit local merge targets --- AGENTS.md | 4 ++-- bin/fm-brief.sh | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdf7b1682..7d7d1a66a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta task metadata; docs/configuration.md routes base, backend-specific, and producer-appended fields to their authoritative owners .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check .pr-poll private validated data sidecar for the byte-static PR merge poll @@ -263,7 +263,7 @@ With `yolo` off, the captain owns ask-user findings, PR merges, and local-only m With `yolo` on, firstmate decides those routine gates and merges only green or otherwise approved work, but still escalates destructive, irreversible, and security-sensitive choices. Never merge a red PR. Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. -After an autonomous merge, give the captain a one-line full-URL or local-main outcome. +After an autonomous merge, give the captain a one-line full-URL or actual local-branch outcome. ### Validate diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 56f3008d3..49944725b 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -32,7 +32,7 @@ # no-mistakes implement -> /no-mistakes pipeline -> PR -> captain merge (default) # direct-PR implement -> push + open PR via gh-axi (no pipeline) -> captain merge # local-only implement on branch, stop and report "ready in branch" (no push/PR); -# captain approves, firstmate merges to local main +# captain approves, firstmate merges to the guarded local target # Ship briefs begin with a worktree-isolation assertion before the branch step. # Scout tasks ignore mode - their deliverable is a report, not a merge. # Every scaffold's status protocol distinguishes the configured @@ -295,9 +295,10 @@ EOF # Definition of done This project ships **local-only**: no remote, no PR, no pipeline. The task is complete only when committed on your branch \`fm/$ID\`. Do NOT push, do NOT open a PR, do NOT merge. -Keep your branch a clean fast-forward onto the current default branch - if \`main\` has advanced, rebase onto it so the eventual merge stays a fast-forward. +Keep your branch a clean fast-forward onto its intended landing target. +Unless firstmate tells you that an existing local target was selected, the target is the current default branch; if the target advances, rebase onto it so the eventual merge stays a fast-forward. When it is implemented and committed, append \`done: ready in branch fm/$ID\` to the status file and stop. -The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path. +The configured merge authority approves the ready branch, then firstmate merges it into the clean, checked-out local target through the guarded fast-forward path. EOF ) ;; From 6626bb1e4da6453181adc2ed5cd3ffc961a46c5d Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 04:24:51 +0400 Subject: [PATCH 08/18] no-mistakes(document): Update target-aware local merge assertion --- tests/fm-brief.test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 645e451c2..96c8830ed 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -85,7 +85,9 @@ test_faster_paths_use_configured_authority_without_stacked_review() { id="brief-local-authority-a4" FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" local-proj >/dev/null 2>&1 brief="$home/data/$id/brief.md" - assert_grep "The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path." "$brief" \ + assert_grep "Unless firstmate tells you that an existing local target was selected, the target is the current default branch" "$brief" \ + "local-only brief lost default-branch fallback guidance" + assert_grep "The configured merge authority approves the ready branch, then firstmate merges it into the clean, checked-out local target through the guarded fast-forward path." "$brief" \ "local-only brief lost configured merge authority and guarded landing" assert_no_grep "The captain approves the ready branch" "$brief" \ "local-only brief hard-coded captain-only authority" From e5dff3ca99e7a2b7adf78984049db38f38f12a63 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 12:42:17 +0400 Subject: [PATCH 09/18] no-mistakes(review): Captain, guard metadata updates and report merge targets accurately --- bin/fm-merge-local.sh | 5 +++++ bin/fm-teardown.sh | 11 ++--------- tests/fm-merge-local.test.sh | 35 +++++++++++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 24 ++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index a1029de57..46d8702d1 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -105,6 +105,7 @@ fi META_DEVICE=$(fm_pr_file_device "$META") || exit 1 META_MODE=$(fm_pr_file_mode "$META") || exit 1 META_IDENTITY=$(fm_pr_file_identity "$META") || exit 1 +META_HASH=$(fm_pr_sha256 "$META") || exit 1 PROJ=$(grep '^project=' "$META" | cut -d= -f2-) MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) @@ -178,6 +179,8 @@ fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" \ || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } [ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } +[ "$(fm_pr_sha256 "$META")" = "$META_HASH" ] \ + || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } before=$(git -C "$PROJ" rev-parse --short "$TARGET_REF") git -C "$PROJ" merge --ff-only "$BRANCH_REF" >/dev/null @@ -186,6 +189,8 @@ fm_pr_regular_destination_on_device_or_absent "$META" "$META_DEVICE" \ || { echo "error: task metadata changed during local merge" >&2; exit 1; } [ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ || { echo "error: task metadata changed during local merge" >&2; exit 1; } +[ "$(fm_pr_sha256 "$META")" = "$META_HASH" ] \ + || { echo "error: task metadata changed during local merge" >&2; exit 1; } mv -f -- "$META_TMP" "$META" || exit 1 META_TMP= fm_pr_private_file_valid "$META" "$META_MODE" "$STATE_DEVICE" || exit 1 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index f0b7638b6..a785598be 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -400,7 +400,7 @@ shell_quote() { } backlog_refresh_reminder() { - local pr done_cmd report_path local_note default_target + local pr done_cmd report_path local_note [ "$KIND" = secondmate ] && return 0 if fm_tasks_axi_backend_available "$CONFIG"; then case "$KIND" in @@ -412,14 +412,7 @@ backlog_refresh_reminder() { if [ "$MODE" = local-only ]; then case "$LOCAL_MERGE_TARGET" in '') local_note="local main" ;; - *) - default_target=$(default_branch || true) - if [ -n "$default_target" ] && [ "$LOCAL_MERGE_TARGET" = "$default_target" ]; then - local_note="local main" - else - local_note="local $LOCAL_MERGE_TARGET" - fi - ;; + *) local_note="local $LOCAL_MERGE_TARGET" ;; esac done_cmd="tasks-axi done $ID --note $(shell_quote "$local_note")" else diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 096527361..0d9b68755 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -12,6 +12,8 @@ fm_git_identity fmtest fmtest@example.invalid MERGE_LOCAL="$ROOT/bin/fm-merge-local.sh" TMP_ROOT=$(fm_test_tmproot fm-merge-local-tests) +REAL_GIT_FOR_TEST=$(command -v git) +export REAL_GIT_FOR_TEST make_case() { local name=$1 target=${2:-main} mode=${3:-local-only} @@ -308,6 +310,38 @@ test_unsafe_task_metadata_refuses() { pass "fm-merge-local refuses symlinked and multiply linked task metadata without mutation" } +test_concurrent_metadata_update_is_preserved() { + local case_dir fakebin rc task_head + case_dir=$(make_case concurrent-meta main) + fakebin=$(fm_fakebin "$case_dir") + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +case " $* " in + *" merge --ff-only "*) printf '%s\n' 'concurrent=preserved' >> "${FM_TEST_META:?}" ;; +esac +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$fakebin/git" + task_head=$(git -C "$case_dir/project" rev-parse refs/heads/fm/task-x1) + + set +e + PATH="$fakebin:$PATH" FM_TEST_META="$case_dir/state/task-x1.meta" \ + run_merge "$case_dir" task-x1 > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "concurrent-meta: landing silently replaced changed metadata" + assert_grep 'task metadata changed during local merge' "$case_dir/stderr" \ + "concurrent-meta: landing did not report the concurrent metadata update" + assert_grep 'concurrent=preserved' "$case_dir/state/task-x1.meta" \ + "concurrent-meta: landing discarded the concurrent metadata update" + assert_no_grep '^local_merge_target=' "$case_dir/state/task-x1.meta" \ + "concurrent-meta: landing replaced metadata after detecting an update" + [ "$(git -C "$case_dir/project" rev-parse refs/heads/main)" = "$task_head" ] \ + || fail "concurrent-meta: fixture did not reach the post-merge metadata guard" + pass "fm-merge-local preserves concurrent in-place metadata updates" +} + test_unsafe_task_id_refuses() { local case_dir case_dir=$(make_case unsafe-task-id main) @@ -330,4 +364,5 @@ test_missing_task_branch_refuses test_non_local_only_mode_refuses test_option_parsing_refuses test_unsafe_task_metadata_refuses +test_concurrent_metadata_update_is_preserved test_unsafe_task_id_refuses diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 39b4ea893..69f6aeff9 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -630,6 +630,29 @@ test_local_only_merge_to_recorded_feature_then_teardown_allows() { pass "local-only work merged to a recorded feature target is torn down" } +test_local_only_recorded_default_target_note_uses_actual_branch() { + local case_dir rc task_head + case_dir=$(make_case recorded-master) + write_meta "$case_dir" local-only ship + git -C "$case_dir/project" branch -m main master + git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/master + wt_commit "$case_dir" "merged master work" + task_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" update-ref refs/heads/master "$task_head" + printf '%s\n' 'local_merge_target=master' >> "$case_dir/state/task-x1.meta" + add_compatible_tasks_axi "$case_dir" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "recorded-master: teardown should accept work on the recorded default target" + assert_grep "tasks-axi done task-x1 --note 'local master'" "$case_dir/stdout" \ + "recorded-master: teardown mislabeled the recorded default target" + pass "local-only completion notes name the recorded default target" +} + test_local_only_completion_note_shell_quotes_recorded_target() { local case_dir rc marker target command case_dir=$(make_case quoted-feature) @@ -1333,6 +1356,7 @@ test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_local_only_merge_to_recorded_feature_then_teardown_allows +test_local_only_recorded_default_target_note_uses_actual_branch test_local_only_completion_note_shell_quotes_recorded_target test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses From 7142d22986b73de9c8ac0b205bbd43af652a3ee4 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 13:08:43 +0400 Subject: [PATCH 10/18] no-mistakes: apply CI fixes --- tests/fm-backend.test.sh | 21 ++++++++++++--------- tests/fm-merge-local.test.sh | 1 + tests/fm-teardown.test.sh | 1 + 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index c25bf8a22..1215bad26 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -630,10 +630,13 @@ run_send_case() { # -- "$bin/bin/fm-send.sh" "$@" >/dev/null 2>&1 } -strip_send_preflight() { # - local preflight +strip_send_observations() { # + local preflight cursor composer preflight=$'tmux\x1fdisplay-message\x1f-p\x1f-t\x1fsess:win\x1f#{pane_id}' - awk -v preflight="$preflight" '$0 != preflight { print }' "$1" + cursor=$'tmux\x1fdisplay-message\x1f-p\x1f-t\x1fsess:win\x1f#{cursor_y}' + composer=$'tmux\x1fcapture-pane\x1f-e\x1f-p\x1f-t\x1fsess:win\x1f-S\x1f0\x1f-E\x1f0' + awk -v preflight="$preflight" -v cursor="$cursor" -v composer="$composer" \ + '$0 != preflight && $0 != cursor && $0 != composer { print }' "$1" } test_send_conformance_old_vs_new() { @@ -652,8 +655,8 @@ test_send_conformance_old_vs_new() { expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" assert_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ "fm-send --key did not verify the explicit tmux target before sending" - strip_send_preflight "$log_old" > "$filtered_old" - strip_send_preflight "$log_new" > "$filtered_new" + strip_send_observations "$log_old" > "$filtered_old" + strip_send_observations "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ || fail "fm-send --key: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-key.txt")" assert_contains "$(cat "$log_new")" $'\x1f''Escape' "fm-send --key did not send the named key" @@ -664,8 +667,8 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" hello captain rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send plain text: old vs new exit code" - strip_send_preflight "$log_old" > "$filtered_old" - strip_send_preflight "$log_new" > "$filtered_new" + strip_send_observations "$log_old" > "$filtered_old" + strip_send_observations "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-plain.txt" 2>&1 \ || fail "fm-send plain text: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-plain.txt")" assert_contains "$(cat "$log_new")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''hello captain' \ @@ -680,8 +683,8 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" /some-skill rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send /skill: old vs new exit code" - strip_send_preflight "$log_old" > "$filtered_old" - strip_send_preflight "$log_new" > "$filtered_new" + strip_send_observations "$log_old" > "$filtered_old" + strip_send_observations "$log_new" > "$filtered_new" diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-slash.txt" 2>&1 \ || fail "fm-send /skill: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-slash.txt")" diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 0d9b68755..1d59e3f8f 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -197,6 +197,7 @@ test_injection_shaped_default_target_refuses() { local case_dir target marker case_dir=$(make_case injection-default main) marker="$case_dir/state/injected" + # shellcheck disable=SC2016 # Deliberately preserve the injection payload literally. target='release$(touch${IFS}$FM_STATE_OVERRIDE/injected)' git -C "$case_dir/project" branch -m "$target" git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$target" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 69f6aeff9..6cf902abc 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -658,6 +658,7 @@ test_local_only_completion_note_shell_quotes_recorded_target() { case_dir=$(make_case quoted-feature) write_meta "$case_dir" local-only ship marker="$case_dir/injected" + # shellcheck disable=SC2016 # Deliberately preserve the injection payload literally. target='feature/$(touch${IFS}injected)' printf 'local_merge_target=%s\n' "$target" >> "$case_dir/state/task-x1.meta" add_compatible_tasks_axi "$case_dir" From c88501d409bd43831fa140a51d7f7b534a4379c8 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 13:47:04 +0400 Subject: [PATCH 11/18] fix: preserve concurrent merge metadata --- bin/fm-merge-local.sh | 32 +++++++++++++++-------------- bin/fm-teardown.sh | 2 +- tests/fm-merge-local.test.sh | 39 ++++++++++++++++++++++++++++++++++-- tests/fm-teardown.test.sh | 23 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 46d8702d1..8fe7fb0c4 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -10,7 +10,7 @@ # It only runs for mode=local-only tasks and only performs a clean # `git merge --ff-only` after all guards pass. See AGENTS.md prime directives, # project management, and task lifecycle. -# After a successful fast-forward it atomically records one +# After a successful fast-forward it appends the effective # `local_merge_target=` in the task metadata for teardown verification. # # Usage: fm-merge-local.sh [--target ] @@ -105,6 +105,7 @@ fi META_DEVICE=$(fm_pr_file_device "$META") || exit 1 META_MODE=$(fm_pr_file_mode "$META") || exit 1 META_IDENTITY=$(fm_pr_file_identity "$META") || exit 1 +META_INODE=$(fm_pr_file_inode "$META") || exit 1 META_HASH=$(fm_pr_sha256 "$META") || exit 1 PROJ=$(grep '^project=' "$META" | cut -d= -f2-) @@ -159,22 +160,21 @@ if ! git -C "$PROJ" merge-base --is-ancestor "$TARGET_REF" "$BRANCH_REF"; then exit 1 fi -META_TMP= merge_local_cleanup() { - [ -z "$META_TMP" ] || rm -f -- "$META_TMP" + exec 3>&- 2>/dev/null || true +} +merge_local_fd_inode() { + if [ "$(uname)" = Darwin ]; then + stat -Lf '%i' /dev/fd/3 2>/dev/null + else + stat -Lc '%i' /dev/fd/3 2>/dev/null + fi } trap merge_local_cleanup EXIT trap 'exit 1' HUP INT TERM -META_TMP=$(mktemp "$STATE/.fm-merge-local-meta.XXXXXX") || exit 1 -while IFS= read -r line || [ -n "$line" ]; do - case "$line" in - local_merge_target=*) ;; - *) printf '%s\n' "$line" >> "$META_TMP" || exit 1 ;; - esac -done < "$META" -printf 'local_merge_target=%s\n' "$TARGET" >> "$META_TMP" || exit 1 -chmod "$META_MODE" "$META_TMP" || exit 1 -fm_pr_private_file_valid "$META_TMP" "$META_MODE" "$STATE_DEVICE" || exit 1 +exec 3>> "$META" || exit 1 +[ "$(merge_local_fd_inode)" = "$META_INODE" ] \ + || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" \ || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } [ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ @@ -191,7 +191,9 @@ fm_pr_regular_destination_on_device_or_absent "$META" "$META_DEVICE" \ || { echo "error: task metadata changed during local merge" >&2; exit 1; } [ "$(fm_pr_sha256 "$META")" = "$META_HASH" ] \ || { echo "error: task metadata changed during local merge" >&2; exit 1; } -mv -f -- "$META_TMP" "$META" || exit 1 -META_TMP= +printf 'local_merge_target=%s\n' "$TARGET" >&3 || exit 1 +exec 3>&- fm_pr_private_file_valid "$META" "$META_MODE" "$STATE_DEVICE" || exit 1 +[ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ + || { echo "error: task metadata changed while recording local merge target" >&2; exit 1; } echo "merged $BRANCH into local target $TARGET ($before -> $after) in $PROJ" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index a785598be..72f4d4c6d 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -411,7 +411,7 @@ backlog_refresh_reminder() { *) if [ "$MODE" = local-only ]; then case "$LOCAL_MERGE_TARGET" in - '') local_note="local main" ;; + '') local_note="local $(default_branch || printf '%s' 'default branch')" ;; *) local_note="local $LOCAL_MERGE_TARGET" ;; esac done_cmd="tasks-axi done $ID --note $(shell_quote "$local_note")" diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 1d59e3f8f..3e1b06eed 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -14,6 +14,8 @@ MERGE_LOCAL="$ROOT/bin/fm-merge-local.sh" TMP_ROOT=$(fm_test_tmproot fm-merge-local-tests) REAL_GIT_FOR_TEST=$(command -v git) export REAL_GIT_FOR_TEST +REAL_SHASUM_FOR_TEST=$(command -v shasum) +export REAL_SHASUM_FOR_TEST make_case() { local name=$1 target=${2:-main} mode=${3:-local-only} @@ -130,8 +132,8 @@ test_explicit_feature_target() { "explicit-feature: successful landing did not record the actual target" assert_grep 'sentinel=preserved' "$case_dir/state/task-x1.meta" \ "explicit-feature: successful landing did not preserve existing metadata" - [ "$(grep -c '^local_merge_target=' "$case_dir/state/task-x1.meta")" = 1 ] \ - || fail "explicit-feature: successful landing did not canonicalize target metadata" + [ "$(grep '^local_merge_target=' "$case_dir/state/task-x1.meta" | tail -1)" = 'local_merge_target=feature/for-you-feed' ] \ + || fail "explicit-feature: successful landing did not make the actual target effective" pass "fm-merge-local fast-forwards an explicitly requested clean feature target without changing main" } @@ -343,6 +345,38 @@ SH pass "fm-merge-local preserves concurrent in-place metadata updates" } +test_post_validation_metadata_update_is_preserved() { + local case_dir fakebin rc count_file + case_dir=$(make_case post-validation-meta main) + fakebin=$(fm_fakebin "$case_dir") + count_file="$case_dir/shasum-count" + cat > "$fakebin/shasum" <<'SH' +#!/usr/bin/env bash +count=0 +[ ! -f "${FM_TEST_COUNT:?}" ] || count=$(cat "$FM_TEST_COUNT") +count=$((count + 1)) +printf '%s\n' "$count" > "$FM_TEST_COUNT" +"${REAL_SHASUM_FOR_TEST:?}" "$@" +if [ "$count" -eq 3 ]; then + printf '%s\n' 'concurrent=preserved' >> "${FM_TEST_META:?}" +fi +SH + chmod +x "$fakebin/shasum" + + set +e + PATH="$fakebin:$PATH" FM_TEST_COUNT="$count_file" FM_TEST_META="$case_dir/state/task-x1.meta" \ + run_merge "$case_dir" task-x1 > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -eq 0 ] || fail "post-validation-meta: landing failed" + assert_grep 'concurrent=preserved' "$case_dir/state/task-x1.meta" \ + "post-validation-meta: landing discarded the post-validation metadata update" + [ "$(grep '^local_merge_target=' "$case_dir/state/task-x1.meta" | tail -1)" = 'local_merge_target=main' ] \ + || fail "post-validation-meta: landing did not leave the target record effective" + pass "fm-merge-local preserves metadata appended after final validation" +} + test_unsafe_task_id_refuses() { local case_dir case_dir=$(make_case unsafe-task-id main) @@ -366,4 +400,5 @@ test_non_local_only_mode_refuses test_option_parsing_refuses test_unsafe_task_metadata_refuses test_concurrent_metadata_update_is_preserved +test_post_validation_metadata_update_is_preserved test_unsafe_task_id_refuses diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 6cf902abc..a047a656c 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -653,6 +653,28 @@ test_local_only_recorded_default_target_note_uses_actual_branch() { pass "local-only completion notes name the recorded default target" } +test_legacy_local_only_completion_note_uses_actual_default_branch() { + local case_dir rc task_head + case_dir=$(make_case legacy-master) + write_meta "$case_dir" local-only ship + git -C "$case_dir/project" branch -m main master + git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/master + wt_commit "$case_dir" "merged master work" + task_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" update-ref refs/heads/master "$task_head" + add_compatible_tasks_axi "$case_dir" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "legacy-master: teardown should accept work on the legacy default target" + assert_grep "tasks-axi done task-x1 --note 'local master'" "$case_dir/stdout" \ + "legacy-master: teardown mislabeled the legacy default target" + pass "legacy local-only completion notes resolve the actual default branch" +} + test_local_only_completion_note_shell_quotes_recorded_target() { local case_dir rc marker target command case_dir=$(make_case quoted-feature) @@ -1358,6 +1380,7 @@ test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_local_only_merge_to_recorded_feature_then_teardown_allows test_local_only_recorded_default_target_note_uses_actual_branch +test_legacy_local_only_completion_note_uses_actual_default_branch test_local_only_completion_note_shell_quotes_recorded_target test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses From 3cf8b035f2accd4b4f1e0533e1e2c7b4a50e6380 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 14:02:33 +0400 Subject: [PATCH 12/18] fix: serialize task metadata replacements --- bin/fm-decision-hold.sh | 5 +++ bin/fm-merge-local.sh | 47 ++++++++++++---------- bin/fm-pr-check.sh | 21 ++++++---- bin/fm-promote.sh | 6 +++ bin/fm-teardown.sh | 11 ++++++ bin/fm-wake-lib.sh | 8 ++++ bin/fm-x-lib.sh | 63 ++++++++++++++++++++--------- tests/fm-merge-local.test.sh | 77 ++++++++++++++---------------------- 8 files changed, 144 insertions(+), 94 deletions(-) diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index aeb140a29..1e591c42a 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -51,6 +51,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-wake-lib.sh" usage() { awk ' @@ -319,7 +322,9 @@ EOF if [ "$has_meta" = 1 ]; then if [ "$(meta_value "$meta" decisions_reviewed)" != 1 ] || [ "$previous" != "$keys" ]; then + fm_meta_lock_acquire "$meta" printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$keys" >> "$meta" + fm_meta_lock_release "$meta" fi # Transfer any still-open status decision to its durable backlog owner so the diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 8fe7fb0c4..327b3a745 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -10,7 +10,7 @@ # It only runs for mode=local-only tasks and only performs a clean # `git merge --ff-only` after all guards pass. See AGENTS.md prime directives, # project management, and task lifecycle. -# After a successful fast-forward it appends the effective +# After a successful fast-forward it atomically records one # `local_merge_target=` in the task metadata for teardown verification. # # Usage: fm-merge-local.sh [--target ] @@ -39,6 +39,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true case "${1:-}" in @@ -96,6 +98,15 @@ validate_target() { } META="$STATE/$ID.meta" +fm_meta_lock_acquire "$META" +META_LOCKED=1 +META_TMP= +merge_local_cleanup() { + [ -z "$META_TMP" ] || rm -f -- "$META_TMP" + [ "${META_LOCKED:-0}" != 1 ] || fm_meta_lock_release "$META" +} +trap merge_local_cleanup EXIT +trap 'exit 1' HUP INT TERM STATE_DEVICE=$(fm_pr_file_device "$STATE") \ || { echo "error: task metadata is unavailable" >&2; exit 1; } if ! fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" || [ ! -f "$META" ]; then @@ -105,7 +116,6 @@ fi META_DEVICE=$(fm_pr_file_device "$META") || exit 1 META_MODE=$(fm_pr_file_mode "$META") || exit 1 META_IDENTITY=$(fm_pr_file_identity "$META") || exit 1 -META_INODE=$(fm_pr_file_inode "$META") || exit 1 META_HASH=$(fm_pr_sha256 "$META") || exit 1 PROJ=$(grep '^project=' "$META" | cut -d= -f2-) @@ -160,21 +170,16 @@ if ! git -C "$PROJ" merge-base --is-ancestor "$TARGET_REF" "$BRANCH_REF"; then exit 1 fi -merge_local_cleanup() { - exec 3>&- 2>/dev/null || true -} -merge_local_fd_inode() { - if [ "$(uname)" = Darwin ]; then - stat -Lf '%i' /dev/fd/3 2>/dev/null - else - stat -Lc '%i' /dev/fd/3 2>/dev/null - fi -} -trap merge_local_cleanup EXIT -trap 'exit 1' HUP INT TERM -exec 3>> "$META" || exit 1 -[ "$(merge_local_fd_inode)" = "$META_INODE" ] \ - || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } +META_TMP=$(mktemp "$STATE/.fm-merge-local-meta.XXXXXX") || exit 1 +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + local_merge_target=*) ;; + *) printf '%s\n' "$line" >> "$META_TMP" || exit 1 ;; + esac +done < "$META" +printf 'local_merge_target=%s\n' "$TARGET" >> "$META_TMP" || exit 1 +chmod "$META_MODE" "$META_TMP" || exit 1 +fm_pr_private_file_valid "$META_TMP" "$META_MODE" "$STATE_DEVICE" || exit 1 fm_pr_regular_destination_on_device_or_absent "$META" "$STATE_DEVICE" \ || { echo "error: task metadata changed during local merge preparation" >&2; exit 1; } [ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ @@ -191,9 +196,9 @@ fm_pr_regular_destination_on_device_or_absent "$META" "$META_DEVICE" \ || { echo "error: task metadata changed during local merge" >&2; exit 1; } [ "$(fm_pr_sha256 "$META")" = "$META_HASH" ] \ || { echo "error: task metadata changed during local merge" >&2; exit 1; } -printf 'local_merge_target=%s\n' "$TARGET" >&3 || exit 1 -exec 3>&- +mv -f -- "$META_TMP" "$META" || exit 1 +META_TMP= fm_pr_private_file_valid "$META" "$META_MODE" "$STATE_DEVICE" || exit 1 -[ "$(fm_pr_file_identity "$META")" = "$META_IDENTITY" ] \ - || { echo "error: task metadata changed while recording local merge target" >&2; exit 1; } +fm_meta_lock_release "$META" +META_LOCKED=0 echo "merged $BRANCH into local target $TARGET ($before -> $after) in $PROJ" diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 8fb460117..0ba5aae53 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -13,6 +13,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-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" if [ "$#" -ne 2 ]; then echo "error: invalid PR check request" >&2 @@ -31,6 +33,16 @@ NUMBER=$FM_PR_NUMBER # Task-derived paths are constructed only after the canonical ID validation. META="$STATE/$ID.meta" +fm_meta_lock_acquire "$META" +META_LOCKED=1 +META_TMP= +pr_check_cleanup() { + fm_pr_poll_cleanup + [ -z "$META_TMP" ] || rm -f -- "$META_TMP" + [ "${META_LOCKED:-0}" != 1 ] || fm_meta_lock_release "$META" +} +trap pr_check_cleanup EXIT +trap 'exit 1' HUP INT TERM if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != 1 ]; then echo "error: task metadata is unavailable" >&2 exit 1 @@ -51,13 +63,6 @@ if [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/dev/null 2>&1; then fi fi -META_TMP= -pr_check_cleanup() { - fm_pr_poll_cleanup - [ -z "$META_TMP" ] || rm -f -- "$META_TMP" -} -trap pr_check_cleanup EXIT -trap 'exit 1' HUP INT TERM fm_pr_poll_prepare "$STATE" "$ID" "$URL" "$OWNER" "$REPO" "$NUMBER" "$SCRIPT_DIR/fm-pr-poll.sh" \ || { echo "error: could not prepare PR poll" >&2; exit 1; } @@ -85,6 +90,8 @@ fm_pr_private_file_valid "$META" 600 "$STATE_DEVICE" || exit 1 fm_pr_metadata_identity_parse "$META" || exit 1 [ "$FM_PR_META_URL" = "$URL" ] && [ "$FM_PR_META_OWNER" = "$OWNER" ] \ && [ "$FM_PR_META_REPO" = "$REPO" ] && [ "$FM_PR_META_NUMBER" = "$NUMBER" ] || exit 1 +fm_meta_lock_release "$META" +META_LOCKED=0 fm_pr_poll_publish_prepared || { echo "error: could not publish PR poll" >&2 diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 827c17998..8bf53ff5a 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -13,9 +13,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true ID=$1 META="$STATE/$ID.meta" +fm_meta_lock_acquire "$META" +trap 'fm_meta_lock_release "$META"' EXIT [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } grep -qx 'kind=scout' "$META" || { echo "error: task $ID is not a scout task (kind=scout not in meta)" >&2; exit 1; } @@ -23,6 +27,8 @@ TMP="$META.tmp" grep -v '^kind=' "$META" > "$TMP" echo "kind=ship" >> "$TMP" mv "$TMP" "$META" +fm_meta_lock_release "$META" +trap - EXIT HOME_Q=$(printf '%q' "$FM_HOME") echo "promoted $ID to ship (teardown protection restored)" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 72f4d4c6d..aa8fc3f02 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -98,6 +98,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -111,6 +113,12 @@ FM_LOCK_LOG_PREFIX=teardown "$FM_ROOT/bin/fm-guard.sh" || true META="$STATE/$ID.meta" +fm_meta_lock_acquire "$META" +META_LOCKED=1 +teardown_meta_lock_cleanup() { + [ "${META_LOCKED:-0}" != 1 ] || fm_meta_lock_release "$META" +} +trap teardown_meta_lock_cleanup EXIT [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } WT=$(grep '^worktree=' "$META" | cut -d= -f2-) T=$(grep '^window=' "$META" | cut -d= -f2-) @@ -1159,6 +1167,9 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +fm_meta_lock_release "$META" +META_LOCKED=0 +trap - EXIT if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index c0e27b1e2..b6dadaca9 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -347,6 +347,14 @@ fm_lock_release() { rmdir "$lockdir" 2>/dev/null || true } +fm_meta_lock_acquire() { + fm_lock_acquire_wait "$1.lock" +} + +fm_meta_lock_release() { + fm_lock_release "$1.lock" +} + fm_wake_clean_field() { LC_ALL=C tr '\t\r\n' ' ' } diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index 0c7e83152..b80525e22 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -46,6 +46,14 @@ # fmx_meta_link_clear - remove the X-request link entirely # Callers must have FM_HOME set before calling fmx_load_config. +FM_X_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fmx_meta_lock_load() { + command -v fm_meta_lock_acquire >/dev/null 2>&1 && return 0 + # shellcheck source=bin/fm-wake-lib.sh + . "$FM_X_LIB_DIR/fm-wake-lib.sh" +} + # Read the value of KEY from a .env-style file: last assignment wins; tolerates a # leading "export ", surrounding whitespace, and one layer of matching single or # double quotes. Prints nothing (and succeeds) when the file or key is absent, so @@ -870,23 +878,31 @@ fmx_meta_tmp() { # budget against a binding the relay already knows about. Returns non-zero if # is missing or the rewrite fails. fmx_meta_link_set() { - local meta=$1 rid=$2 ts=$3 followups=${4:-0} platform=${5:-} reply_max=${6:-} tmp - [ -f "$meta" ] || return 1 - tmp=$(fmx_meta_tmp "$meta") || return 1 + local meta=$1 rid=$2 ts=$3 followups=${4:-0} platform=${5:-} reply_max=${6:-} tmp rc=0 + fmx_meta_lock_load + fm_meta_lock_acquire "$meta" + if [ ! -f "$meta" ]; then + fm_meta_lock_release "$meta" + return 1 + fi + tmp=$(fmx_meta_tmp "$meta") || { fm_meta_lock_release "$meta"; return 1; } if ! { grep -vE '^x_request=|^x_request_ts=|^x_followups=|^x_platform=|^x_reply_max_chars=' "$meta" || true; } > "$tmp"; then - rm -f "$tmp"; return 1 + rm -f "$tmp"; fm_meta_lock_release "$meta"; return 1 fi - printf 'x_request=%s\n' "$rid" >> "$tmp" || { rm -f "$tmp"; return 1; } - printf 'x_request_ts=%s\n' "$ts" >> "$tmp" || { rm -f "$tmp"; return 1; } - printf 'x_followups=%s\n' "$followups" >> "$tmp" || { rm -f "$tmp"; return 1; } + printf 'x_request=%s\n' "$rid" >> "$tmp" || rc=1 + [ "$rc" -ne 0 ] || printf 'x_request_ts=%s\n' "$ts" >> "$tmp" || rc=1 + [ "$rc" -ne 0 ] || printf 'x_followups=%s\n' "$followups" >> "$tmp" || rc=1 if [ -n "$platform" ]; then - printf 'x_platform=%s\n' "$platform" >> "$tmp" || { rm -f "$tmp"; return 1; } + [ "$rc" -ne 0 ] || printf 'x_platform=%s\n' "$platform" >> "$tmp" || rc=1 fi case "$reply_max" in ''|*[!0-9]*) ;; - *) printf 'x_reply_max_chars=%s\n' "$reply_max" >> "$tmp" || { rm -f "$tmp"; return 1; } ;; + *) [ "$rc" -ne 0 ] || printf 'x_reply_max_chars=%s\n' "$reply_max" >> "$tmp" || rc=1 ;; esac - mv -f "$tmp" "$meta" || { rm -f "$tmp"; return 1; } + [ "$rc" -ne 0 ] || mv -f "$tmp" "$meta" || rc=1 + [ "$rc" -eq 0 ] || rm -f "$tmp" + fm_meta_lock_release "$meta" + return "$rc" } # fmx_meta_followups_set : atomically rewrite just the x_followups @@ -894,13 +910,17 @@ fmx_meta_link_set() { # Returns non-zero if is missing or the rewrite fails. fmx_meta_followups_set() { local meta=$1 n=$2 tmp - [ -f "$meta" ] || return 1 - tmp=$(fmx_meta_tmp "$meta") || return 1 + fmx_meta_lock_load + fm_meta_lock_acquire "$meta" + if [ ! -f "$meta" ]; then fm_meta_lock_release "$meta"; return 1; fi + tmp=$(fmx_meta_tmp "$meta") || { fm_meta_lock_release "$meta"; return 1; } if ! { grep -vE '^x_followups=' "$meta" || true; } > "$tmp"; then - rm -f "$tmp"; return 1 + rm -f "$tmp"; fm_meta_lock_release "$meta"; return 1 + fi + if ! printf 'x_followups=%s\n' "$n" >> "$tmp" || ! mv -f "$tmp" "$meta"; then + rm -f "$tmp"; fm_meta_lock_release "$meta"; return 1 fi - printf 'x_followups=%s\n' "$n" >> "$tmp" || { rm -f "$tmp"; return 1; } - mv -f "$tmp" "$meta" || { rm -f "$tmp"; return 1; } + fm_meta_lock_release "$meta" } # fmx_meta_link_clear : atomically remove the x_request/x_request_ts/ @@ -909,10 +929,15 @@ fmx_meta_followups_set() { # missing. fmx_meta_link_clear() { local meta=$1 tmp - [ -f "$meta" ] || return 0 - tmp=$(fmx_meta_tmp "$meta") || return 1 + fmx_meta_lock_load + fm_meta_lock_acquire "$meta" + if [ ! -f "$meta" ]; then fm_meta_lock_release "$meta"; return 0; fi + tmp=$(fmx_meta_tmp "$meta") || { fm_meta_lock_release "$meta"; return 1; } if ! { grep -vE '^x_request=|^x_request_ts=|^x_followups=|^x_platform=|^x_reply_max_chars=' "$meta" || true; } > "$tmp"; then - rm -f "$tmp"; return 1 + rm -f "$tmp"; fm_meta_lock_release "$meta"; return 1 + fi + if ! mv -f "$tmp" "$meta"; then + rm -f "$tmp"; fm_meta_lock_release "$meta"; return 1 fi - mv -f "$tmp" "$meta" || { rm -f "$tmp"; return 1; } + fm_meta_lock_release "$meta" } diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 3e1b06eed..5ecaaef50 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -132,8 +132,8 @@ test_explicit_feature_target() { "explicit-feature: successful landing did not record the actual target" assert_grep 'sentinel=preserved' "$case_dir/state/task-x1.meta" \ "explicit-feature: successful landing did not preserve existing metadata" - [ "$(grep '^local_merge_target=' "$case_dir/state/task-x1.meta" | tail -1)" = 'local_merge_target=feature/for-you-feed' ] \ - || fail "explicit-feature: successful landing did not make the actual target effective" + [ "$(grep -c '^local_merge_target=' "$case_dir/state/task-x1.meta")" = 1 ] \ + || fail "explicit-feature: successful landing did not canonicalize target metadata" pass "fm-merge-local fast-forwards an explicitly requested clean feature target without changing main" } @@ -313,43 +313,12 @@ test_unsafe_task_metadata_refuses() { pass "fm-merge-local refuses symlinked and multiply linked task metadata without mutation" } -test_concurrent_metadata_update_is_preserved() { - local case_dir fakebin rc task_head - case_dir=$(make_case concurrent-meta main) - fakebin=$(fm_fakebin "$case_dir") - cat > "$fakebin/git" <<'SH' -#!/usr/bin/env bash -case " $* " in - *" merge --ff-only "*) printf '%s\n' 'concurrent=preserved' >> "${FM_TEST_META:?}" ;; -esac -exec "${REAL_GIT_FOR_TEST:?}" "$@" -SH - chmod +x "$fakebin/git" - task_head=$(git -C "$case_dir/project" rev-parse refs/heads/fm/task-x1) - - set +e - PATH="$fakebin:$PATH" FM_TEST_META="$case_dir/state/task-x1.meta" \ - run_merge "$case_dir" task-x1 > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - - [ "$rc" -ne 0 ] || fail "concurrent-meta: landing silently replaced changed metadata" - assert_grep 'task metadata changed during local merge' "$case_dir/stderr" \ - "concurrent-meta: landing did not report the concurrent metadata update" - assert_grep 'concurrent=preserved' "$case_dir/state/task-x1.meta" \ - "concurrent-meta: landing discarded the concurrent metadata update" - assert_no_grep '^local_merge_target=' "$case_dir/state/task-x1.meta" \ - "concurrent-meta: landing replaced metadata after detecting an update" - [ "$(git -C "$case_dir/project" rev-parse refs/heads/main)" = "$task_head" ] \ - || fail "concurrent-meta: fixture did not reach the post-merge metadata guard" - pass "fm-merge-local preserves concurrent in-place metadata updates" -} - -test_post_validation_metadata_update_is_preserved() { - local case_dir fakebin rc count_file - case_dir=$(make_case post-validation-meta main) +test_concurrent_metadata_replacement_waits_for_landing() { + local case_dir fakebin rc count_file done_file attempts + case_dir=$(make_case concurrent-replacement main) fakebin=$(fm_fakebin "$case_dir") count_file="$case_dir/shasum-count" + done_file="$case_dir/replacement-done" cat > "$fakebin/shasum" <<'SH' #!/usr/bin/env bash count=0 @@ -358,23 +327,38 @@ count=$((count + 1)) printf '%s\n' "$count" > "$FM_TEST_COUNT" "${REAL_SHASUM_FOR_TEST:?}" "$@" if [ "$count" -eq 3 ]; then - printf '%s\n' 'concurrent=preserved' >> "${FM_TEST_META:?}" + ( + . "${FM_TEST_WAKE_LIB:?}" + fm_meta_lock_acquire "${FM_TEST_META:?}" + replacement="${FM_TEST_META}.replacement.${BASHPID:-$$}" + { cat "$FM_TEST_META"; printf '%s\n' 'replacement=preserved'; } > "$replacement" + mv -f "$replacement" "$FM_TEST_META" + fm_meta_lock_release "$FM_TEST_META" + touch "${FM_TEST_DONE:?}" + ) >/dev/null 2>&1 & fi SH chmod +x "$fakebin/shasum" set +e - PATH="$fakebin:$PATH" FM_TEST_COUNT="$count_file" FM_TEST_META="$case_dir/state/task-x1.meta" \ + PATH="$fakebin:$PATH" FM_TEST_COUNT="$count_file" FM_TEST_DONE="$done_file" \ + FM_TEST_META="$case_dir/state/task-x1.meta" FM_TEST_WAKE_LIB="$ROOT/bin/fm-wake-lib.sh" \ run_merge "$case_dir" task-x1 > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? set -e - [ "$rc" -eq 0 ] || fail "post-validation-meta: landing failed" - assert_grep 'concurrent=preserved' "$case_dir/state/task-x1.meta" \ - "post-validation-meta: landing discarded the post-validation metadata update" - [ "$(grep '^local_merge_target=' "$case_dir/state/task-x1.meta" | tail -1)" = 'local_merge_target=main' ] \ - || fail "post-validation-meta: landing did not leave the target record effective" - pass "fm-merge-local preserves metadata appended after final validation" + [ "$rc" -eq 0 ] || fail "concurrent-replacement: landing failed" + attempts=0 + while [ ! -f "$done_file" ] && [ "$attempts" -lt 100 ]; do + sleep 0.01 + attempts=$((attempts + 1)) + done + [ -f "$done_file" ] || fail "concurrent-replacement: replacement writer did not finish" + assert_grep 'replacement=preserved' "$case_dir/state/task-x1.meta" \ + "concurrent-replacement: replacement writer did not publish" + assert_grep 'local_merge_target=main' "$case_dir/state/task-x1.meta" \ + "concurrent-replacement: active metadata lost the landing target" + pass "fm-merge-local serializes branch landing with metadata replacement" } test_unsafe_task_id_refuses() { @@ -399,6 +383,5 @@ test_missing_task_branch_refuses test_non_local_only_mode_refuses test_option_parsing_refuses test_unsafe_task_metadata_refuses -test_concurrent_metadata_update_is_preserved -test_post_validation_metadata_update_is_preserved +test_concurrent_metadata_replacement_waits_for_landing test_unsafe_task_id_refuses From 8f361373d12113787334cc5876d3e0031632e428 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 14:29:44 +0400 Subject: [PATCH 13/18] fix: harden metadata lock boundaries --- bin/fm-decision-hold.sh | 27 +++++++++-- bin/fm-pr-check.sh | 11 +++-- bin/fm-promote.sh | 6 +++ bin/fm-wake-lib.sh | 45 ++++++++++++------ bin/fm-x-lib.sh | 6 +-- tests/fm-decision-hold-lifecycle.test.sh | 34 ++++++++++++++ tests/fm-pr-check-security.test.sh | 52 +++++++++++++++++++-- tests/fm-promote.test.sh | 58 ++++++++++++++++++++++++ tests/fm-watcher-lock.test.sh | 30 ++++++++++++ 9 files changed, 240 insertions(+), 29 deletions(-) create mode 100755 tests/fm-promote.test.sh diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 1e591c42a..37f2cc1a0 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -55,6 +55,12 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-wake-lib.sh" +DECISION_META= +DECISION_META_LOCKED=0 +decision_meta_lock_cleanup() { + [ "$DECISION_META_LOCKED" != 1 ] || fm_meta_lock_release "$DECISION_META" +} + usage() { awk ' NR == 1 { next } @@ -283,7 +289,6 @@ command_complete() { validate_slug origin-id "$origin" shift meta="$STATE/$origin.meta" - [ -f "$meta" ] && has_meta=1 require_tasks_axi origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME" if [ "$#" -eq 1 ] && [ "$1" = --none ]; then @@ -296,10 +301,21 @@ command_complete() { shift done fi - if [ "$has_meta" = 1 ]; then + DECISION_META=$meta + fm_meta_lock_acquire "$meta" || fail "could not acquire metadata lock for $origin" + DECISION_META_LOCKED=1 + trap decision_meta_lock_cleanup EXIT + if [ -f "$meta" ] && [ ! -L "$meta" ]; then + has_meta=1 previous=$(meta_value "$meta" decision_keys) fi keys=$(sorted_key_union "$previous" "$supplied") + if [ "$has_meta" != 1 ]; then + fm_meta_lock_release "$meta" + DECISION_META_LOCKED=0 + DECISION_META= + trap - EXIT + fi if [ -n "$keys" ]; then while IFS= read -r key; do [ -n "$key" ] || continue @@ -321,11 +337,14 @@ $open EOF if [ "$has_meta" = 1 ]; then + [ -f "$meta" ] && [ ! -L "$meta" ] || fail "task metadata disappeared for $origin" if [ "$(meta_value "$meta" decisions_reviewed)" != 1 ] || [ "$previous" != "$keys" ]; then - fm_meta_lock_acquire "$meta" printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$keys" >> "$meta" - fm_meta_lock_release "$meta" fi + fm_meta_lock_release "$meta" + DECISION_META_LOCKED=0 + DECISION_META= + trap - EXIT # Transfer any still-open status decision to its durable backlog owner so the # live status fold does not duplicate the same Captain's Call item. diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 0ba5aae53..a229756f5 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -33,8 +33,7 @@ NUMBER=$FM_PR_NUMBER # Task-derived paths are constructed only after the canonical ID validation. META="$STATE/$ID.meta" -fm_meta_lock_acquire "$META" -META_LOCKED=1 +META_LOCKED=0 META_TMP= pr_check_cleanup() { fm_pr_poll_cleanup @@ -42,7 +41,6 @@ pr_check_cleanup() { [ "${META_LOCKED:-0}" != 1 ] || fm_meta_lock_release "$META" } trap pr_check_cleanup EXIT -trap 'exit 1' HUP INT TERM if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != 1 ]; then echo "error: task metadata is unavailable" >&2 exit 1 @@ -66,6 +64,13 @@ fi fm_pr_poll_prepare "$STATE" "$ID" "$URL" "$OWNER" "$REPO" "$NUMBER" "$SCRIPT_DIR/fm-pr-poll.sh" \ || { echo "error: could not prepare PR poll" >&2; exit 1; } +fm_meta_lock_acquire "$META" +META_LOCKED=1 +trap 'exit 1' HUP INT TERM +if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != 1 ]; then + echo "error: task metadata is unavailable" >&2 + exit 1 +fi META_DEVICE=$(fm_pr_file_device "$META") || exit 1 STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 [ "$META_DEVICE" = "$STATE_DEVICE" ] || { echo "error: task metadata is unavailable" >&2; exit 1; } diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 8bf53ff5a..43ddb2927 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -13,6 +13,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +if [ "$#" -ne 1 ] || ! fm_task_id_path_safe "$1"; then + echo "error: invalid promote request" >&2 + exit 2 +fi # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index b6dadaca9..151006787 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -111,19 +111,30 @@ fm_lock_prepare_owner() { [ "$back" = "$mypid" ] } +fm_lock_owner_path_valid() { + local lockdir=$1 owner=$2 lock_abs owner_abs suffix + case "$owner" in /*) ;; *) return 1 ;; esac + lock_abs=$(fm_lock_abs_path "$lockdir") || return 1 + [ -d "$owner" ] && [ ! -L "$owner" ] || return 1 + owner_abs=$(cd "$owner" 2>/dev/null && pwd -P) || return 1 + [ "$owner" = "$owner_abs" ] || return 1 + case "$owner" in + "$lock_abs.owner."*) suffix=${owner#"$lock_abs.owner."} ;; + *) return 1 ;; + esac + case "$suffix" in ''|*/*) return 1 ;; esac +} + fm_lock_link_owner() { local lockdir=$1 owner owner=$(readlink "$lockdir" 2>/dev/null) || return 1 - [ -n "$owner" ] || return 1 - case "$owner" in - /*) printf '%s\n' "$owner" ;; - *) printf '%s/%s\n' "$(dirname "$lockdir")" "$owner" ;; - esac + fm_lock_owner_path_valid "$lockdir" "$owner" || return 1 + printf '%s\n' "$owner" } fm_lock_points_to_owner() { local lockdir=$1 ownerdir=$2 actual - actual=$(readlink "$lockdir" 2>/dev/null) || return 1 + actual=$(fm_lock_link_owner "$lockdir") || return 1 [ "$actual" = "$ownerdir" ] } @@ -134,11 +145,11 @@ fm_lock_discard_owner() { rmdir "$ownerdir" 2>/dev/null || true } -fm_lock_remove_stray_owner_link() { - local lockdir=$1 ownerdir=$2 stray - stray="$lockdir/$(basename "$ownerdir")" - if [ -L "$stray" ] && [ "$(readlink "$stray" 2>/dev/null || true)" = "$ownerdir" ]; then - rm -f "$stray" 2>/dev/null || true +fm_lock_create_link() { + if [ "$(uname)" = Darwin ]; then + ln -sh "$1" "$2" 2>/dev/null + else + ln -sT -- "$1" "$2" 2>/dev/null fi } @@ -190,7 +201,7 @@ fm_lock_try_create() { fm_lock_discard_owner "$ownerdir" return 1 fi - if ln -s "$ownerdir" "$lockdir" 2>/dev/null && fm_lock_points_to_owner "$lockdir" "$ownerdir"; then + if fm_lock_create_link "$ownerdir" "$lockdir" && fm_lock_points_to_owner "$lockdir" "$ownerdir"; then if fm_lock_claim "$lockdir" "$ownerdir" "$allowed_steal_owner"; then FM_LOCK_OWNER_DIR=$ownerdir return 0 @@ -198,8 +209,6 @@ fm_lock_try_create() { if fm_lock_points_to_owner "$lockdir" "$ownerdir"; then rm -f "$lockdir" 2>/dev/null || true fi - else - fm_lock_remove_stray_owner_link "$lockdir" "$ownerdir" fi fm_lock_discard_owner "$ownerdir" return 1 @@ -348,7 +357,13 @@ fm_lock_release() { } fm_meta_lock_acquire() { - fm_lock_acquire_wait "$1.lock" + local lockdir="$1.lock" candidate + for candidate in "$lockdir" "$lockdir.steal"; do + if [ -L "$candidate" ] && ! fm_lock_link_owner "$candidate" >/dev/null; then + return 1 + fi + done + fm_lock_acquire_wait "$lockdir" } fm_meta_lock_release() { diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index b80525e22..81e791778 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -880,7 +880,7 @@ fmx_meta_tmp() { fmx_meta_link_set() { local meta=$1 rid=$2 ts=$3 followups=${4:-0} platform=${5:-} reply_max=${6:-} tmp rc=0 fmx_meta_lock_load - fm_meta_lock_acquire "$meta" + fm_meta_lock_acquire "$meta" || return 1 if [ ! -f "$meta" ]; then fm_meta_lock_release "$meta" return 1 @@ -911,7 +911,7 @@ fmx_meta_link_set() { fmx_meta_followups_set() { local meta=$1 n=$2 tmp fmx_meta_lock_load - fm_meta_lock_acquire "$meta" + fm_meta_lock_acquire "$meta" || return 1 if [ ! -f "$meta" ]; then fm_meta_lock_release "$meta"; return 1; fi tmp=$(fmx_meta_tmp "$meta") || { fm_meta_lock_release "$meta"; return 1; } if ! { grep -vE '^x_followups=' "$meta" || true; } > "$tmp"; then @@ -930,7 +930,7 @@ fmx_meta_followups_set() { fmx_meta_link_clear() { local meta=$1 tmp fmx_meta_lock_load - fm_meta_lock_acquire "$meta" + fm_meta_lock_acquire "$meta" || return 1 if [ ! -f "$meta" ]; then fm_meta_lock_release "$meta"; return 0; fi tmp=$(fmx_meta_tmp "$meta") || { fm_meta_lock_release "$meta"; return 1; } if ! { grep -vE '^x_request=|^x_request_ts=|^x_followups=|^x_platform=|^x_reply_max_chars=' "$meta" || true; } > "$tmp"; then diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 0ef84c4a6..955d22a49 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -550,11 +550,45 @@ test_resolve_matches_quoted_blocked_by_edges() { pass "resolve matches first/middle/last in quoted blocked_by and rejects a genuinely absent id" } +test_completion_does_not_recreate_metadata_removed_while_waiting() { + local home id ready release holder completion i rc + home=$(make_home stale-meta-race) + id=stale-origin + tasks_in "$home" add "$id" "Investigate stale metadata" --kind scout --repo sample --start >/dev/null \ + || fail "could not create stale metadata origin" + write_origin_meta "$home" "$id" + ready="$home/lock-ready" + release="$home/lock-release" + FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1" + fm_meta_lock_acquire "$2" + touch "$3" + while [ ! -e "$4" ]; do sleep 0.01; done + fm_meta_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state/$id.meta" "$ready" "$release" & + holder=$! + i=0 + while [ ! -e "$ready" ] && [ "$i" -lt 100 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$ready" ] || fail "stale metadata lock holder did not start" + run_decisions "$home" complete "$id" --none > "$home/complete.out" 2> "$home/complete.err" & + completion=$! + sleep 0.1 + rm -f "$home/state/$id.meta" + touch "$release" + wait "$holder" || fail "stale metadata lock holder failed" + rc=0 + wait "$completion" || rc=$? + [ "$rc" -eq 0 ] || fail "completion failed after metadata teardown: $(cat "$home/complete.err")" + assert_absent "$home/state/$id.meta" "completion recreated removed task metadata" + pass "completion revalidates metadata after waiting for its lock" +} + test_uninventoried_report_decision_refuses_completion test_scout_teardown_always_requires_inventory_verification test_structured_holds_survive_teardown_and_route_resolution test_origin_slug_validation_precedes_path_construction +test_completion_does_not_recreate_metadata_removed_while_waiting test_visual_review_uses_shared_completion_owner test_none_inventory_and_resolved_prose_do_not_create_holds test_terminal_single_owner_status_decision_does_not_block_empty_inventory diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 9d4154e50..313d1e59d 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -65,7 +65,13 @@ SH #!/usr/bin/env bash printf '%s\n' "$*" >> "$FM_TEST_GH_LOG" case " $* " in - *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" ;; + *" headRefOid "*) + if [ -n "${FM_TEST_GH_GATE_READY:-}" ]; then + touch "$FM_TEST_GH_GATE_READY" + while [ ! -e "${FM_TEST_GH_GATE_RELEASE:?}" ]; do sleep 0.01; done + fi + printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" + ;; *" state "*) [ "${FM_TEST_GH_FAIL:-0}" = 0 ] || exit 1 [ "${FM_TEST_GH_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GH_SLEEP" @@ -225,7 +231,10 @@ run_check_entry() { shift FM_ROOT_OVERRIDE="$dir/root" FM_HOME="$dir/home" \ FM_TEST_GUARD_LOG="$dir/guard.log" FM_TEST_GH_LOG="$dir/gh.log" \ - FM_TEST_GH_AXI_LOG="$dir/gh-axi.log" PATH="$dir/fakebin:$BASE_PATH" \ + FM_TEST_GH_AXI_LOG="$dir/gh-axi.log" \ + FM_TEST_GH_GATE_READY="${FM_TEST_GH_GATE_READY:-}" \ + FM_TEST_GH_GATE_RELEASE="${FM_TEST_GH_GATE_RELEASE:-}" \ + PATH="$dir/fakebin:$BASE_PATH" \ "$PR_CHECK" "$@" } @@ -238,6 +247,40 @@ run_merge_entry() { "$PR_MERGE" "$@" } +test_pr_lookup_does_not_hold_task_metadata_lock() { + local dir ready release acquired check_pid lock_pid i rc + dir=$(make_case pr-lock-scope) + write_task_meta "$dir" + ready="$dir/gh-ready" + release="$dir/gh-release" + acquired="$dir/meta-lock-acquired" + FM_TEST_GH_GATE_READY="$ready" FM_TEST_GH_GATE_RELEASE="$release" \ + run_check_entry "$dir" task-a https://github.com/o/r/pull/10 \ + > "$dir/check.out" 2> "$dir/check.err" & + check_pid=$! + i=0 + while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$ready" ] || fail "PR lookup gate did not start" + FM_STATE_OVERRIDE="$dir/home/state" bash -c ' + . "$1" + fm_meta_lock_acquire "$2" || exit 1 + touch "$3" + fm_meta_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$dir/home/state/task-a.meta" "$acquired" & + lock_pid=$! + i=0 + while [ ! -e "$acquired" ] && [ "$i" -lt 100 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$acquired" ] || fail "PR lookup held the task metadata lock" + wait "$lock_pid" || fail "metadata lock probe failed" + touch "$release" + rc=0 + wait "$check_pid" || rc=$? + [ "$rc" -eq 0 ] || fail "PR check failed after gated lookup: $(cat "$dir/check.err")" + assert_grep 'pr=https://github.com/o/r/pull/10' "$dir/home/state/task-a.meta" \ + "PR check did not record metadata after the lookup" + pass "fm-pr-check keeps external PR lookup outside the metadata lock" +} + # shellcheck disable=SC2016 # Literal rejected URL bytes are parser test data. INVALID_URLS=( 'https://github.com/o/r/pull/1/' @@ -744,11 +787,11 @@ SH run_check_entry "$dir" task-a https://github.com/o/r/pull/1 > "$dir/direct.out" 2> "$dir/direct.err" & direct_pid=$! i=0 - while [ "$i" -lt 100 ] && ! find "$dir/home/state" -name '.fm-pr-poll-check.*' -print | grep . >/dev/null; do + while [ "$i" -lt 300 ] && ! find "$dir/home/state" -name '.fm-pr-poll-check.*' -print | grep . >/dev/null; do sleep 0.01 i=$((i + 1)) done - [ "$i" -lt 100 ] || fail "atomic publication did not reach staged check" + [ "$i" -lt 300 ] || fail "atomic publication did not reach staged check" set +e FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch.out" 2> "$dir/watch.err" @@ -2681,6 +2724,7 @@ SH test_parser_matrix test_invalid_entrypoints_have_zero_side_effects test_valid_recording_and_merge_derivation +test_pr_lookup_does_not_hold_task_metadata_lock test_rejected_metacharacter_bytes_are_inert test_static_poll_contract test_atomic_interruption_leaves_no_partial_artifact diff --git a/tests/fm-promote.test.sh b/tests/fm-promote.test.sh new file mode 100755 index 000000000..2646444fb --- /dev/null +++ b/tests/fm-promote.test.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -u + +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +PROMOTE="$ROOT/bin/fm-promote.sh" +TMP_ROOT=$(fm_test_tmproot fm-promote-tests) + +make_case() { + local name=$1 dir + dir="$TMP_ROOT/$name" + mkdir -p "$dir/home/state" "$dir/fm-root/bin" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$dir/fm-root/bin/fm-guard.sh" + chmod +x "$dir/fm-root/bin/fm-guard.sh" + printf '%s\n' "$dir" +} + +run_promote() { + local dir=$1 + shift + FM_ROOT_OVERRIDE="$dir/fm-root" FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + "$PROMOTE" "$@" +} + +test_invalid_ids_refuse_before_lock_paths() { + local dir rc escaped + dir=$(make_case invalid) + escaped="$dir/outside.meta.lock" + rc=0 + run_promote "$dir" ../../outside > "$dir/stdout" 2> "$dir/stderr" || rc=$? + [ "$rc" -eq 2 ] || fail "invalid promote id returned $rc" + assert_absent "$escaped" "invalid promote id created an escaped lock" + assert_grep 'invalid promote request' "$dir/stderr" "invalid promote id lacked a usage error" + + rc=0 + run_promote "$dir" safe extra > "$dir/extra.stdout" 2> "$dir/extra.stderr" || rc=$? + [ "$rc" -eq 2 ] || fail "extra promote argument returned $rc" + assert_absent "$dir/home/state/safe.meta.lock" "extra promote argument created a lock" + pass "fm-promote validates argument count and task ids before lock paths" +} + +test_valid_promotion_keeps_metadata_lock_scoped() { + local dir + dir=$(make_case valid) + fm_write_meta "$dir/home/state/task.meta" \ + 'kind=scout' \ + 'mode=no-mistakes' \ + 'sentinel=preserved' + run_promote "$dir" task > "$dir/stdout" 2> "$dir/stderr" \ + || fail "valid promote failed" + assert_grep 'kind=ship' "$dir/home/state/task.meta" "valid promote did not set ship kind" + assert_grep 'sentinel=preserved' "$dir/home/state/task.meta" "valid promote lost metadata" + assert_absent "$dir/home/state/task.meta.lock" "valid promote left its metadata lock" + pass "fm-promote retains valid promotion behavior" +} + +test_invalid_ids_refuse_before_lock_paths +test_valid_promotion_keeps_metadata_lock_scoped diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 984824376..578d869e7 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -909,8 +909,38 @@ test_pid_identity_is_locale_invariant() { pass "fm_pid_identity is locale-invariant across LC_ALL/LC_TIME" } +test_metadata_lock_rejects_noncanonical_owner_symlinks() { + local dir state meta external lock rc + dir=$(make_case metadata-owner-symlink) + state="$dir/state" + meta="$state/task.meta" + external="$dir/external-owner" + lock="$meta.lock" + mkdir "$external" + printf '%s\n' sentinel > "$external/pid" + printf '%s\n' preserve > "$external/fm-home" + ln -s "$external" "$lock" + rc=0 + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_meta_lock_acquire "$2"' _ "$LIB" "$meta" || rc=$? + [ "$rc" -ne 0 ] || fail "metadata lock accepted an external owner symlink" + [ "$(cat "$external/pid")" = sentinel ] || fail "metadata lock altered the external pid file" + [ "$(cat "$external/fm-home")" = preserve ] || fail "metadata lock altered the external owner directory" + + rm -f "$lock" + mkdir "$lock" + printf '%s\n' "$(dead_pid)" > "$lock/pid" + ln -s "$external" "$lock.steal" + rc=0 + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_meta_lock_acquire "$2"' _ "$LIB" "$meta" || rc=$? + [ "$rc" -ne 0 ] || fail "metadata lock accepted an external steal-owner symlink" + [ "$(cat "$external/pid")" = sentinel ] || fail "steal lock altered the external pid file" + [ "$(cat "$external/fm-home")" = preserve ] || fail "steal lock altered the external owner directory" + pass "metadata locks reject noncanonical primary and steal owner symlinks" +} + test_singleton_start test_pid_identity_is_locale_invariant +test_metadata_lock_rejects_noncanonical_owner_symlinks test_stale_watch_lock_reclaimed test_live_stale_watch_lock_is_actionable test_guard_warnings From 6083594a71697aff5e95926295f551d7090973cb Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 14:39:36 +0400 Subject: [PATCH 14/18] fix: bind PR publication to task identity --- bin/fm-pr-check.sh | 16 ++++-- tests/fm-pr-check-security.test.sh | 82 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index a229756f5..f5a41143b 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -35,6 +35,14 @@ NUMBER=$FM_PR_NUMBER META="$STATE/$ID.meta" META_LOCKED=0 META_TMP= +pr_check_task_identity() { + local meta=$1 key line + for key in window worktree project harness kind mode tasktmp backend terminal home \ + herdr_session zellij_session orca_worktree_id cmux_workspace_id; do + line=$(grep "^$key=" "$meta" 2>/dev/null | tail -1 || true) + printf '%s\n' "$line" + done +} pr_check_cleanup() { fm_pr_poll_cleanup [ -z "$META_TMP" ] || rm -f -- "$META_TMP" @@ -45,6 +53,7 @@ if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != echo "error: task metadata is unavailable" >&2 exit 1 fi +TASK_IDENTITY=$(pr_check_task_identity "$META") # Neutralize any pre-fix poll before recording or arming this task. The # migration never executes legacy artifacts and holds watcher exclusion while @@ -71,6 +80,8 @@ if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != echo "error: task metadata is unavailable" >&2 exit 1 fi +[ "$(pr_check_task_identity "$META")" = "$TASK_IDENTITY" ] \ + || { echo "error: task metadata identity changed during PR lookup" >&2; exit 1; } META_DEVICE=$(fm_pr_file_device "$META") || exit 1 STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 [ "$META_DEVICE" = "$STATE_DEVICE" ] || { echo "error: task metadata is unavailable" >&2; exit 1; } @@ -95,11 +106,10 @@ fm_pr_private_file_valid "$META" 600 "$STATE_DEVICE" || exit 1 fm_pr_metadata_identity_parse "$META" || exit 1 [ "$FM_PR_META_URL" = "$URL" ] && [ "$FM_PR_META_OWNER" = "$OWNER" ] \ && [ "$FM_PR_META_REPO" = "$REPO" ] && [ "$FM_PR_META_NUMBER" = "$NUMBER" ] || exit 1 -fm_meta_lock_release "$META" -META_LOCKED=0 - fm_pr_poll_publish_prepared || { echo "error: could not publish PR poll" >&2 exit 1 } +fm_meta_lock_release "$META" +META_LOCKED=0 printf 'armed: state/%s.check.sh\n' "$ID" diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 313d1e59d..9d8d481b3 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -281,6 +281,86 @@ test_pr_lookup_does_not_hold_task_metadata_lock() { pass "fm-pr-check keeps external PR lookup outside the metadata lock" } +test_pr_lookup_rejects_recreated_task_metadata() { + local dir ready release check_pid i rc + dir=$(make_case pr-stale-task) + write_task_meta "$dir" + ready="$dir/gh-ready" + release="$dir/gh-release" + FM_TEST_GH_GATE_READY="$ready" FM_TEST_GH_GATE_RELEASE="$release" \ + run_check_entry "$dir" task-a https://github.com/o/r/pull/10 \ + > "$dir/check.out" 2> "$dir/check.err" & + check_pid=$! + i=0 + while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$ready" ] || fail "stale-task PR lookup gate did not start" + fm_write_meta "$dir/home/state/task-a.meta.new" \ + 'window=fm-task-a-recreated' \ + "worktree=$dir/recreated-wt" \ + "project=$dir/recreated-project" \ + 'kind=ship' \ + 'mode=no-mistakes' \ + 'sentinel=recreated' + mv -f "$dir/home/state/task-a.meta.new" "$dir/home/state/task-a.meta" + touch "$release" + rc=0 + wait "$check_pid" || rc=$? + [ "$rc" -ne 0 ] || fail "PR check accepted recreated task metadata" + assert_grep 'sentinel=recreated' "$dir/home/state/task-a.meta" \ + "PR check replaced the recreated task metadata" + assert_no_grep 'pr=https://github.com/o/r/pull/10' "$dir/home/state/task-a.meta" \ + "PR check wrote the stale PR into recreated task metadata" + assert_absent "$dir/home/state/task-a.check.sh" "stale-task PR check published a poll" + assert_grep 'task metadata identity changed' "$dir/check.err" \ + "stale-task PR check did not report the identity change" + pass "fm-pr-check rejects task recreation during external lookup" +} + +test_pr_poll_publication_stays_inside_metadata_lock() { + local dir fakebin ready release acquired check_pid lock_pid i rc + dir=$(make_case pr-publish-lock) + fakebin="$dir/fakebin" + write_task_meta "$dir" + ready="$dir/publish-ready" + release="$dir/publish-release" + acquired="$dir/meta-lock-acquired" + cat > "$fakebin/mv" < "$dir/check.out" 2> "$dir/check.err" & + check_pid=$! + i=0 + while [ ! -e "$ready" ] && [ "$i" -lt 300 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$ready" ] || fail "poll publication gate did not start" + FM_STATE_OVERRIDE="$dir/home/state" bash -c ' + . "$1" + fm_meta_lock_acquire "$2" || exit 1 + touch "$3" + fm_meta_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$dir/home/state/task-a.meta" "$acquired" & + lock_pid=$! + sleep 0.1 + assert_absent "$acquired" "poll publication released the metadata lock early" + touch "$release" + rc=0 + wait "$check_pid" || rc=$? + [ "$rc" -eq 0 ] || fail "gated PR publication failed: $(cat "$dir/check.err")" + wait "$lock_pid" || fail "post-publication metadata lock probe failed" + assert_present "$acquired" "metadata lock remained unavailable after publication" + pass "fm-pr-check publishes prepared polls inside the metadata lock" +} + # shellcheck disable=SC2016 # Literal rejected URL bytes are parser test data. INVALID_URLS=( 'https://github.com/o/r/pull/1/' @@ -2725,6 +2805,8 @@ test_parser_matrix test_invalid_entrypoints_have_zero_side_effects test_valid_recording_and_merge_derivation test_pr_lookup_does_not_hold_task_metadata_lock +test_pr_lookup_rejects_recreated_task_metadata +test_pr_poll_publication_stays_inside_metadata_lock test_rejected_metacharacter_bytes_are_inert test_static_poll_contract test_atomic_interruption_leaves_no_partial_artifact From 6b44bc3a781c8f25d74ff70633390de7216505b9 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 14:52:07 +0400 Subject: [PATCH 15/18] fix: bind PR checks to task instances --- bin/fm-pr-check.sh | 16 +++++++++++++--- bin/fm-pr-lib.sh | 13 +++++++++++++ bin/fm-spawn.sh | 5 +++++ tests/fm-pr-check-security.test.sh | 19 ++++++++++++++++--- tests/fm-spawn-dispatch-profile.test.sh | 2 ++ 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index f5a41143b..a2e93e7ac 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -36,7 +36,14 @@ META="$STATE/$ID.meta" META_LOCKED=0 META_TMP= pr_check_task_identity() { - local meta=$1 key line + local meta=$1 key line instance + line=$(grep '^task_instance=' "$meta" 2>/dev/null | tail -1 || true) + if [ -n "$line" ]; then + instance=${line#task_instance=} + fm_task_instance_valid "$instance" || return 1 + printf '%s\n' "$line" + return 0 + fi for key in window worktree project harness kind mode tasktmp backend terminal home \ herdr_session zellij_session orca_worktree_id cmux_workspace_id; do line=$(grep "^$key=" "$meta" 2>/dev/null | tail -1 || true) @@ -53,7 +60,8 @@ if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != echo "error: task metadata is unavailable" >&2 exit 1 fi -TASK_IDENTITY=$(pr_check_task_identity "$META") +TASK_IDENTITY=$(pr_check_task_identity "$META") \ + || { echo "error: task metadata identity is invalid" >&2; exit 1; } # Neutralize any pre-fix poll before recording or arming this task. The # migration never executes legacy artifacts and holds watcher exclusion while @@ -80,7 +88,9 @@ if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != echo "error: task metadata is unavailable" >&2 exit 1 fi -[ "$(pr_check_task_identity "$META")" = "$TASK_IDENTITY" ] \ +CURRENT_TASK_IDENTITY=$(pr_check_task_identity "$META") \ + || { echo "error: task metadata identity is invalid" >&2; exit 1; } +[ "$CURRENT_TASK_IDENTITY" = "$TASK_IDENTITY" ] \ || { echo "error: task metadata identity changed during PR lookup" >&2; exit 1; } META_DEVICE=$(fm_pr_file_device "$META") || exit 1 STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 96e5613af..fd404296c 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -61,6 +61,19 @@ fm_task_id_creation_valid() { [ "${#id}" -le 64 ] } +fm_task_instance_valid() { + local instance=${1-} + local LC_ALL=C + [[ "$instance" =~ ^[0-9a-f]{32}$ ]] +} + +fm_task_instance_new() { + local instance + instance=$(od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n') + fm_task_instance_valid "$instance" || return 1 + printf '%s\n' "$instance" +} + fm_pr_url_parse() { local raw=${1-} pattern local LC_ALL=C diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fc177354b..f195ad6e7 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -79,6 +79,7 @@ # On success prints: spawned harness= kind= mode= yolo= window= worktree= # mode/yolo are resolved per-project from data/projects.md for ship/scout tasks; # secondmate spawns record mode=secondmate, yolo=off, home=, and projects=. +# Every task metadata record includes a generation-unique task_instance= token. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -223,6 +224,7 @@ orca_spawn_abort_cleanup() { if [ -d "$STATE" ]; then { echo "window=$W" + echo "task_instance=$TASK_INSTANCE" echo "worktree=${WT:-}" echo "project=$PROJ_ABS" echo "harness=$HARNESS" @@ -281,6 +283,8 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * fi ID=${POS[0]} fm_task_id_creation_valid "$ID" || { echo "error: invalid task id" >&2; exit 2; } +TASK_INSTANCE=$(fm_task_instance_new) \ + || { echo "error: could not create task instance identity" >&2; exit 1; } PROJ= ARG3= FIRSTMATE_HOME= @@ -990,6 +994,7 @@ META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W { echo "window=$META_WINDOW" + echo "task_instance=$TASK_INSTANCE" echo "worktree=$WT" echo "project=$PROJ_ABS" echo "harness=$HARNESS" diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 9d8d481b3..ca0d538b9 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -95,6 +95,7 @@ write_task_meta() { local dir=$1 id=${2:-task-a} fm_write_meta "$dir/home/state/$id.meta" \ "window=fm-$id" \ + 'task_instance=11111111111111111111111111111111' \ "worktree=$dir/wt" \ "project=$dir/project" \ "kind=ship" \ @@ -295,9 +296,10 @@ test_pr_lookup_rejects_recreated_task_metadata() { while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done [ -e "$ready" ] || fail "stale-task PR lookup gate did not start" fm_write_meta "$dir/home/state/task-a.meta.new" \ - 'window=fm-task-a-recreated' \ - "worktree=$dir/recreated-wt" \ - "project=$dir/recreated-project" \ + 'window=fm-task-a' \ + 'task_instance=22222222222222222222222222222222' \ + "worktree=$dir/wt" \ + "project=$dir/project" \ 'kind=ship' \ 'mode=no-mistakes' \ 'sentinel=recreated' @@ -316,6 +318,16 @@ test_pr_lookup_rejects_recreated_task_metadata() { pass "fm-pr-check rejects task recreation during external lookup" } +test_task_instance_generation_is_unique() { + local first second + first=$(fm_task_instance_new) || fail "could not generate first task instance" + second=$(fm_task_instance_new) || fail "could not generate second task instance" + fm_task_instance_valid "$first" || fail "first task instance was invalid" + fm_task_instance_valid "$second" || fail "second task instance was invalid" + [ "$first" != "$second" ] || fail "task instance generation repeated a token" + pass "task instance generation creates distinct validated identities" +} + test_pr_poll_publication_stays_inside_metadata_lock() { local dir fakebin ready release acquired check_pid lock_pid i rc dir=$(make_case pr-publish-lock) @@ -2804,6 +2816,7 @@ SH test_parser_matrix test_invalid_entrypoints_have_zero_side_effects test_valid_recording_and_merge_derivation +test_task_instance_generation_is_unique test_pr_lookup_does_not_hold_task_metadata_lock test_pr_lookup_rejects_recreated_task_metadata test_pr_poll_publication_stays_inside_metadata_lock diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d549c9bce..dd21d915a 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -100,6 +100,8 @@ EOF assert_meta_profile() { local meta=$1 harness=$2 model=$3 effort=$4 + grep -Eq '^task_instance=[0-9a-f]{32}$' "$meta" \ + || fail "meta missing a valid task_instance" assert_grep "harness=$harness" "$meta" "meta missing harness=$harness" assert_grep "model=$model" "$meta" "meta missing model=$model" assert_grep "effort=$effort" "$meta" "meta missing effort=$effort" From 4f0f19ad8d1be68f816b7c968fb7821028c160d2 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 15:01:21 +0400 Subject: [PATCH 16/18] fix: serialize spawn metadata publication --- bin/fm-spawn.sh | 62 ++++++++++++++++++------- tests/fm-backend-orca.test.sh | 46 ++++++++++++++++-- tests/fm-spawn-dispatch-profile.test.sh | 45 ++++++++++++++++++ 3 files changed, 131 insertions(+), 22 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index f195ad6e7..a2b6c08d1 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -193,6 +193,16 @@ fi ORCA_ABORT_CLEANUP=0 ORCA_WORKTREE_ID= ORCA_TERMINAL= +META_LOCKED=0 +META_LOCK_AVAILABLE=0 + +spawn_meta_lock_load() { + [ "$META_LOCK_AVAILABLE" = 0 ] || return 0 + [ -d "$STATE" ] || return 1 + # shellcheck source=bin/fm-wake-lib.sh + . "$SCRIPT_DIR/fm-wake-lib.sh" + META_LOCK_AVAILABLE=1 +} parse_orca_worktree_result() { local raw=$1 rest @@ -213,6 +223,10 @@ parse_orca_worktree_result() { orca_spawn_abort_cleanup() { local status=$? + if [ "${META_LOCKED:-0}" = 1 ]; then + fm_meta_lock_release "$STATE/$ID.meta" + META_LOCKED=0 + fi [ "$ORCA_ABORT_CLEANUP" = 1 ] || return "$status" ORCA_ABORT_CLEANUP=0 if [ -n "${ORCA_TERMINAL:-}" ]; then @@ -222,22 +236,27 @@ orca_spawn_abort_cleanup() { if ! fm_backend_remove_worktree orca "$ORCA_WORKTREE_ID" 2>/dev/null; then mkdir -p "$STATE" 2>/dev/null || true if [ -d "$STATE" ]; then - { - echo "window=$W" - echo "task_instance=$TASK_INSTANCE" - echo "worktree=${WT:-}" - echo "project=$PROJ_ABS" - echo "harness=$HARNESS" - echo "kind=$KIND" - echo "mode=${MODE:-no-mistakes}" - echo "yolo=${YOLO:-off}" - echo "tasktmp=${TASK_TMP:-}" - echo "model=${MODEL:-default}" - echo "effort=${EFFORT:-default}" - echo "backend=orca" - echo "orca_worktree_id=$ORCA_WORKTREE_ID" - [ -z "${ORCA_TERMINAL:-}" ] || echo "terminal=$ORCA_TERMINAL" - } > "$STATE/$ID.meta" 2>/dev/null || true + if spawn_meta_lock_load && fm_meta_lock_acquire "$STATE/$ID.meta"; then + META_LOCKED=1 + { + echo "window=$W" + echo "task_instance=$TASK_INSTANCE" + echo "worktree=${WT:-}" + echo "project=$PROJ_ABS" + echo "harness=$HARNESS" + echo "kind=$KIND" + echo "mode=${MODE:-no-mistakes}" + echo "yolo=${YOLO:-off}" + echo "tasktmp=${TASK_TMP:-}" + echo "model=${MODEL:-default}" + echo "effort=${EFFORT:-default}" + echo "backend=orca" + echo "orca_worktree_id=$ORCA_WORKTREE_ID" + [ -z "${ORCA_TERMINAL:-}" ] || echo "terminal=$ORCA_TERMINAL" + } > "$STATE/$ID.meta" 2>/dev/null || true + fm_meta_lock_release "$STATE/$ID.meta" + META_LOCKED=0 + fi fi fi fi @@ -694,6 +713,8 @@ validate_spawn_worktree() { # fi } +spawn_meta_lock_load || true + W="fm-$ID" case "$BACKEND" in tmux) @@ -992,6 +1013,10 @@ fi META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W +spawn_meta_lock_load || exit 1 +fm_meta_lock_acquire "$STATE/$ID.meta" +META_LOCKED=1 +META_WRITE_STATUS=0 { echo "window=$META_WINDOW" echo "task_instance=$TASK_INSTANCE" @@ -1031,7 +1056,10 @@ META_WINDOW=$T echo "home=$PROJ_ABS" echo "projects=$SECONDMATE_PROJECTS" fi -} > "$STATE/$ID.meta" +} > "$STATE/$ID.meta" || META_WRITE_STATUS=$? +fm_meta_lock_release "$STATE/$ID.meta" +META_LOCKED=0 +[ "$META_WRITE_STATUS" -eq 0 ] || exit "$META_WRITE_STATUS" [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 sq_brief=$(shell_quote "$BRIEF") diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index e0bce69cf..f0ec5477c 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -631,7 +631,7 @@ test_spawn_removes_orca_worktree_when_terminal_create_fails() { } test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { - local proj wt data state config id out status + local proj wt data state config id out status ready release holder_pid spawn_pid i waiting before id="orcacleanupleakz0" proj="$TMP_ROOT/cleanup-fail-project" wt="$TMP_ROOT/cleanup-fail-wt" @@ -648,12 +648,48 @@ test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { printf '{"ok":true,"result":{"worktree":{"id":"wt-cleanup-fail","path":"%s"}}}\n' "$wt" > "$RESP/3.out" printf '1\n' > "$RESP/4.exit" printf '1\n' > "$RESP/5.exit" - out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + ready="$CASE_DIR/lock-ready" + release="$CASE_DIR/lock-release" + printf 'sentinel=old-generation\n' > "$state/$id.meta" + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_meta_lock_acquire "$2" || exit 1 + touch "$3" + while [ ! -e "$4" ]; do sleep 0.01; done + fm_meta_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$state/$id.meta" "$ready" "$release" & + holder_pid=$! + i=0 + while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done + if [ ! -e "$ready" ]; then + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + fail "Orca metadata lock holder did not start" + fi + PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ - "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) - status=$? + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca \ + > "$CASE_DIR/spawn.out" 2>&1 & + spawn_pid=$! + i=0 + while [ "$(cat "$RESP/.count" 2>/dev/null || echo 0)" -lt 5 ] && [ "$i" -lt 300 ]; do + sleep 0.01 + i=$((i + 1)) + done + sleep 0.2 + waiting=0 + kill -0 "$spawn_pid" 2>/dev/null && waiting=1 + before=$(cat "$state/$id.meta") + touch "$release" + wait "$holder_pid" || fail "Orca metadata lock holder failed" + status=0 + wait "$spawn_pid" || status=$? + out=$(cat "$CASE_DIR/spawn.out") [ "$status" -ne 0 ] || fail "Orca spawn should fail when terminal creation and abort cleanup fail" + [ "$waiting" -eq 1 ] || fail "Orca abort fallback did not wait for the metadata lock" + [ "$before" = 'sentinel=old-generation' ] \ + || fail "Orca abort fallback replaced metadata while another writer held the lock" assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-cleanup-fail'$'\x1f''--force'$'\x1f''--json' \ "Orca spawn should attempt helper cleanup before preserving metadata" assert_present "$state/$id.meta" "failed Orca abort cleanup should preserve metadata" @@ -661,7 +697,7 @@ test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { assert_grep "backend=orca" "$state/$id.meta" "preserved metadata missing backend=orca" assert_grep "orca_worktree_id=wt-cleanup-fail" "$state/$id.meta" "preserved metadata missing Orca worktree id" assert_no_grep "terminal=" "$state/$id.meta" "preserved metadata should not invent a terminal handle" - pass "fm-spawn.sh --backend orca: preserves metadata when abort cleanup fails" + pass "fm-spawn.sh --backend orca: locks preserved abort metadata" } test_spawn_releases_orca_resources_when_metadata_write_fails() { diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index dd21d915a..f56847081 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -107,6 +107,50 @@ assert_meta_profile() { assert_grep "effort=$effort" "$meta" "meta missing effort=$effort" } +test_spawn_serializes_metadata_replacement() { + local rec id meta ready release holder_pid spawn_pid i waiting before status + id=profile-meta-lock-z17 + rec=$(make_spawn_case profile-meta-lock claude "$id") + read_case_record "$rec" + meta="$HOME_DIR/state/$id.meta" + ready="$CASE_DIR/lock-ready" + release="$CASE_DIR/lock-release" + printf 'sentinel=old-generation\n' > "$meta" + FM_STATE_OVERRIDE="$HOME_DIR/state" bash -c ' + . "$1" + fm_meta_lock_acquire "$2" || exit 1 + touch "$3" + while [ ! -e "$4" ]; do sleep 0.01; done + fm_meta_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$meta" "$ready" "$release" & + holder_pid=$! + i=0 + while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done + if [ ! -e "$ready" ]; then + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + fail "metadata lock holder did not start" + fi + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ + > "$CASE_DIR/spawn.out" 2>&1 & + spawn_pid=$! + sleep 0.2 + waiting=0 + kill -0 "$spawn_pid" 2>/dev/null && waiting=1 + before=$(cat "$meta") + touch "$release" + wait "$holder_pid" || fail "metadata lock holder failed" + status=0 + wait "$spawn_pid" || status=$? + expect_code 0 "$status" "spawn failed after metadata lock release" + [ "$waiting" -eq 1 ] || fail "spawn did not wait for the metadata lock" + [ "$before" = 'sentinel=old-generation' ] \ + || fail "spawn replaced metadata while another writer held the lock" + assert_no_grep '^sentinel=' "$meta" "spawn retained superseded metadata" + assert_meta_profile "$meta" claude default default + pass "spawn serializes task metadata replacement" +} + test_no_profile_keeps_claude_launch_unchanged() { local rec id out status expected launch id=profile-off-z1 @@ -387,6 +431,7 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_launch_unchanged +test_spawn_serializes_metadata_replacement test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness From c02015ce54ff208244ccbf7c00e9f6d0066d88ed Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 15:09:40 +0400 Subject: [PATCH 17/18] fix: order same-ID spawn generations --- bin/fm-spawn.sh | 33 +++++++---- tests/fm-backend-orca.test.sh | 49 +++++++--------- tests/fm-spawn-dispatch-profile.test.sh | 77 +++++++++++++------------ 3 files changed, 83 insertions(+), 76 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a2b6c08d1..45f0f6ad8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -223,11 +223,13 @@ parse_orca_worktree_result() { orca_spawn_abort_cleanup() { local status=$? - if [ "${META_LOCKED:-0}" = 1 ]; then - fm_meta_lock_release "$STATE/$ID.meta" - META_LOCKED=0 + if [ "$ORCA_ABORT_CLEANUP" != 1 ]; then + if [ "${META_LOCKED:-0}" = 1 ]; then + fm_meta_lock_release "$STATE/$ID.meta" + META_LOCKED=0 + fi + return "$status" fi - [ "$ORCA_ABORT_CLEANUP" = 1 ] || return "$status" ORCA_ABORT_CLEANUP=0 if [ -n "${ORCA_TERMINAL:-}" ]; then fm_backend_kill orca "$ORCA_TERMINAL" 2>/dev/null || true @@ -236,8 +238,9 @@ orca_spawn_abort_cleanup() { if ! fm_backend_remove_worktree orca "$ORCA_WORKTREE_ID" 2>/dev/null; then mkdir -p "$STATE" 2>/dev/null || true if [ -d "$STATE" ]; then - if spawn_meta_lock_load && fm_meta_lock_acquire "$STATE/$ID.meta"; then - META_LOCKED=1 + if [ "$META_LOCKED" = 1 ] || { + spawn_meta_lock_load && fm_meta_lock_acquire "$STATE/$ID.meta" && META_LOCKED=1 + }; then { echo "window=$W" echo "task_instance=$TASK_INSTANCE" @@ -254,12 +257,14 @@ orca_spawn_abort_cleanup() { echo "orca_worktree_id=$ORCA_WORKTREE_ID" [ -z "${ORCA_TERMINAL:-}" ] || echo "terminal=$ORCA_TERMINAL" } > "$STATE/$ID.meta" 2>/dev/null || true - fm_meta_lock_release "$STATE/$ID.meta" - META_LOCKED=0 fi fi fi fi + if [ "${META_LOCKED:-0}" = 1 ]; then + fm_meta_lock_release "$STATE/$ID.meta" + META_LOCKED=0 + fi return "$status" } trap orca_spawn_abort_cleanup EXIT @@ -713,7 +718,13 @@ validate_spawn_worktree() { # fi } -spawn_meta_lock_load || true +if [ ! -e "$STATE" ]; then + mkdir -p "$STATE" +fi +if spawn_meta_lock_load; then + fm_meta_lock_acquire "$STATE/$ID.meta" + META_LOCKED=1 +fi W="fm-$ID" case "$BACKEND" in @@ -1013,9 +1024,7 @@ fi META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W -spawn_meta_lock_load || exit 1 -fm_meta_lock_acquire "$STATE/$ID.meta" -META_LOCKED=1 +[ "$META_LOCKED" = 1 ] || exit 1 META_WRITE_STATUS=0 { echo "window=$META_WINDOW" diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index f0ec5477c..9dadc7288 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -29,6 +29,10 @@ if [ "${1:-}" = status ] && [ "${FM_ORCA_STATUS_RESPONSE:-ready}" != sequence ]; fi n=$next echo "$n" > "$COUNT_FILE" +if [ "${FM_ORCA_GATE_CALL:-}" = "$n" ]; then + touch "${FM_ORCA_GATE_READY:?}" + while [ ! -e "${FM_ORCA_GATE_RELEASE:?}" ]; do sleep 0.01; done +fi if [ -f "$RESP/$n.exit" ]; then exit "$(cat "$RESP/$n.exit")" fi @@ -631,7 +635,7 @@ test_spawn_removes_orca_worktree_when_terminal_create_fails() { } test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { - local proj wt data state config id out status ready release holder_pid spawn_pid i waiting before + local proj wt data state config id out status ready release acquired spawn_pid probe_pid i blocked id="orcacleanupleakz0" proj="$TMP_ROOT/cleanup-fail-project" wt="$TMP_ROOT/cleanup-fail-wt" @@ -650,46 +654,35 @@ test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { printf '1\n' > "$RESP/5.exit" ready="$CASE_DIR/lock-ready" release="$CASE_DIR/lock-release" - printf 'sentinel=old-generation\n' > "$state/$id.meta" - FM_STATE_OVERRIDE="$state" bash -c ' - . "$1" - fm_meta_lock_acquire "$2" || exit 1 - touch "$3" - while [ ! -e "$4" ]; do sleep 0.01; done - fm_meta_lock_release "$2" - ' _ "$ROOT/bin/fm-wake-lib.sh" "$state/$id.meta" "$ready" "$release" & - holder_pid=$! - i=0 - while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done - if [ ! -e "$ready" ]; then - kill "$holder_pid" 2>/dev/null || true - wait "$holder_pid" 2>/dev/null || true - fail "Orca metadata lock holder did not start" - fi + acquired="$CASE_DIR/probe-acquired" PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + FM_ORCA_GATE_CALL=5 FM_ORCA_GATE_READY="$ready" FM_ORCA_GATE_RELEASE="$release" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca \ > "$CASE_DIR/spawn.out" 2>&1 & spawn_pid=$! i=0 - while [ "$(cat "$RESP/.count" 2>/dev/null || echo 0)" -lt 5 ] && [ "$i" -lt 300 ]; do - sleep 0.01 - i=$((i + 1)) - done + while [ ! -e "$ready" ] && [ "$i" -lt 300 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$ready" ] || fail "Orca abort cleanup did not reach its lifecycle gate" + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_meta_lock_acquire "$2" || exit 1 + touch "$3" + fm_meta_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$state/$id.meta" "$acquired" & + probe_pid=$! sleep 0.2 - waiting=0 - kill -0 "$spawn_pid" 2>/dev/null && waiting=1 - before=$(cat "$state/$id.meta") + blocked=0 + [ ! -e "$acquired" ] && blocked=1 touch "$release" - wait "$holder_pid" || fail "Orca metadata lock holder failed" status=0 wait "$spawn_pid" || status=$? + wait "$probe_pid" || fail "post-abort metadata lock probe failed" out=$(cat "$CASE_DIR/spawn.out") [ "$status" -ne 0 ] || fail "Orca spawn should fail when terminal creation and abort cleanup fail" - [ "$waiting" -eq 1 ] || fail "Orca abort fallback did not wait for the metadata lock" - [ "$before" = 'sentinel=old-generation' ] \ - || fail "Orca abort fallback replaced metadata while another writer held the lock" + [ "$blocked" -eq 1 ] || fail "Orca abort cleanup released its generation lock early" + assert_present "$acquired" "metadata lock remained unavailable after Orca abort cleanup" assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-cleanup-fail'$'\x1f''--force'$'\x1f''--json' \ "Orca spawn should attempt helper cleanup before preserving metadata" assert_present "$state/$id.meta" "failed Orca abort cleanup should preserve metadata" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index f56847081..8276ca332 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -25,7 +25,14 @@ esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|kill-window) exit 0 ;; + new-window) + if [ -n "${FM_FAKE_SPAWN_GATE_READY:-}" ]; then + touch "$FM_FAKE_SPAWN_GATE_READY" + while [ ! -e "${FM_FAKE_SPAWN_GATE_RELEASE:?}" ]; do sleep 0.01; done + fi + exit 0 + ;; + has-session|new-session|kill-window) exit 0 ;; send-keys) if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then prev= @@ -108,47 +115,45 @@ assert_meta_profile() { } test_spawn_serializes_metadata_replacement() { - local rec id meta ready release holder_pid spawn_pid i waiting before status + local rec id meta first_ready first_release second_ready second_release + local first_pid second_pid i first_status second_status first_instance second_instance id=profile-meta-lock-z17 rec=$(make_spawn_case profile-meta-lock claude "$id") read_case_record "$rec" meta="$HOME_DIR/state/$id.meta" - ready="$CASE_DIR/lock-ready" - release="$CASE_DIR/lock-release" - printf 'sentinel=old-generation\n' > "$meta" - FM_STATE_OVERRIDE="$HOME_DIR/state" bash -c ' - . "$1" - fm_meta_lock_acquire "$2" || exit 1 - touch "$3" - while [ ! -e "$4" ]; do sleep 0.01; done - fm_meta_lock_release "$2" - ' _ "$ROOT/bin/fm-wake-lib.sh" "$meta" "$ready" "$release" & - holder_pid=$! + first_ready="$CASE_DIR/first-ready" + first_release="$CASE_DIR/first-release" + second_ready="$CASE_DIR/second-ready" + second_release="$CASE_DIR/second-release" + FM_FAKE_SPAWN_GATE_READY="$first_ready" FM_FAKE_SPAWN_GATE_RELEASE="$first_release" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ + > "$CASE_DIR/first.out" 2>&1 & + first_pid=$! i=0 - while [ ! -e "$ready" ] && [ "$i" -lt 200 ]; do sleep 0.01; i=$((i + 1)); done - if [ ! -e "$ready" ]; then - kill "$holder_pid" 2>/dev/null || true - wait "$holder_pid" 2>/dev/null || true - fail "metadata lock holder did not start" - fi - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - > "$CASE_DIR/spawn.out" 2>&1 & - spawn_pid=$! + while [ ! -e "$first_ready" ] && [ "$i" -lt 300 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$first_ready" ] || fail "first spawn did not reach its lifecycle gate" + FM_FAKE_SPAWN_GATE_READY="$second_ready" FM_FAKE_SPAWN_GATE_RELEASE="$second_release" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" \ + "$id" "$PROJ_DIR" --harness codex > "$CASE_DIR/second.out" 2>&1 & + second_pid=$! sleep 0.2 - waiting=0 - kill -0 "$spawn_pid" 2>/dev/null && waiting=1 - before=$(cat "$meta") - touch "$release" - wait "$holder_pid" || fail "metadata lock holder failed" - status=0 - wait "$spawn_pid" || status=$? - expect_code 0 "$status" "spawn failed after metadata lock release" - [ "$waiting" -eq 1 ] || fail "spawn did not wait for the metadata lock" - [ "$before" = 'sentinel=old-generation' ] \ - || fail "spawn replaced metadata while another writer held the lock" - assert_no_grep '^sentinel=' "$meta" "spawn retained superseded metadata" - assert_meta_profile "$meta" claude default default - pass "spawn serializes task metadata replacement" + [ ! -e "$second_ready" ] || fail "second spawn crossed the first generation lifecycle" + touch "$first_release" + i=0 + while [ ! -e "$second_ready" ] && [ "$i" -lt 300 ]; do sleep 0.01; i=$((i + 1)); done + [ -e "$second_ready" ] || fail "second spawn did not begin after the first generation" + first_instance=$(grep '^task_instance=' "$meta" | cut -d= -f2-) + touch "$second_release" + first_status=0 + wait "$first_pid" || first_status=$? + second_status=0 + wait "$second_pid" || second_status=$? + expect_code 0 "$first_status" "first same-ID spawn failed" + expect_code 0 "$second_status" "second same-ID spawn failed" + second_instance=$(grep '^task_instance=' "$meta" | cut -d= -f2-) + [ "$first_instance" != "$second_instance" ] || fail "same-ID spawns reused one task instance" + assert_meta_profile "$meta" codex default default + pass "same-ID spawn generations run and publish in lifecycle order" } test_no_profile_keeps_claude_launch_unchanged() { From 26e2a71724719d5153a4d144946e12e5dead4ca4 Mon Sep 17 00:00:00 2001 From: Waleed Arafa Date: Sun, 19 Jul 2026 15:15:30 +0400 Subject: [PATCH 18/18] fix: hold spawn lock through launch --- bin/fm-spawn.sh | 4 ++-- tests/fm-spawn-dispatch-profile.test.sh | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 45f0f6ad8..0c9a3eda5 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1066,8 +1066,6 @@ META_WRITE_STATUS=0 echo "projects=$SECONDMATE_PROJECTS" fi } > "$STATE/$ID.meta" || META_WRITE_STATUS=$? -fm_meta_lock_release "$STATE/$ID.meta" -META_LOCKED=0 [ "$META_WRITE_STATUS" -eq 0 ] || exit "$META_WRITE_STATUS" [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 @@ -1097,5 +1095,7 @@ sleep 0.3 spawn_send_literal "$T" "$LAUNCH" sleep 0.3 spawn_send_key "$T" Enter +fm_meta_lock_release "$STATE/$ID.meta" +META_LOCKED=0 echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$META_WINDOW worktree=$WT" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 8276ca332..52253db68 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -116,7 +116,7 @@ assert_meta_profile() { test_spawn_serializes_metadata_replacement() { local rec id meta first_ready first_release second_ready second_release - local first_pid second_pid i first_status second_status first_instance second_instance + local first_pid second_pid i first_status second_status first_instance second_instance second_launch_log id=profile-meta-lock-z17 rec=$(make_spawn_case profile-meta-lock claude "$id") read_case_record "$rec" @@ -125,6 +125,7 @@ test_spawn_serializes_metadata_replacement() { first_release="$CASE_DIR/first-release" second_ready="$CASE_DIR/second-ready" second_release="$CASE_DIR/second-release" + second_launch_log="$CASE_DIR/second-launch.log" FM_FAKE_SPAWN_GATE_READY="$first_ready" FM_FAKE_SPAWN_GATE_RELEASE="$first_release" \ run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ > "$CASE_DIR/first.out" 2>&1 & @@ -133,7 +134,7 @@ test_spawn_serializes_metadata_replacement() { while [ ! -e "$first_ready" ] && [ "$i" -lt 300 ]; do sleep 0.01; i=$((i + 1)); done [ -e "$first_ready" ] || fail "first spawn did not reach its lifecycle gate" FM_FAKE_SPAWN_GATE_READY="$second_ready" FM_FAKE_SPAWN_GATE_RELEASE="$second_release" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$second_launch_log" \ "$id" "$PROJ_DIR" --harness codex > "$CASE_DIR/second.out" 2>&1 & second_pid=$! sleep 0.2 @@ -142,6 +143,8 @@ test_spawn_serializes_metadata_replacement() { i=0 while [ ! -e "$second_ready" ] && [ "$i" -lt 300 ]; do sleep 0.01; i=$((i + 1)); done [ -e "$second_ready" ] || fail "second spawn did not begin after the first generation" + assert_grep 'claude --dangerously-skip-permissions' "$LAUNCH_LOG" \ + "second generation began before the first worker launch was submitted" first_instance=$(grep '^task_instance=' "$meta" | cut -d= -f2-) touch "$second_release" first_status=0