From b59f581967ddba36edc74832a5cff38a36a1389b Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Fri, 7 Aug 2026 18:47:59 -0400 Subject: [PATCH 1/5] feat(bin): detect duplicate work at intake (CFVC-02) fm-spawn already refused a duplicate LAUNCH - the same task id twice - and nothing compared a new task's SUBJECT against work already open. That gap was paid for once already: one fix, two tasks, two branches, two workers, and an open pull request that already carried the change, with nothing detecting it. Every ship and scout spawn now emits the candidate overlap set before any endpoint exists: open backlog tasks, tasks holding live runtime metadata, local and remote branches, and open pull request titles. Code emits candidates and never decides equivalence; whether two prose descriptions are the same work stays firstmate's judgment, made from a surfaced set instead of from memory. A candidate must clear a shared-token floor AND cover half of the smaller of the two vocabularies. Both halves are load-bearing: measured against a real 140-item backlog, 254 branches, and 95 open pull requests, the floor alone surfaced 61 candidates for one task, while denominating by the subject alone would put short branch names permanently out of reach. Candidates are ordered strongest evidence first, never bounded, so nothing is hidden from the reader or the gate. The empty-set law binds: a source that cannot be read yields overlap=unavailable, never overlap=none. The result is recorded in the task's own metadata, so "nothing was compared" and "compared, found nothing" stay distinguishable afterwards. Enforcement follows the additive-inert pattern. config/spawn-overlap absent or advisory prints and records without refusing; enforce additionally requires --overlap-ack to name every surfaced ref; off skips the scan and says so. Any other value refuses, because a safety knob that cannot be read must never be treated as an absent one. The pull request source goes through one new shared reader in fm-pr-lib.sh rather than a second forge reader with its own failure semantics. Tests replay the recorded incident and are red against the previous build by construction, with negative controls for the unrelated task, the unreachable forge, the absent forge CLI, the coincidental match, and the ordering. --- AGENTS.md | 4 +- bin/fm-config-inherit-lib.sh | 2 +- bin/fm-pr-lib.sh | 39 +++ bin/fm-spawn.sh | 291 ++++++++++++++++++++++ docs/architecture.md | 5 + docs/configuration.md | 23 ++ tests/fm-spawn-overlap.test.sh | 432 +++++++++++++++++++++++++++++++++ 7 files changed, 794 insertions(+), 2 deletions(-) create mode 100755 tests/fm-spawn-overlap.test.sh diff --git a/AGENTS.md b/AGENTS.md index 896847a3ea8..01d89f14c7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignore config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning +config/spawn-overlap duplicate-work overlap posture; LOCAL, gitignored; absent or "advisory" = surfaced and recorded but never refused, "enforce" = a live overlap needs an explicit acknowledgement, "off" = not scanned; inherited by secondmate homes; see docs/configuration.md "Duplicate-work overlap scan" config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" @@ -92,7 +93,7 @@ state/ volatile runtime signals; gitignored .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; 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 the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for a Relay-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; a ship or scout also records the intake duplicate-work result as overlap=, plus overlap_refs= and overlap_ack= when they apply (docs/configuration.md "Duplicate-work overlap scan"); an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; 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 the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for a Relay-originated task (section 14) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified Relay 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 @@ -283,6 +284,7 @@ Write the task-specific brief under section 11 before spawning. ### Dispatch and supervision handoff Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4. +Every ship or scout spawn surfaces the open tasks, live branches, and open pull requests that share this task's subject; read that set and decide equivalence yourself, because code only lists candidates, and treat an unreadable source as an incomplete set rather than a clean one. The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task. After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as under way. A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 0b3ec94f091..7ff1670e7b4 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -63,7 +63,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context spawn-overlap}" # Items whose value is a home-SESSION enablement decision rather than durable # local configuration. They are inherited at the launch convergence point, where diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index b70d8468894..39287b57c87 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -327,6 +327,45 @@ fm_pr_metadata_identity_parse() { [ -n "$FM_PR_META_URL" ] } +# List a repository's OPEN pull requests as "\t" rows, one per +# line, on stdout. This is the single forge reader for title-level work +# discovery: bin/fm-research-scan.sh's --landing prover already listens for +# delivery this way, and bin/fm-spawn.sh's intake overlap scan asks the same +# question about work already in flight, so both ask it through one place +# rather than growing a second forge reader with its own failure semantics. +# +# The empty-set law binds here and is the whole reason this returns a status. +# A missing CLI, an unauthenticated CLI, a network failure, or a rejected +# listing all return non-zero with FM_PR_LIST_ERROR set to a one-line reason. +# A caller must render that as "unknown" and NEVER as "no open requests": the +# absence of an answer is not an answer. A successful listing with no rows is +# the only thing that means no open requests, and it returns zero. +# +# No --fields is passed: the default gh-axi listing already carries number, +# title, and state, and a rejected field list would fail the whole call. +fm_pr_open_request_titles() { # <repo-dir> [limit] + local repo=$1 limit=${2:-100} out + FM_PR_LIST_ERROR= + case "$limit" in + ''|*[!0-9]*) FM_PR_LIST_ERROR="pull request limit '$limit' is not a number"; return 1 ;; + esac + [ "$limit" -gt 0 ] 2>/dev/null || { FM_PR_LIST_ERROR="pull request limit must be positive"; return 1; } + [ -d "$repo" ] || { FM_PR_LIST_ERROR="no repository directory at $repo"; return 1; } + command -v gh-axi >/dev/null 2>&1 || { FM_PR_LIST_ERROR="gh-axi is not on PATH"; return 1; } + if ! out=$( (cd "$repo" && gh-axi pr list --state open --limit "$limit") 2>&1 ); then + FM_PR_LIST_ERROR=$(printf '%s' "$out" | head -n 1 | cut -c 1-200) + [ -n "$FM_PR_LIST_ERROR" ] || FM_PR_LIST_ERROR="gh-axi pr list failed with no output" + return 1 + fi + # gh-axi prints a TOON block: a "pull_requests[N]{...}:" header, then one + # indented "<number>,\"<title>\",<state>,<author>,<draft>,<review>" row each. + # The title is matched greedily up to the last quote that is followed by the + # four trailing scalar fields, so a comma or quote inside a title cannot + # truncate it. + printf '%s\n' "$out" \ + | sed -n 's/^[[:space:]]\{1,\}\([0-9]\{1,\}\),"\(.*\)",[^,]*,[^,]*,[^,]*,[^,]*$/\1\t\2/p' +} + # Sidecar layout: provider, url, host, path, number, one per line. A sidecar # written before the provider tag existed has a URL on its first line and one # line fewer, so it fails both the field count and the provider comparison and diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index b272fd0ed0f..553fe9925f3 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -32,6 +32,18 @@ # or herdr), refuses unless the endpoint's shell is sitting in the recorded # worktree, and clears the previous harness's per-task wiring before arming # the new incarnation. +# Every ship and scout spawn runs the DUPLICATE-WORK OVERLAP SCAN before any +# endpoint exists: open tasks, live local and remote branches, and open pull +# request titles that share normalised subject tokens with this task are +# printed as candidates, strongest evidence first, and recorded in the task's +# metadata as overlap=, +# overlap_refs=, and overlap_ack=. Code emits the candidate set and NEVER +# decides equivalence, which is firstmate's judgment. A source that cannot be +# read yields overlap=unavailable, never overlap=none. config/spawn-overlap +# selects advisory (the default: printed and recorded, never refused), +# enforce (--overlap-ack must name every surfaced ref), or off; any other +# value refuses. --overlap-ack <ref>[,<ref>...] is that acknowledgement and is +# refused on --secondmate spawns, which are homes rather than work items. # --harness <name> is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. # --model <name> and --effort <low|medium|high|xhigh|max> are concrete profile @@ -252,6 +264,8 @@ BACKEND_ARG= MODE= YOLO= TRACEPARENT_ARG= +OVERLAP_ACK_ARG= +OVERLAP_ACK_SET=0 HARNESS_SET=0 MODEL_SET=0 EFFORT_SET=0 @@ -275,6 +289,7 @@ for a in "$@"; do mode) MODE=$a; MODE_SET=1 ;; yolo) YOLO=$a; YOLO_SET=1 ;; traceparent) TRACEPARENT_ARG=$a; TRACEPARENT_SET=1 ;; + overlap-ack) OVERLAP_ACK_ARG=$a; OVERLAP_ACK_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac want_value= @@ -298,6 +313,8 @@ for a in "$@"; do --yolo=*) YOLO=${a#--yolo=}; YOLO_SET=1 ;; --traceparent) want_value=traceparent ;; --traceparent=*) TRACEPARENT_ARG=${a#--traceparent=}; TRACEPARENT_SET=1 ;; + --overlap-ack) want_value=overlap-ack ;; + --overlap-ack=*) OVERLAP_ACK_ARG=${a#--overlap-ack=}; OVERLAP_ACK_SET=1 ;; *) POS+=("$a") ;; esac done @@ -322,6 +339,11 @@ if [ "$TRACEPARENT_SET" -eq 1 ]; then exit 1 } fi +[ "$OVERLAP_ACK_SET" -eq 0 ] || [ -n "$OVERLAP_ACK_ARG" ] || { echo "error: --overlap-ack requires a non-empty value" >&2; exit 1; } +if [ "$OVERLAP_ACK_SET" -eq 1 ] && [ "$KIND" = secondmate ]; then + echo "error: --overlap-ack applies only to ship and scout spawns; a secondmate is a persistent home rather than a work item, so no work overlaps it" >&2 + exit 1 +fi case "$EFFORT" in ''|low|medium|high|xhigh|max) ;; *) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;; @@ -1597,6 +1619,268 @@ if [ "$KIND" = ship ]; then fi fi +# DUPLICATE-WORK OVERLAP SCAN. fm-spawn already refuses a duplicate LAUNCH - +# the same task id twice - and nothing compared a new task's SUBJECT against +# work already open. That gap was paid for once already: one fix, two tasks, +# two branches, two workers, nothing detected. This is that comparison, placed +# at the existing last gate before dispatch rather than in a new component. +# +# THE DIVISION OF LABOUR IS THE DESIGN. Code emits the CANDIDATE set - open +# tasks, live branches, and open pull request titles sharing normalised tokens +# with this task's subject - and never decides equivalence. "Is this the same +# work" compares two prose descriptions, so it is firstmate's judgment, made +# from a surfaced set instead of from memory. +# +# THE EMPTY-SET LAW BINDS. A source that cannot be read yields +# overlap=unavailable, never overlap=none. An absent set is not a pass. +# +# ENFORCEMENT SCOPE, on bin/fm-model-registry-lib.sh's additive-inert pattern: +# config/spawn-overlap absent or "advisory" -> scan, print, record; never +# refuse. Unenforced is never SILENT, but it is not a refusal. +# "enforce" -> additionally require --overlap-ack to name every surfaced ref +# before dispatch, so a live overlap cannot be dispatched onto without an +# explicit acknowledgement in the task's own spawn record. +# "off" -> skip the scan and say so. +# A value that is none of those REFUSES: a broken safety knob must never read +# as an absent one. docs/configuration.md "Duplicate-work overlap scan" owns it. +OVERLAP_STATE=off +OVERLAP_REFS= +OVERLAP_ROWS= + +# Words that carry no signal about WHICH work a task is. Without them every +# task matches every other task and the acknowledgement decays into a reflex, +# which is worse than no scan at all. +OVERLAP_STOPWORDS='the and for are was were has have had been being this that with when then than they them their there here will must never always only also from into out over under about after before while where which what does done doing not but its our your you can may per via each both same other more most much many some any all one two new old add adds added get gets got run runs ran use uses used using make makes made take takes took give gives gave keep keeps kept let lets set sets put puts task tasks work works working worked item items job jobs thing things stuff bug bugs fix fixes fixed fixing issue issues error errors problem problems change changes changed changing update updates updated support supports supported feature features code codes file files line lines path paths dir dirs repo repos project projects branch branches test tests testing spec specs report reports section sections doc docs data note notes readme agent agents firstmate crewmate crewmates scout scouts captain wip tmp temp misc main master trunk head base ref refs pull request requests draft chore refactor' + +# Everything downstream of this is one awk program on purpose: it holds the ONE +# copy of the token normaliser, so the subject and every candidate are reduced +# by identical rules. Two copies would drift and the scan would quietly stop +# matching. +# +# A candidate must clear BOTH a floor and a ratio, and the ratio is what makes +# this usable. Measured against a real 140-item backlog, 254 branches, and 95 +# open pull requests, a bare two-token floor surfaced 61 candidates for one +# task: in a fleet whose work all shares a house vocabulary, "two words in +# common" is met by almost everything, and a set nobody can read is a set +# nobody reads. Requiring a candidate to cover half of the SUBJECT's own +# tokens is scale-free - it stays satisfied by the short ids of the recorded +# incident, where two shared tokens are two thirds of the subject, and drops +# the coincidental matches, whose share is a quarter or less. +overlap_match() { # <subject-text> <min-tokens> <min-percent>; candidate rows on stdin + awk -v subject="$1" -v min="$2" -v pct="$3" -v stop=" $OVERLAP_STOPWORDS " ' + function norm(w) { + if (length(w) < 3) return "" + if (length(w) > 3 && substr(w, length(w), 1) == "s" && substr(w, length(w) - 1, 1) != "s") + w = substr(w, 1, length(w) - 1) + if (length(w) < 3) return "" + if (index(stop, " " w " ") > 0) return "" + return w + } + BEGIN { + FS = "\t"; OFS = "\t" + n = split(tolower(subject), parts, /[^a-z0-9]+/) + for (i = 1; i <= n; i++) { + w = norm(parts[i]) + if (w == "" || (w in subj)) continue + subj[w] = 1 + ordered[++subjn] = w + } + # Insertion sort keeps the shared-token list stable across runs, so two + # spawns of the same task print the same evidence. + for (i = 2; i <= subjn; i++) { + v = ordered[i] + for (j = i - 1; j >= 1 && ordered[j] > v; j--) ordered[j + 1] = ordered[j] + ordered[j + 1] = v + } + } + # An unreadable source passes straight through: it is evidence of a gap in + # the candidate set, not a candidate, and it must survive to the caller. + # The leading score sorts it to the top, where an incomplete set belongs. + $1 == "unavailable" { print 9999, $1, $2, $3, $4; next } + { + split("", cand) + candn = 0 + n = split(tolower($4), parts, /[^a-z0-9]+/) + for (i = 1; i <= n; i++) { + w = norm(parts[i]) + if (w == "" || (w in cand)) continue + cand[w] = 1 + candn++ + } + shared = ""; count = 0 + for (i = 1; i <= subjn; i++) { + if (!(ordered[i] in cand)) continue + shared = (shared == "" ? ordered[i] : shared "|" ordered[i]) + count++ + } + if (count < min) next + # Denominate by the SMALLER vocabulary. Denominating by the subject alone + # would make a branch unreachable - a branch name carries four or five + # words against a subject and a task body carrying dozens - and branches + # are the earliest evidence that somebody already started this. + smaller = (subjn < candn ? subjn : candn) + if (smaller > 0 && count * 100 < smaller * pct) next + print count, $1, $2, substr($3, 1, 120), shared + }' +} + +# Every candidate source emits the same four TAB-separated fields, so one +# matcher serves all three and a new source costs no new failure semantics: +# <kind>\t<ref>\t<title>\t<searchable text> +# A source that cannot answer emits kind "unavailable" with its reason. +overlap_candidates() { # <task-id> <project-dir> + local id=$1 proj=$2 backlog="$DATA/backlog.md" meta tid ref num title out limit prlist + + # Open tasks: this home's durable queue, plus any task with live runtime + # metadata, so a task dispatched without a backlog row is still visible. + if [ -d "$DATA" ]; then + if [ -f "$backlog" ]; then + # Field separators are stripped inside awk rather than by a downstream + # pipeline, so a tab in a task body can never shift a later field. + awk -v self="$id" ' + function emit() { + if (id != "" && id != self) { + gsub(/\t/, " ", title); gsub(/\t/, " ", body) + printf "task\t%s\t%s\t%s %s %s\n", id, title, id, title, body + } + id = ""; title = ""; body = "" + } + /^- \[ \] / { + emit() + rest = substr($0, 7) + p = index(rest, " - ") + if (p > 0) { id = substr(rest, 1, p - 1); title = substr(rest, p + 3) } + else { id = rest; title = "" } + next + } + /^- \[[xX]\] / { emit(); next } + /^#/ { emit(); next } + /^[ \t]/ { if (id != "") body = body " " $0; next } + { next } + END { emit() } + ' "$backlog" + fi + else + printf 'unavailable\ttask\tthis home has no readable data directory at %s\t\n' "$DATA" + fi + if [ -d "$STATE" ]; then + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + tid=$(basename "$meta" .meta) + [ "$tid" != "$id" ] || continue + grep -q '^kind=secondmate$' "$meta" && continue + if [ -f "$backlog" ] && grep -qF -- "- [ ] $tid " "$backlog"; then + continue + fi + printf 'task\t%s\t(under way, no backlog row)\t%s\n' "$tid" "$tid" + done + else + printf 'unavailable\ttask\tthis home has no readable runtime state directory at %s\t\n' "$STATE" + fi + + # Live branches, local and remote. A branch is the cheapest proof that + # somebody already started this, and it exists before any pull request does. + if out=$(git -C "$proj" for-each-ref --format='%(refname:short)' refs/heads refs/remotes 2>&1); then + while IFS= read -r ref; do + [ -n "$ref" ] || continue + case "$ref" in + *"$id") continue ;; + */HEAD|HEAD) continue ;; + esac + printf 'branch\t%s\t\t%s\n' "$ref" "$ref" + done <<EOF +$out +EOF + else + printf 'unavailable\tbranch\t%s\t\n' "$(printf '%s' "$out" | head -n 1 | tr '\t' ' ' | cut -c 1-200)" + fi + + # Open pull requests, read through fm-pr-lib.sh's single forge reader so this + # scan adds no second forge failure mode of its own. The listing goes to a + # file rather than a command substitution because FM_PR_LIST_ERROR - the + # reason a forge could not answer, and the whole difference between + # "unavailable" and "none" - would not survive the subshell. + limit=${FM_SPAWN_OVERLAP_PR_LIMIT:-100} + if ! prlist=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-overlap-pr.XXXXXX" 2>/dev/null); then + printf 'unavailable\tpr\tno temporary file could be created for the pull request listing\t\n' + elif fm_pr_open_request_titles "$proj" "$limit" > "$prlist" 2>/dev/null; then + while IFS=$'\t' read -r num title; do + [ -n "$num" ] || continue + printf 'pr\t%s\t%s\t%s\n' "$num" "$title" "$title" + done < "$prlist" + rm -f "$prlist" + else + rm -f "$prlist" + printf 'unavailable\tpr\t%s\t\n' "$(printf '%s' "$FM_PR_LIST_ERROR" | tr '\t' ' ' | cut -c 1-200)" + fi +} + +if [ "$KIND" != secondmate ]; then + OVERLAP_MODE=advisory + if [ -f "$CONFIG/spawn-overlap" ]; then + OVERLAP_MODE=$(head -n 1 "$CONFIG/spawn-overlap" 2>/dev/null | tr -d '[:space:]') + [ -n "$OVERLAP_MODE" ] || OVERLAP_MODE=advisory + fi + case "$OVERLAP_MODE" in + advisory|enforce|off) ;; + *) + echo "error: config/spawn-overlap says '$OVERLAP_MODE'; it must be advisory, enforce, or off. A safety knob that cannot be read must never be treated as an absent one" >&2 + exit 1 ;; + esac + if [ "$OVERLAP_MODE" = off ]; then + OVERLAP_STATE=off + echo "overlap=off (config/spawn-overlap); nothing compared this task's subject against work already open" >&2 + else + # The subject is the task id plus the first line of the brief's task + # statement - the same words the worker is being sent to act on. + OVERLAP_SUBJECT="$ID $(awk '/^# Task[[:space:]]*$/ {intask = 1; next} intask && NF {print; exit}' "$BRIEF" 2>/dev/null | cut -c 1-300)" + # Strongest evidence first, so the reader meets the likeliest duplicate at + # the top rather than somewhere down a list. The sort is on the matcher's + # score column, which is dropped again immediately: it orders the set and + # never bounds it, so nothing is hidden from the reader or the gate. + OVERLAP_ROWS=$(overlap_candidates "$ID" "$PROJ_ABS" \ + | overlap_match "$OVERLAP_SUBJECT" "${FM_SPAWN_OVERLAP_MIN_TOKENS:-2}" "${FM_SPAWN_OVERLAP_MIN_PERCENT:-50}" \ + | LC_ALL=C sort -t"$(printf '\t')" -k1,1nr -k2,2 -k3,3 \ + | cut -f2-) + if printf '%s\n' "$OVERLAP_ROWS" | awk -F'\t' '$1 == "unavailable" { found = 1 } END { exit found ? 0 : 1 }'; then + OVERLAP_STATE=unavailable + elif [ -n "$OVERLAP_ROWS" ]; then + OVERLAP_STATE=candidates + else + OVERLAP_STATE=none + fi + OVERLAP_REFS=$(printf '%s\n' "$OVERLAP_ROWS" | awk -F'\t' 'NF >= 2 {printf "%s%s:%s", sep, $1, $2; sep = ","}') + if [ "$OVERLAP_STATE" = none ]; then + echo "overlap=none - no open task, live branch, or open pull request shares this task's subject tokens" >&2 + else + { + echo "overlap=$OVERLAP_STATE" + printf 'overlap[%s]{kind,ref,title,shared_tokens}:\n' "$(printf '%s\n' "$OVERLAP_ROWS" | grep -c .)" + printf '%s\n' "$OVERLAP_ROWS" | awk -F'\t' 'NF >= 2 {printf " %s,%s,\"%s\",%s\n", $1, $2, $3, $4}' + echo "These are CANDIDATES, not a verdict: read each one and decide whether it is the same work. An 'unavailable' row means a source could not be read, so the set is incomplete - it is never a pass." + } >&2 + fi + # Enforcement: every surfaced ref must be named in --overlap-ack before this + # task may be dispatched onto work already open. + if [ "$OVERLAP_MODE" = enforce ] && [ "$OVERLAP_STATE" != none ]; then + OVERLAP_UNACKED=$(printf '%s\n' "$OVERLAP_REFS" | tr ',' '\n' | grep -v '^$' | while IFS= read -r ref; do + case ",$OVERLAP_ACK_ARG," in + *",$ref,"*) continue ;; + *) printf '%s\n' "$ref" ;; + esac + done) + if [ -n "$OVERLAP_UNACKED" ]; then + { + echo "error: $ID would dispatch against work already open, and --overlap-ack does not name:" + printf '%s\n' "$OVERLAP_UNACKED" | sed 's/^/ /' + echo "Read each candidate above, decide whether it is the same work, and either route this task onto the existing work or re-run with --overlap-ack '$OVERLAP_REFS'. Code lists candidates; it never decides equivalence." + } >&2 + exit 1 + fi + fi + fi +fi + BRIEF_DIR_REAL=$(cd "$(dirname "$BRIEF")" && pwd -P) BRIEF_REAL="$BRIEF_DIR_REAL/$(basename "$BRIEF")" @@ -2489,6 +2773,13 @@ preserve_relaunch_meta() { echo "project=$PROJ_ABS" echo "harness=$HARNESS" echo "kind=$KIND" + # What the intake overlap scan saw, and what firstmate acknowledged before + # this task was allowed to run alongside it. Recorded even when the scan found + # nothing, so "nobody checked" and "checked, found nothing" stay distinct + # after the fact - the same reason overlap=unavailable is not overlap=none. + [ "$KIND" = secondmate ] || echo "overlap=$OVERLAP_STATE" + [ -z "$OVERLAP_REFS" ] || echo "overlap_refs=$OVERLAP_REFS" + [ "$OVERLAP_ACK_SET" -eq 0 ] || echo "overlap_ack=$OVERLAP_ACK_ARG" [ -z "$MODE" ] || echo "mode=$MODE" [ -z "$YOLO" ] || echo "yolo=$YOLO" echo "tasktmp=$TASK_TMP" diff --git a/docs/architecture.md b/docs/architecture.md index 9c56d251764..150a87be992 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,6 +166,11 @@ The helper's header owns the exact signal detection, relocated-home limitation, Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks leave standalone investigation reports at `data/<id>/report.md` and never push. The intake and authority contract in `AGENTS.md` owns when separate scout research is warranted. +Either shape can be dispatched onto work somebody already started, so `fm-spawn.sh` compares the new task's subject against the open backlog, the tasks holding live runtime metadata, the project's branches, and its open pull request titles before any endpoint exists, and records the result in task metadata. +The split of responsibility is the point: code emits candidates because ids, branches, and titles are structured, and firstmate decides whether two prose descriptions are the same work. +A source that cannot be read is reported as unavailable rather than empty, on the same law the merge path applies to check runs. +[`docs/configuration.md`](configuration.md) "Duplicate-work overlap scan" owns the posture knob and the advisory default. + ## Dispatch profiles Crewmate and scout dispatch can stay on the static crewmate harness resolved by `config/crew-harness`, or it can use local dispatch profiles in `config/crew-dispatch.json`. diff --git a/docs/configuration.md b/docs/configuration.md index 223be05cd2f..fa097c5a6f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,6 +126,26 @@ A Secondmate on a remote route is covered the same way: the primary resolves and The presence flag is session-scoped enablement, so it transfers at launch and is left unchanged by live convergence into a running home. See [`trace-context.md`](trace-context.md) for carrier semantics, supported routes, the manual fleet-restart requirement, the session boundary, and safety limits; `bin/fm-trace-context-lib.sh`'s header owns the exact mechanics, and [`verification/trace-context.md`](verification/trace-context.md) records repeatable evidence. +## Duplicate-work overlap scan (config/spawn-overlap) + +Every ship and scout spawn compares the new task's subject against work that is already open before any endpoint or worktree exists. +The candidate set is the open tasks in this home's backlog, the tasks that hold live runtime metadata, the project's local and remote branches, and the project's open pull request titles. +The task's subject is its id plus the first line of its brief's task statement, and a candidate is kept when it shares at least two normalised tokens with that subject and those shared tokens cover at least half of the smaller of the two vocabularies. +Both conditions matter: a bare token floor is met by almost everything in a fleet whose work shares one house vocabulary, and denominating by the subject alone would put short branch names permanently out of reach. +Candidates are listed strongest evidence first, and the list is ordered rather than bounded, so the reader meets the likeliest duplicate at the top without anything being dropped. +`bin/fm-spawn.sh`'s header owns the normalisation and the exact emitted fields; `bin/fm-pr-lib.sh`'s `fm_pr_open_request_titles` is the single forge reader behind the pull request source. +Code emits candidates and never decides equivalence: whether two prose descriptions are the same work is firstmate's judgment, made from a surfaced set. + +A source that cannot be read yields `overlap=unavailable` and never `overlap=none`, so an incomplete set is never mistaken for a clean one. +The result is recorded in the task's own metadata as `overlap=`, plus `overlap_refs=` when candidates exist and `overlap_ack=` when an acknowledgement was given, which keeps "nothing was compared" and "compared, found nothing" distinguishable afterwards. + +The local, gitignored `config/spawn-overlap` file selects the posture and is inherited by secondmate homes. +Absent or `advisory` prints and records the result and never refuses. +`enforce` additionally requires `bin/fm-spawn.sh --overlap-ack <ref>[,<ref>...]` to name every surfaced ref before the dispatch proceeds; the refusal prints the exact value to reuse. +`off` skips the scan and says so. +Any other value refuses the spawn, because a safety knob that cannot be read must never be treated as an absent one. +`FM_SPAWN_OVERLAP_PR_LIMIT` bounds the pull requests listed per spawn (default 100), `FM_SPAWN_OVERLAP_MIN_TOKENS` sets the shared-token floor (default 2), and `FM_SPAWN_OVERLAP_MIN_PERCENT` sets the coverage threshold (default 50). + ## Gate defaults (.no-mistakes.yaml) The tracked `.no-mistakes.yaml` keeps test evidence outside the repo and pins `commands.lint` to `bin/fm-lint.sh` so local lint matches CI. @@ -518,6 +538,9 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops FM_BACKEND_CMUX_COMPOSER_LINES=20 # cmux-only: tail lines scanned to locate the composer row for submit verification FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer placeholder regex after border/prompt stripping CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) +FM_SPAWN_OVERLAP_PR_LIMIT=100 # open pull requests listed per spawn by the duplicate-work overlap scan +FM_SPAWN_OVERLAP_MIN_TOKENS=2 # shared normalised tokens required before a candidate is surfaced as an overlap +FM_SPAWN_OVERLAP_MIN_PERCENT=50 # percent of the smaller vocabulary those shared tokens must cover FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording diff --git a/tests/fm-spawn-overlap.test.sh b/tests/fm-spawn-overlap.test.sh new file mode 100755 index 00000000000..2e16dc91ec0 --- /dev/null +++ b/tests/fm-spawn-overlap.test.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn.sh's intake duplicate-work overlap scan. +# +# The headline case is a replay of a recorded live incident: one fix was +# implemented twice, under two task ids, on two branches, by two workers, with +# an open pull request already carrying the change, and nothing detected it. +# Every fixture below is that incident's real shape - the open task +# merge-refuses-unverified-green, its branch, and pull request 1614 - so the +# replay is red against any build without the scan by construction. +# +# The spawns run against a fake tmux pane and a fake gh-axi, so no harness, +# terminal, or forge is touched. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-overlap) + +# The recorded incident, verbatim. +DUP_TASK=merge-refuses-unverified-green +DUP_TITLE='refuse merges without verified green checks' +DUP_BRANCH=fm/merge-refuses-unverified-green +DUP_PR=1614 +DUP_PR_TITLE='fix(bin): refuse merges without verified green checks' +NEW_TASK=merge-path-verifies-no-ci-green +UNRELATED_TASK=posix-launcher-platform-surface + +# --- fixtures --------------------------------------------------------------- + +# A gh-axi whose `pr list` prints the TOON block the real one prints, or fails +# the way an unreachable or unauthenticated forge fails. FM_FAKE_GH_FAIL +# selects the outage. +make_fake_gh() { + local fakebin=$1 + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = pr ] && [ "${2:-}" = list ]; then + if [ -n "${FM_FAKE_GH_FAIL:-}" ]; then + echo "error: could not reach github.com: dial tcp: lookup github.com: no such host" >&2 + exit 1 + fi + cat <<'TOON' +count: 2 of 523 total +pull_requests[2]{number,title,state,author,draft,review}: + 1614,"fix(bin): refuse merges without verified green checks",open,someone,no,none + 1885,"Make filed work reach the captain by name, not just the record",open,other,no,none +help[1]: + Run `gh-axi pr view <number>` to view details +TOON + exit 0 +fi +exit 0 +SH + chmod +x "$fakebin/gh-axi" +} + +make_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window|send-keys) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" pi pi-signed + make_fake_gh "$fakebin" + printf '%s\n' "$fakebin" +} + +# A home carrying the incident's open task, its branch, and (through the fake +# gh-axi above) its open pull request. +make_case() { # <name> <spawned-id> + local name=$1 id=$2 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf 'pi\n' > "$home/config/crew-harness" + touch "$home/state/.last-watcher-beat" + fm_git_worktree "$proj" "$wt" "wt-$name" + git -C "$proj" branch "$DUP_BRANCH" >/dev/null 2>&1 + cat > "$home/data/backlog.md" <<EOF +# Backlog + +## In flight +- [ ] $DUP_TASK - $DUP_TITLE (repo: alpha) (kind: ship) (since 2026-08-05) + Two composing defects found by an earlier sweep: an empty check-run set reads + as green, and the landing path verifies nothing before it lands. + +## Queued +- [ ] unrelated-doc-audience-sweep - Reclassify the documentation audience inventory (repo: alpha) (kind: ship) + Nothing to do with landing or check runs. + +## Done +- [x] $NEW_TASK-old - a closed record naming merges and green checks that must never be surfaced (repo: alpha) +EOF + mkdir -p "$home/data/$id" + printf '# Task\n%s\n' "${3:-brief for $id}" > "$home/data/$id/brief.md" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +read_case_record() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR <<EOF +$1 +EOF +} + +run_spawn() { # <home> <wt> <fakebin> <spawn args...> + local home=$1 wt=$2 fakebin=$3 + shift 3 + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ + "$SPAWN" "$@" --mode no-mistakes --yolo off 2>&1 +} + +# --- the replay ------------------------------------------------------------- + +test_replay_surfaces_the_recorded_duplicate() { + local rec out status meta + rec=$(make_case replay "$NEW_TASK") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "advisory mode must surface the overlap without refusing the spawn" + + assert_contains "$out" "overlap=candidates" "the replay did not report a candidate overlap" + assert_contains "$out" "task,$DUP_TASK," "the open task already covering this work was not surfaced" + assert_contains "$out" "branch,$DUP_BRANCH," "the live branch already covering this work was not surfaced" + assert_contains "$out" "pr,$DUP_PR,\"$DUP_PR_TITLE\"" \ + "the open pull request already covering this work was not surfaced with the title that makes it reviewable" + assert_contains "$out" "green" "the shared tokens that make the overlap reviewable were not reported" + assert_not_contains "$out" "overlap=none" "a live overlap must never be reported as none" + # The closed backlog record names the same words on purpose: a landed task is + # not work in flight and surfacing it would train the reader to skim. + assert_not_contains "$out" "$NEW_TASK-old" "a closed backlog record was surfaced as open work" + # The unrelated open item shares no subject tokens and must stay out of the set. + assert_not_contains "$out" "unrelated-doc-audience-sweep" "an unrelated open task was surfaced" + + meta="$HOME_DIR/state/$NEW_TASK.meta" + assert_grep "overlap=candidates" "$meta" "the spawn record does not carry the overlap result" + assert_grep "task:$DUP_TASK" "$meta" "the spawn record does not name the overlapping task" + assert_grep "branch:$DUP_BRANCH" "$meta" "the spawn record does not name the overlapping branch" + assert_grep "pr:$DUP_PR" "$meta" "the spawn record does not name the overlapping pull request" + pass "replaying the recorded incident surfaces the duplicate task, branch, and pull request" +} + +test_unrelated_task_surfaces_no_overlap() { + local rec out status meta + rec=$(make_case negative-control "$UNRELATED_TASK") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$UNRELATED_TASK" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "an unrelated task must spawn" + assert_contains "$out" "overlap=none" "the negative control did not report an empty overlap set" + assert_not_contains "$out" "$DUP_TASK" "the negative control matched work it shares no subject with" + assert_not_contains "$out" "$DUP_BRANCH" "the negative control matched a branch it shares no subject with" + + meta="$HOME_DIR/state/$UNRELATED_TASK.meta" + assert_grep "overlap=none" "$meta" "the spawn record does not distinguish a checked-and-empty set" + assert_no_grep "overlap_refs=" "$meta" "an empty overlap set must record no refs" + pass "an unrelated task surfaces no overlap and records the empty set as checked" +} + +# --- the empty-set law ------------------------------------------------------ + +test_forge_outage_is_unavailable_not_none() { + local rec out status meta + rec=$(make_case forge-outage "$UNRELATED_TASK") + read_case_record "$rec" + + # The unrelated task is deliberate: with a reachable forge this same fixture + # reports overlap=none, so the only thing under test is the outage. + out=$(FM_FAKE_GH_FAIL=1 run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$UNRELATED_TASK" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "advisory mode must not refuse on an unreachable forge" + assert_contains "$out" "overlap=unavailable" "an unreachable forge was not reported as unavailable" + assert_not_contains "$out" "overlap=none" "an unreachable forge must never read as an empty overlap set" + assert_contains "$out" "unavailable,pr," "the unreadable source was not named" + + meta="$HOME_DIR/state/$UNRELATED_TASK.meta" + assert_grep "overlap=unavailable" "$meta" "the spawn record does not preserve the incomplete set" + assert_no_grep "overlap=none" "$meta" "the spawn record downgraded an incomplete set to a pass" + pass "an unreachable forge yields overlap=unavailable and never overlap=none" +} + +test_missing_forge_cli_is_unavailable_not_none() { + local rec out fakebin + rec=$(make_case forge-missing "$UNRELATED_TASK") + read_case_record "$rec" + # Remove the fake and shadow the real one with a PATH holding neither, so the + # CLI is genuinely absent rather than failing. + rm -f "$FAKEBIN_DIR/gh-axi" + fakebin="$CASE_DIR/isolated-bin" + mkdir -p "$fakebin" + cp "$FAKEBIN_DIR"/* "$fakebin/" 2>/dev/null || true + + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + GROK_HOME="$HOME_DIR/grok-home" PATH="$fakebin:/usr/bin:/bin" \ + "$SPAWN" "$UNRELATED_TASK" "$PROJ_DIR" --mode no-mistakes --yolo off 2>&1) + + assert_contains "$out" "overlap=unavailable" "an absent forge CLI was not reported as unavailable" + assert_not_contains "$out" "overlap=none" "an absent forge CLI must never read as an empty overlap set" + pass "an absent forge CLI yields overlap=unavailable and never overlap=none" +} + +# --- enforcement ------------------------------------------------------------ + +test_enforce_requires_acknowledgement_and_records_it() { + local rec out status meta refs + rec=$(make_case enforce "$NEW_TASK") + read_case_record "$rec" + printf 'enforce\n' > "$HOME_DIR/config/spawn-overlap" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + expect_code 1 "$status" "enforce mode must refuse an unacknowledged live overlap" + assert_contains "$out" "would dispatch against work already open" "the refusal did not name its reason" + assert_contains "$out" "task:$DUP_TASK" "the refusal did not name the unacknowledged task" + assert_contains "$out" "--overlap-ack" "the refusal did not name the way through" + assert_absent "$HOME_DIR/state/$NEW_TASK.meta" "a refused spawn must leave no task metadata behind" + + # The refusal prints the exact acknowledgement to reuse; take it from there + # rather than reconstructing it, which is how firstmate would use it. + refs=$(printf '%s\n' "$out" | sed -n "s/.*--overlap-ack '\\([^']*\\)'.*/\\1/p" | head -n 1) + [ -n "$refs" ] || fail "the refusal did not print a reusable --overlap-ack value" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR" --overlap-ack "$refs") + status=$? + expect_code 0 "$status" "an explicit acknowledgement must let the dispatch through" + meta="$HOME_DIR/state/$NEW_TASK.meta" + assert_grep "overlap=candidates" "$meta" "the acknowledged spawn record lost the overlap result" + assert_grep "overlap_ack=" "$meta" "the acknowledgement was not recorded in the spawn record" + assert_contains "$(cat "$meta")" "overlap_ack=$refs" "the recorded acknowledgement does not match what was given" + pass "enforce mode refuses an unacknowledged live overlap and records the acknowledgement that clears it" +} + +test_enforce_refuses_an_incomplete_set_without_acknowledgement() { + local rec out status + rec=$(make_case enforce-outage "$UNRELATED_TASK") + read_case_record "$rec" + printf 'enforce\n' > "$HOME_DIR/config/spawn-overlap" + + out=$(FM_FAKE_GH_FAIL=1 run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$UNRELATED_TASK" "$PROJ_DIR") + status=$? + expect_code 1 "$status" "an incomplete overlap set must not pass as an empty one" + assert_contains "$out" "unavailable:pr" "the refusal did not name the source that could not be read" + pass "enforce mode refuses an incomplete overlap set, because an absent set is not a pass" +} + +test_advisory_default_never_refuses() { + local rec out status + rec=$(make_case advisory-default "$NEW_TASK") + read_case_record "$rec" + assert_absent "$HOME_DIR/config/spawn-overlap" "the default case must carry no overlap config" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "the default posture must print and record without refusing" + assert_contains "$out" "overlap=candidates" "the default posture went silent on a live overlap" + pass "the default posture is loud and never a refusal" +} + +test_off_skips_the_scan_but_says_so() { + local rec out status meta + rec=$(make_case switched-off "$NEW_TASK") + read_case_record "$rec" + printf 'off\n' > "$HOME_DIR/config/spawn-overlap" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "an opted-out home must still spawn" + assert_contains "$out" "overlap=off" "opting out was silent" + assert_not_contains "$out" "overlap=none" "an unscanned task must never read as a checked-and-empty one" + meta="$HOME_DIR/state/$NEW_TASK.meta" + assert_grep "overlap=off" "$meta" "the spawn record does not distinguish an unscanned task" + pass "off skips the scan and records that nothing was compared" +} + +test_unreadable_config_refuses() { + local rec out status + rec=$(make_case bad-config "$NEW_TASK") + read_case_record "$rec" + printf 'advisery\n' > "$HOME_DIR/config/spawn-overlap" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + expect_code 1 "$status" "a safety knob that cannot be read must refuse" + assert_contains "$out" "config/spawn-overlap" "the refusal did not name the file to fix" + assert_absent "$HOME_DIR/state/$NEW_TASK.meta" "a refused spawn must leave no task metadata behind" + pass "an unreadable overlap config refuses instead of reading as absent" +} + +# --- sources ---------------------------------------------------------------- + +test_live_task_without_a_backlog_row_is_surfaced() { + local rec out + rec=$(make_case live-meta-only "$NEW_TASK") + read_case_record "$rec" + # A task under way whose backlog row was never written: the durable queue + # cannot see it, and it is exactly as duplicable as one that has a row. + fm_write_meta "$HOME_DIR/state/merge-green-verifier-live.meta" \ + "window=firstmate:fm-merge-green-verifier-live" \ + "worktree=$WT_DIR" "project=$PROJ_DIR" "harness=pi" "kind=ship" + # A persistent second mate is a home, not a work item, and must not be + # surfaced as overlapping work however its name reads. + fm_write_secondmate_meta "$HOME_DIR/state/merge-green-secondmate.meta" "$HOME_DIR" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + assert_contains "$out" "task,merge-green-verifier-live," "a task under way with no backlog row was invisible" + assert_not_contains "$out" "merge-green-secondmate" "a persistent second mate was surfaced as overlapping work" + pass "a task under way with no backlog row is surfaced, and a second mate is not" +} + +# --- what separates a candidate from a coincidence -------------------------- + +# A shared-token floor alone is not a filter in a fleet whose work all shares +# one house vocabulary: measured against a real backlog it surfaced 61 +# candidates for a single task, and a set nobody can read is a set nobody +# reads. A candidate must also cover half of the smaller of the two +# vocabularies. Both halves of that rule are load-bearing and are asserted here +# together, because relaxing either one silently is what would make the scan +# decorative. +test_a_long_subject_keeps_short_branches_and_drops_coincidences() { + local rec out subject + subject='The landing path merges without verifying that continuous integration is green.' + rec=$(make_case ratio "$NEW_TASK" "$subject") + read_case_record "$rec" + # A task with a large, unrelated vocabulary that happens to name two of the + # subject's words. Two shared tokens clear the floor and must not be enough. + cat >> "$HOME_DIR/data/backlog.md" <<'EOF' +- [ ] engraphis-thumbnail-cache-eviction - Add an LRU eviction policy to the Engraphis thumbnail cache (repo: engraphis) (kind: ship) + The cache grows without bound across imports. Size it by resident bytes, + evict least-recently-used entries first, and expose the high-water mark. + Unrelated aside: the release notes were merged while the dashboard was green. +EOF + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + assert_contains "$out" "task,$DUP_TASK," "the genuine duplicate was lost once the subject grew" + # Four words against a subject of eight: denominating by the subject alone + # would put every branch permanently out of reach. + assert_contains "$out" "branch,$DUP_BRANCH," "a short branch name was unreachable from a long subject" + assert_not_contains "$out" "engraphis-thumbnail-cache-eviction" \ + "a coincidental two-word overlap with an unrelated task was surfaced as a candidate" + pass "a long subject still reaches short branch names and no longer matches coincidences" +} + +test_strongest_evidence_is_listed_first() { + local rec out first + rec=$(make_case ranking "$NEW_TASK") + read_case_record "$rec" + # The weaker match is deliberately written FIRST in the backlog, so source + # order and evidence order disagree and only real ranking can pass this. + cat > "$HOME_DIR/data/backlog.md" <<EOF +# Backlog + +## In flight +- [ ] green-release-notes - Collect the green release notes after each merge (repo: alpha) (kind: ship) + Nothing to do with verifying anything. +- [ ] $DUP_TASK - $DUP_TITLE (repo: alpha) (kind: ship) (since 2026-08-05) + An empty check-run set reads as green, and the landing path verifies nothing. +EOF + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + assert_contains "$out" "green-release-notes" "the weaker candidate was filtered out, so this proves nothing about order" + first=$(printf '%s\n' "$out" | sed -n 's/^ \(task\|branch\|pr\),.*/&/p' | head -n 1) + assert_contains "$first" "$DUP_TASK" \ + "the strongest candidate was not listed first, so the likeliest duplicate is not what gets read" + pass "candidates are listed strongest evidence first, not in the order the sources were read" +} + +# --- the shared forge reader ------------------------------------------------ + +test_forge_reader_separates_no_rows_from_no_answer() { + local dir fakebin out status + dir="$TMP_ROOT/forge-reader" + fakebin=$(make_fakebin "$dir/fake") + mkdir -p "$dir/repo" + + out=$(PATH="$fakebin:$PATH" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 100' _ "$ROOT" "$dir/repo") + status=$? + expect_code 0 "$status" "a reachable forge must return success" + assert_contains "$out" "1614" "the listing dropped the pull request number" + assert_contains "$out" "refuse merges without verified green checks" "the listing dropped the title" + # A title carrying a comma must survive intact, or a later field would be + # read as part of it. + assert_contains "$out" "Make filed work reach the captain by name, not just the record" \ + "a comma inside a title truncated it" + + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_FAIL=1 bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 100; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") + assert_contains "$out" "rc=1" "an unreachable forge returned success" + assert_contains "$out" "err=" "an unreachable forge reported no reason" + assert_not_contains "$out" "1614" "a failed listing must yield no rows at all" + pass "the shared forge reader separates 'no open requests' from 'no answer'" +} + +test_replay_surfaces_the_recorded_duplicate +test_unrelated_task_surfaces_no_overlap +test_forge_outage_is_unavailable_not_none +test_missing_forge_cli_is_unavailable_not_none +test_enforce_requires_acknowledgement_and_records_it +test_enforce_refuses_an_incomplete_set_without_acknowledgement +test_advisory_default_never_refuses +test_off_skips_the_scan_but_says_so +test_unreadable_config_refuses +test_live_task_without_a_backlog_row_is_surfaced +test_a_long_subject_keeps_short_branches_and_drops_coincidences +test_strongest_evidence_is_listed_first +test_forge_reader_separates_no_rows_from_no_answer + +echo "# all fm-spawn-overlap tests passed" From 539c72b68fe8e1673e880edb2d214ab707abf61c Mon Sep 17 00:00:00 2001 From: Shane Bracewell <sbracewell64@gmail.com> Date: Fri, 7 Aug 2026 23:21:54 -0400 Subject: [PATCH 2/5] no-mistakes(review): harden overlap scan truncation, config, and source failure handling --- bin/fm-pr-lib.sh | 41 +++++++++++++---- bin/fm-spawn.sh | 51 +++++++++++++++++---- docs/configuration.md | 5 +- tests/fm-spawn-overlap.test.sh | 83 +++++++++++++++++++++++++++++++--- 4 files changed, 153 insertions(+), 27 deletions(-) diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 39287b57c87..3d495450387 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -335,8 +335,9 @@ fm_pr_metadata_identity_parse() { # rather than growing a second forge reader with its own failure semantics. # # The empty-set law binds here and is the whole reason this returns a status. -# A missing CLI, an unauthenticated CLI, a network failure, or a rejected -# listing all return non-zero with FM_PR_LIST_ERROR set to a one-line reason. +# A missing CLI, an unauthenticated CLI, a network failure, a rejected listing, +# or a listing truncated below the forge's reported open total all return +# non-zero with FM_PR_LIST_ERROR set to a one-line reason. # A caller must render that as "unknown" and NEVER as "no open requests": the # absence of an answer is not an answer. A successful listing with no rows is # the only thing that means no open requests, and it returns zero. @@ -344,7 +345,7 @@ fm_pr_metadata_identity_parse() { # No --fields is passed: the default gh-axi listing already carries number, # title, and state, and a rejected field list would fail the whole call. fm_pr_open_request_titles() { # <repo-dir> [limit] - local repo=$1 limit=${2:-100} out + local repo=$1 limit=${2:-600} out rows total FM_PR_LIST_ERROR= case "$limit" in ''|*[!0-9]*) FM_PR_LIST_ERROR="pull request limit '$limit' is not a number"; return 1 ;; @@ -357,13 +358,33 @@ fm_pr_open_request_titles() { # <repo-dir> [limit] [ -n "$FM_PR_LIST_ERROR" ] || FM_PR_LIST_ERROR="gh-axi pr list failed with no output" return 1 fi - # gh-axi prints a TOON block: a "pull_requests[N]{...}:" header, then one - # indented "<number>,\"<title>\",<state>,<author>,<draft>,<review>" row each. - # The title is matched greedily up to the last quote that is followed by the - # four trailing scalar fields, so a comma or quote inside a title cannot - # truncate it. - printf '%s\n' "$out" \ - | sed -n 's/^[[:space:]]\{1,\}\([0-9]\{1,\}\),"\(.*\)",[^,]*,[^,]*,[^,]*,[^,]*$/\1\t\2/p' + # gh-axi prints a TOON block: a count header, a "pull_requests[N]{...}:" + # header, then one indented "<number>,\"<title>\",<state>,<author>,<draft>, + # <review>" row each. The title is matched greedily up to the last quote that + # is followed by the four trailing scalar fields, so a comma or quote inside + # a title cannot truncate it. + rows=$(printf '%s\n' "$out" \ + | sed -n 's/^[[:space:]]\{1,\}\([0-9]\{1,\}\),"\(.*\)",[^,]*,[^,]*,[^,]*,[^,]*$/\1\t\2/p') + # A listing bounded by the window still exits zero, so the "count: N of M + # total" header is the only truncation signal, and a total above the window + # FAILS the call: a window onto the open set must never read as the open set. + # An absent or unparseable header leaves the total unknown rather than proven + # untruncated, so a listing that fills its whole window is refused too; only + # a listing smaller than its window - which no limit can have cut - passes + # without a total. + total=$(printf '%s\n' "$out" \ + | sed -n 's/^count:[[:space:]]*[0-9]\{1,\}[[:space:]]\{1,\}of[[:space:]]\{1,\}\([0-9]\{1,\}\)[[:space:]]\{1,\}total$/\1/p' \ + | head -n 1) + if [ -n "$total" ]; then + if [ "$total" -gt "$limit" ]; then + FM_PR_LIST_ERROR="the forge reports $total open pull requests and only the newest $limit were listed; a truncated listing must not read as the open set" + return 1 + fi + elif [ "$(printf '%s\n' "$rows" | grep -c .)" -ge "$limit" ]; then + FM_PR_LIST_ERROR="the listing filled its whole $limit-row window and reported no total, so truncation cannot be ruled out" + return 1 + fi + [ -z "$rows" ] || printf '%s\n' "$rows" } # Sidecar layout: provider, url, host, path, number, one per line. A sidecar diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 553fe9925f3..1a3fcee7f80 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -39,7 +39,9 @@ # metadata as overlap=, # overlap_refs=, and overlap_ack=. Code emits the candidate set and NEVER # decides equivalence, which is firstmate's judgment. A source that cannot be -# read yields overlap=unavailable, never overlap=none. config/spawn-overlap +# read yields overlap=unavailable, never overlap=none, and a pull request +# listing whose forge-reported open total exceeds FM_SPAWN_OVERLAP_PR_LIMIT +# (default 600) counts as unreadable the same way. config/spawn-overlap # selects advisory (the default: printed and recorded, never refused), # enforce (--overlap-ack must name every surfaced ref), or off; any other # value refuses. --overlap-ack <ref>[,<ref>...] is that acknowledgement and is @@ -1670,6 +1672,11 @@ overlap_match() { # <subject-text> <min-tokens> <min-percent>; candidate rows o awk -v subject="$1" -v min="$2" -v pct="$3" -v stop=" $OVERLAP_STOPWORDS " ' function norm(w) { if (length(w) < 3) return "" + # Stopword membership is checked BOTH before and after de-pluralization: + # a stopword whose stemmed form is not itself listed ("this" -> "thi", + # "fixes" -> "fixe") would otherwise escape the filter and count as + # shared signal. + if (index(stop, " " w " ") > 0) return "" if (length(w) > 3 && substr(w, length(w), 1) == "s" && substr(w, length(w) - 1, 1) != "s") w = substr(w, 1, length(w) - 1) if (length(w) < 3) return "" @@ -1729,15 +1736,19 @@ overlap_match() { # <subject-text> <min-tokens> <min-percent>; candidate rows o # <kind>\t<ref>\t<title>\t<searchable text> # A source that cannot answer emits kind "unavailable" with its reason. overlap_candidates() { # <task-id> <project-dir> - local id=$1 proj=$2 backlog="$DATA/backlog.md" meta tid ref num title out limit prlist + local id=$1 proj=$2 backlog="$DATA/backlog.md" meta tid ref num title out limit prlist rc # Open tasks: this home's durable queue, plus any task with live runtime # metadata, so a task dispatched without a backlog row is still visible. if [ -d "$DATA" ]; then if [ -f "$backlog" ]; then # Field separators are stripped inside awk rather than by a downstream - # pipeline, so a tab in a task body can never shift a later field. - awk -v self="$id" ' + # pipeline, so a tab in a task body can never shift a later field. The + # awk is guarded because this function runs with set -e inherited: a + # backlog that passes -f but cannot be read must mark the gap with an + # unavailable row rather than abort the subshell and silently truncate + # the sweep. + if ! awk -v self="$id" ' function emit() { if (id != "" && id != self) { gsub(/\t/, " ", title); gsub(/\t/, " ", body) @@ -1758,7 +1769,9 @@ overlap_candidates() { # <task-id> <project-dir> /^[ \t]/ { if (id != "") body = body " " $0; next } { next } END { emit() } - ' "$backlog" + ' "$backlog"; then + printf 'unavailable\ttask\tthe backlog at %s could not be read\t\n' "$backlog" + fi fi else printf 'unavailable\ttask\tthis home has no readable data directory at %s\t\n' "$DATA" @@ -1768,7 +1781,15 @@ overlap_candidates() { # <task-id> <project-dir> [ -f "$meta" ] || continue tid=$(basename "$meta" .meta) [ "$tid" != "$id" ] || continue - grep -q '^kind=secondmate$' "$meta" && continue + # grep exit 2 means the record could not be read - a vanished or + # unreadable file - which is a gap in the set, not a non-match. + rc=0 + grep -q '^kind=secondmate$' "$meta" || rc=$? + [ "$rc" -ne 0 ] || continue + if [ "$rc" -ge 2 ]; then + printf 'unavailable\ttask\tthe task record at %s could not be read\t\n' "$meta" + continue + fi if [ -f "$backlog" ] && grep -qF -- "- [ ] $tid " "$backlog"; then continue fi @@ -1783,8 +1804,12 @@ overlap_candidates() { # <task-id> <project-dir> if out=$(git -C "$proj" for-each-ref --format='%(refname:short)' refs/heads refs/remotes 2>&1); then while IFS= read -r ref; do [ -n "$ref" ] || continue + # Only the task's OWN branch is excluded, and the exclusion requires a + # path-component boundary: a branch that merely ends with the id, such as + # fm/re-verify-green for task verify-green, is the same work family and + # must stay visible. case "$ref" in - *"$id") continue ;; + "$id"|*/"$id") continue ;; */HEAD|HEAD) continue ;; esac printf 'branch\t%s\t\t%s\n' "$ref" "$ref" @@ -1800,12 +1825,13 @@ EOF # file rather than a command substitution because FM_PR_LIST_ERROR - the # reason a forge could not answer, and the whole difference between # "unavailable" and "none" - would not survive the subshell. - limit=${FM_SPAWN_OVERLAP_PR_LIMIT:-100} + limit=${FM_SPAWN_OVERLAP_PR_LIMIT:-600} if ! prlist=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-overlap-pr.XXXXXX" 2>/dev/null); then printf 'unavailable\tpr\tno temporary file could be created for the pull request listing\t\n' elif fm_pr_open_request_titles "$proj" "$limit" > "$prlist" 2>/dev/null; then while IFS=$'\t' read -r num title; do [ -n "$num" ] || continue + title=${title//$'\t'/ } printf 'pr\t%s\t%s\t%s\n' "$num" "$title" "$title" done < "$prlist" rm -f "$prlist" @@ -1818,7 +1844,14 @@ EOF if [ "$KIND" != secondmate ]; then OVERLAP_MODE=advisory if [ -f "$CONFIG/spawn-overlap" ]; then - OVERLAP_MODE=$(head -n 1 "$CONFIG/spawn-overlap" 2>/dev/null | tr -d '[:space:]') + # Absent stays advisory; PRESENT but unreadable refuses. An unreadable + # enforce file that quietly downgraded to advisory would be a broken + # safety knob reading as an absent one. + if [ ! -r "$CONFIG/spawn-overlap" ] || ! OVERLAP_MODE=$(head -n 1 "$CONFIG/spawn-overlap" 2>/dev/null); then + echo "error: config/spawn-overlap exists but cannot be read. A safety knob that cannot be read must never be treated as an absent one" >&2 + exit 1 + fi + OVERLAP_MODE=$(printf '%s' "$OVERLAP_MODE" | tr -d '[:space:]') [ -n "$OVERLAP_MODE" ] || OVERLAP_MODE=advisory fi case "$OVERLAP_MODE" in diff --git a/docs/configuration.md b/docs/configuration.md index fa097c5a6f7..21074c1ef12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,6 +137,7 @@ Candidates are listed strongest evidence first, and the list is ordered rather t Code emits candidates and never decides equivalence: whether two prose descriptions are the same work is firstmate's judgment, made from a surfaced set. A source that cannot be read yields `overlap=unavailable` and never `overlap=none`, so an incomplete set is never mistaken for a clean one. +A pull request listing whose forge-reported open total exceeds `FM_SPAWN_OVERLAP_PR_LIMIT` fails the same way, because a truncated listing is an incomplete set rather than the open set. The result is recorded in the task's own metadata as `overlap=`, plus `overlap_refs=` when candidates exist and `overlap_ack=` when an acknowledgement was given, which keeps "nothing was compared" and "compared, found nothing" distinguishable afterwards. The local, gitignored `config/spawn-overlap` file selects the posture and is inherited by secondmate homes. @@ -144,7 +145,7 @@ Absent or `advisory` prints and records the result and never refuses. `enforce` additionally requires `bin/fm-spawn.sh --overlap-ack <ref>[,<ref>...]` to name every surfaced ref before the dispatch proceeds; the refusal prints the exact value to reuse. `off` skips the scan and says so. Any other value refuses the spawn, because a safety knob that cannot be read must never be treated as an absent one. -`FM_SPAWN_OVERLAP_PR_LIMIT` bounds the pull requests listed per spawn (default 100), `FM_SPAWN_OVERLAP_MIN_TOKENS` sets the shared-token floor (default 2), and `FM_SPAWN_OVERLAP_MIN_PERCENT` sets the coverage threshold (default 50). +`FM_SPAWN_OVERLAP_PR_LIMIT` bounds the pull requests listed per spawn (default 600), `FM_SPAWN_OVERLAP_MIN_TOKENS` sets the shared-token floor (default 2), and `FM_SPAWN_OVERLAP_MIN_PERCENT` sets the coverage threshold (default 50). ## Gate defaults (.no-mistakes.yaml) @@ -538,7 +539,7 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops FM_BACKEND_CMUX_COMPOSER_LINES=20 # cmux-only: tail lines scanned to locate the composer row for submit verification FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer placeholder regex after border/prompt stripping CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) -FM_SPAWN_OVERLAP_PR_LIMIT=100 # open pull requests listed per spawn by the duplicate-work overlap scan +FM_SPAWN_OVERLAP_PR_LIMIT=600 # open pull requests listed per spawn by the duplicate-work overlap scan; a forge total above it reads as unavailable, never as none FM_SPAWN_OVERLAP_MIN_TOKENS=2 # shared normalised tokens required before a candidate is surfaced as an overlap FM_SPAWN_OVERLAP_MIN_PERCENT=50 # percent of the smaller vocabulary those shared tokens must cover FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh diff --git a/tests/fm-spawn-overlap.test.sh b/tests/fm-spawn-overlap.test.sh index 2e16dc91ec0..4f824c243fb 100755 --- a/tests/fm-spawn-overlap.test.sh +++ b/tests/fm-spawn-overlap.test.sh @@ -202,6 +202,29 @@ test_forge_outage_is_unavailable_not_none() { pass "an unreachable forge yields overlap=unavailable and never overlap=none" } +test_truncated_pr_listing_is_unavailable_not_none() { + local rec out status meta + rec=$(make_case pr-window "$UNRELATED_TASK") + read_case_record "$rec" + + # The fake gh-axi's count header reports 523 open in total, so a two-row + # window is a bounded look at a larger open set. The unrelated task is + # deliberate: with a window covering the whole total this same fixture + # reports overlap=none, so the only thing under test is the truncation. + out=$(FM_SPAWN_OVERLAP_PR_LIMIT=2 run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$UNRELATED_TASK" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "advisory mode must not refuse on a truncated listing" + assert_contains "$out" "overlap=unavailable" "a truncated pull request listing was not reported as unavailable" + assert_not_contains "$out" "overlap=none" "a truncated listing must never read as an empty overlap set" + assert_contains "$out" "unavailable,pr," "the truncated source was not named" + assert_contains "$out" "523" "the reason did not name the forge's reported open total" + + meta="$HOME_DIR/state/$UNRELATED_TASK.meta" + assert_grep "overlap=unavailable" "$meta" "the spawn record does not preserve the incomplete set" + assert_no_grep "overlap=none" "$meta" "the spawn record downgraded a truncated listing to a pass" + pass "a pull request listing smaller than the forge's open total yields overlap=unavailable and never overlap=none" +} + test_missing_forge_cli_is_unavailable_not_none() { local rec out fakebin rec=$(make_case forge-missing "$UNRELATED_TASK") @@ -298,7 +321,7 @@ test_off_skips_the_scan_but_says_so() { pass "off skips the scan and records that nothing was compared" } -test_unreadable_config_refuses() { +test_misspelled_config_value_refuses() { local rec out status rec=$(make_case bad-config "$NEW_TASK") read_case_record "$rec" @@ -309,7 +332,30 @@ test_unreadable_config_refuses() { expect_code 1 "$status" "a safety knob that cannot be read must refuse" assert_contains "$out" "config/spawn-overlap" "the refusal did not name the file to fix" assert_absent "$HOME_DIR/state/$NEW_TASK.meta" "a refused spawn must leave no task metadata behind" - pass "an unreadable overlap config refuses instead of reading as absent" + pass "a misspelled overlap config value refuses instead of reading as absent" +} + +test_unreadable_config_file_refuses() { + local rec out status + rec=$(make_case unreadable-config "$NEW_TASK") + read_case_record "$rec" + if [ "$(id -u)" = 0 ]; then + pass "skipped: running as root ignores the unreadable-config permission bits" + return 0 + fi + # The knob is set to enforce and then made unreadable: absent stays advisory, + # but PRESENT and unreadable must refuse rather than silently downgrade. + printf 'enforce\n' > "$HOME_DIR/config/spawn-overlap" + chmod 000 "$HOME_DIR/config/spawn-overlap" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + chmod 644 "$HOME_DIR/config/spawn-overlap" + expect_code 1 "$status" "a present but unreadable safety knob must refuse" + assert_contains "$out" "config/spawn-overlap" "the refusal did not name the file to fix" + assert_contains "$out" "cannot be read" "the refusal did not say why" + assert_absent "$HOME_DIR/state/$NEW_TASK.meta" "a refused spawn must leave no task metadata behind" + pass "a present but unreadable overlap config refuses instead of reading as absent" } # --- sources ---------------------------------------------------------------- @@ -333,6 +379,21 @@ test_live_task_without_a_backlog_row_is_surfaced() { pass "a task under way with no backlog row is surfaced, and a second mate is not" } +test_a_branch_merely_ending_with_the_task_id_stays_visible() { + local rec out + rec=$(make_case branch-boundary "$NEW_TASK") + read_case_record "$rec" + # Only the task's OWN branch is excluded from the scan, on a path-component + # boundary. A branch that merely ends with the id - a retry or follow-up of + # the same work family - is the strongest evidence of a duplicate. + git -C "$PROJ_DIR" branch "fm/re-$NEW_TASK" >/dev/null 2>&1 + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + assert_contains "$out" "branch,fm/re-$NEW_TASK," \ + "a branch that merely ends with the task id was hidden as if it were the task's own branch" + pass "a branch that merely ends with the task id stays visible to the scan" +} + # --- what separates a candidate from a coincidence -------------------------- # A shared-token floor alone is not a filter in a fleet whose work all shares @@ -398,7 +459,7 @@ test_forge_reader_separates_no_rows_from_no_answer() { fakebin=$(make_fakebin "$dir/fake") mkdir -p "$dir/repo" - out=$(PATH="$fakebin:$PATH" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 100' _ "$ROOT" "$dir/repo") + out=$(PATH="$fakebin:$PATH" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 600' _ "$ROOT" "$dir/repo") status=$? expect_code 0 "$status" "a reachable forge must return success" assert_contains "$out" "1614" "the listing dropped the pull request number" @@ -408,23 +469,33 @@ test_forge_reader_separates_no_rows_from_no_answer() { assert_contains "$out" "Make filed work reach the captain by name, not just the record" \ "a comma inside a title truncated it" - out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_FAIL=1 bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 100; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_FAIL=1 bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 600; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") assert_contains "$out" "rc=1" "an unreachable forge returned success" assert_contains "$out" "err=" "an unreachable forge reported no reason" assert_not_contains "$out" "1614" "a failed listing must yield no rows at all" - pass "the shared forge reader separates 'no open requests' from 'no answer'" + + # A window smaller than the forge's reported open total must fail the call, + # naming both, and yield no rows: a truncated listing is not the open set. + out=$(PATH="$fakebin:$PATH" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 2; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") + assert_contains "$out" "rc=1" "a truncated listing returned success" + assert_contains "$out" "523" "the truncation reason did not name the forge's open total" + assert_not_contains "$out" "1614" "a truncated listing must yield no rows at all" + pass "the shared forge reader separates 'no open requests' from 'no answer' and from 'a bounded window'" } test_replay_surfaces_the_recorded_duplicate test_unrelated_task_surfaces_no_overlap test_forge_outage_is_unavailable_not_none +test_truncated_pr_listing_is_unavailable_not_none test_missing_forge_cli_is_unavailable_not_none test_enforce_requires_acknowledgement_and_records_it test_enforce_refuses_an_incomplete_set_without_acknowledgement test_advisory_default_never_refuses test_off_skips_the_scan_but_says_so -test_unreadable_config_refuses +test_misspelled_config_value_refuses +test_unreadable_config_file_refuses test_live_task_without_a_backlog_row_is_surfaced +test_a_branch_merely_ending_with_the_task_id_stays_visible test_a_long_subject_keeps_short_branches_and_drops_coincidences test_strongest_evidence_is_listed_first test_forge_reader_separates_no_rows_from_no_answer From 17e750f9588e941eb35892719ede717fb826a5fe Mon Sep 17 00:00:00 2001 From: Shane Bracewell <sbracewell64@gmail.com> Date: Sat, 8 Aug 2026 19:33:36 -0400 Subject: [PATCH 3/5] no-mistakes(review): self-size PR window, fail short listings, surface brief gaps --- bin/fm-pr-lib.sh | 108 +++++++++++++++--------- bin/fm-spawn.sh | 30 +++++-- docs/configuration.md | 9 +- tests/fm-spawn-overlap.test.sh | 148 ++++++++++++++++++++++++++++----- 4 files changed, 227 insertions(+), 68 deletions(-) diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 3d495450387..6ef42f66e7b 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -328,62 +328,96 @@ fm_pr_metadata_identity_parse() { } # List a repository's OPEN pull requests as "<number>\t<title>" rows, one per -# line, on stdout. This is the single forge reader for title-level work -# discovery: bin/fm-research-scan.sh's --landing prover already listens for -# delivery this way, and bin/fm-spawn.sh's intake overlap scan asks the same -# question about work already in flight, so both ask it through one place -# rather than growing a second forge reader with its own failure semantics. +# line, on stdout. This is the single forge reader for the OPEN-SET question - +# what work is in flight right now - asked by bin/fm-spawn.sh's intake overlap +# scan. bin/fm-research-scan.sh's --landing prover asks a different, all-state +# delivery question through its own reader; only the open-set question routes +# through here, so that question has one failure semantics rather than one per +# caller. # # The empty-set law binds here and is the whole reason this returns a status. # A missing CLI, an unauthenticated CLI, a network failure, a rejected listing, -# or a listing truncated below the forge's reported open total all return -# non-zero with FM_PR_LIST_ERROR set to a one-line reason. +# or a listing that accounts for less than the forge's reported open total all +# return non-zero with FM_PR_LIST_ERROR set to a one-line reason. # A caller must render that as "unknown" and NEVER as "no open requests": the # absence of an answer is not an answer. A successful listing with no rows is # the only thing that means no open requests, and it returns zero. # +# The window SELF-SIZES: the caller's limit is only the initial window, and a +# forge-reported total above it re-runs the listing exactly once with a window +# covering that total, so a growing open set does not age a fixed default into +# permanent unavailability. FM_PR_LIST_CEILING (default 5000) bounds that +# re-listing; a reported total above the ceiling fails with the reason rather +# than chasing a pathological forge without bound. +# # No --fields is passed: the default gh-axi listing already carries number, # title, and state, and a rejected field list would fail the whole call. fm_pr_open_request_titles() { # <repo-dir> [limit] - local repo=$1 limit=${2:-600} out rows total + local repo=$1 limit=${2:-600} ceiling=${FM_PR_LIST_CEILING:-5000} out rows header counted total listed relisted=0 FM_PR_LIST_ERROR= case "$limit" in ''|*[!0-9]*) FM_PR_LIST_ERROR="pull request limit '$limit' is not a number"; return 1 ;; esac [ "$limit" -gt 0 ] 2>/dev/null || { FM_PR_LIST_ERROR="pull request limit must be positive"; return 1; } + case "$ceiling" in + ''|*[!0-9]*) FM_PR_LIST_ERROR="pull request ceiling '$ceiling' (FM_PR_LIST_CEILING) is not a number"; return 1 ;; + esac + [ "$ceiling" -gt 0 ] 2>/dev/null || { FM_PR_LIST_ERROR="pull request ceiling (FM_PR_LIST_CEILING) must be positive"; return 1; } [ -d "$repo" ] || { FM_PR_LIST_ERROR="no repository directory at $repo"; return 1; } command -v gh-axi >/dev/null 2>&1 || { FM_PR_LIST_ERROR="gh-axi is not on PATH"; return 1; } - if ! out=$( (cd "$repo" && gh-axi pr list --state open --limit "$limit") 2>&1 ); then - FM_PR_LIST_ERROR=$(printf '%s' "$out" | head -n 1 | cut -c 1-200) - [ -n "$FM_PR_LIST_ERROR" ] || FM_PR_LIST_ERROR="gh-axi pr list failed with no output" - return 1 - fi - # gh-axi prints a TOON block: a count header, a "pull_requests[N]{...}:" - # header, then one indented "<number>,\"<title>\",<state>,<author>,<draft>, - # <review>" row each. The title is matched greedily up to the last quote that - # is followed by the four trailing scalar fields, so a comma or quote inside - # a title cannot truncate it. - rows=$(printf '%s\n' "$out" \ - | sed -n 's/^[[:space:]]\{1,\}\([0-9]\{1,\}\),"\(.*\)",[^,]*,[^,]*,[^,]*,[^,]*$/\1\t\2/p') - # A listing bounded by the window still exits zero, so the "count: N of M - # total" header is the only truncation signal, and a total above the window - # FAILS the call: a window onto the open set must never read as the open set. - # An absent or unparseable header leaves the total unknown rather than proven - # untruncated, so a listing that fills its whole window is refused too; only - # a listing smaller than its window - which no limit can have cut - passes - # without a total. - total=$(printf '%s\n' "$out" \ - | sed -n 's/^count:[[:space:]]*[0-9]\{1,\}[[:space:]]\{1,\}of[[:space:]]\{1,\}\([0-9]\{1,\}\)[[:space:]]\{1,\}total$/\1/p' \ - | head -n 1) - if [ -n "$total" ]; then - if [ "$total" -gt "$limit" ]; then - FM_PR_LIST_ERROR="the forge reports $total open pull requests and only the newest $limit were listed; a truncated listing must not read as the open set" + while :; do + if ! out=$( (cd "$repo" && gh-axi pr list --state open --limit "$limit") 2>&1 ); then + FM_PR_LIST_ERROR=$(printf '%s' "$out" | head -n 1 | cut -c 1-200) + [ -n "$FM_PR_LIST_ERROR" ] || FM_PR_LIST_ERROR="gh-axi pr list failed with no output" return 1 fi - elif [ "$(printf '%s\n' "$rows" | grep -c .)" -ge "$limit" ]; then - FM_PR_LIST_ERROR="the listing filled its whole $limit-row window and reported no total, so truncation cannot be ruled out" - return 1 - fi + # gh-axi prints a TOON block: a count header, a "pull_requests[N]{...}:" + # header, then one indented "<number>,\"<title>\",<state>,<author>,<draft>, + # <review>" row each. The title is matched greedily up to the last quote + # that is followed by the four trailing scalar fields, so a comma or quote + # inside a title cannot truncate it. + rows=$(printf '%s\n' "$out" \ + | sed -n 's/^[[:space:]]\{1,\}\([0-9]\{1,\}\),"\(.*\)",[^,]*,[^,]*,[^,]*,[^,]*$/\1\t\2/p') + listed=$(printf '%s\n' "$rows" | grep -c .) + # The count header is "count: N of M total" when the listing is bounded + # below the open total and a bare "count: N" when it is complete. The + # invariant is that the listing must ACCOUNT FOR the whole reported open + # total: whenever the header's own listed count or the rows actually + # parsed here fall short of that total, the call FAILS, because a window + # onto the open set must never read as the open set. Without a parseable + # total the total is unknown rather than proven, so a listing that filled + # its whole window is refused too; only a listing smaller than its window + # - which no limit can have cut - passes without a total. + header=$(printf '%s\n' "$out" \ + | sed -n 's/^count:[[:space:]]*\([0-9]\{1,\}\)[[:space:]]\{1,\}of[[:space:]]\{1,\}\([0-9]\{1,\}\)[[:space:]]\{1,\}total$/\1 \2/p' \ + | head -n 1) + if [ -n "$header" ]; then + counted=${header%% *} + total=${header##* } + if [ "$counted" -ge "$total" ] && [ "$listed" -ge "$total" ]; then + break + fi + # Self-sizing: one re-list, with one row of headroom so a complete + # listing sits strictly inside its window and passes the no-total rule + # above even when the forge then omits the total. + if [ "$relisted" -eq 0 ] && [ "$total" -gt "$limit" ]; then + if [ "$total" -gt "$ceiling" ]; then + FM_PR_LIST_ERROR="the forge reports $total open pull requests, above the $ceiling-row ceiling (FM_PR_LIST_CEILING); an open set that large is refused rather than chased" + return 1 + fi + relisted=1 + limit=$((total + 1)) + continue + fi + FM_PR_LIST_ERROR="the forge reports $total open pull requests and only $listed were listed; a listing short of the open total must not read as the open set" + return 1 + fi + if [ "$listed" -ge "$limit" ]; then + FM_PR_LIST_ERROR="the listing filled its whole $limit-row window and reported no total, so truncation cannot be ruled out" + return 1 + fi + break + done [ -z "$rows" ] || printf '%s\n' "$rows" } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1a3fcee7f80..55ddbeb5215 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -39,9 +39,13 @@ # metadata as overlap=, # overlap_refs=, and overlap_ack=. Code emits the candidate set and NEVER # decides equivalence, which is firstmate's judgment. A source that cannot be -# read yields overlap=unavailable, never overlap=none, and a pull request -# listing whose forge-reported open total exceeds FM_SPAWN_OVERLAP_PR_LIMIT -# (default 600) counts as unreadable the same way. config/spawn-overlap +# read yields overlap=unavailable, never overlap=none. The pull request +# window (FM_SPAWN_OVERLAP_PR_LIMIT, default 600) is only the initial size: +# the shared reader re-lists once to cover a forge-reported total above it, +# and a listing still short of that total - or a total above +# FM_PR_LIST_CEILING (default 5000) - counts as unreadable the same way. A +# brief that cannot be read or names no task statement is a gap in the +# SUBJECT and surfaces as unavailable too. config/spawn-overlap # selects advisory (the default: printed and recorded, never refused), # enforce (--overlap-ack must name every surfaced ref), or off; any other # value refuses. --overlap-ack <ref>[,<ref>...] is that acknowledgement and is @@ -1865,13 +1869,27 @@ if [ "$KIND" != secondmate ]; then echo "overlap=off (config/spawn-overlap); nothing compared this task's subject against work already open" >&2 else # The subject is the task id plus the first line of the brief's task - # statement - the same words the worker is being sent to act on. - OVERLAP_SUBJECT="$ID $(awk '/^# Task[[:space:]]*$/ {intask = 1; next} intask && NF {print; exit}' "$BRIEF" 2>/dev/null | cut -c 1-300)" + # statement - the same words the worker is being sent to act on. A brief + # that passes the existence check but cannot be read, or that carries no + # task statement, must not quietly shrink the subject to the bare id and + # let an id-only comparison report none: the gap is surfaced as an + # unavailable row like every other unreadable source. + OVERLAP_SUBJECT="$ID" + OVERLAP_SUBJECT_GAP= + if OVERLAP_TASK_LINE=$(awk '/^# Task[[:space:]]*$/ {intask = 1; next} intask && NF {print; exit}' "$BRIEF") \ + && [ -n "$OVERLAP_TASK_LINE" ]; then + OVERLAP_SUBJECT="$ID ${OVERLAP_TASK_LINE:0:300}" + else + OVERLAP_SUBJECT_GAP="the brief at $BRIEF could not be read or names no task statement, so only the task id was compared" + fi # Strongest evidence first, so the reader meets the likeliest duplicate at # the top rather than somewhere down a list. The sort is on the matcher's # score column, which is dropped again immediately: it orders the set and # never bounds it, so nothing is hidden from the reader or the gate. - OVERLAP_ROWS=$(overlap_candidates "$ID" "$PROJ_ABS" \ + OVERLAP_ROWS=$({ + [ -z "$OVERLAP_SUBJECT_GAP" ] || printf 'unavailable\tsubject\t%s\t\n' "$OVERLAP_SUBJECT_GAP" + overlap_candidates "$ID" "$PROJ_ABS" + } \ | overlap_match "$OVERLAP_SUBJECT" "${FM_SPAWN_OVERLAP_MIN_TOKENS:-2}" "${FM_SPAWN_OVERLAP_MIN_PERCENT:-50}" \ | LC_ALL=C sort -t"$(printf '\t')" -k1,1nr -k2,2 -k3,3 \ | cut -f2-) diff --git a/docs/configuration.md b/docs/configuration.md index 21074c1ef12..39e25e4f045 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,7 +137,9 @@ Candidates are listed strongest evidence first, and the list is ordered rather t Code emits candidates and never decides equivalence: whether two prose descriptions are the same work is firstmate's judgment, made from a surfaced set. A source that cannot be read yields `overlap=unavailable` and never `overlap=none`, so an incomplete set is never mistaken for a clean one. -A pull request listing whose forge-reported open total exceeds `FM_SPAWN_OVERLAP_PR_LIMIT` fails the same way, because a truncated listing is an incomplete set rather than the open set. +A brief that cannot be read, or that names no task statement to compare, is a gap in the subject itself and surfaces as an `unavailable` row the same way, so a degraded subject can never report a clean `overlap=none`. +The pull request listing must account for the forge's whole reported open total: `FM_SPAWN_OVERLAP_PR_LIMIT` is only the initial window, the reader re-lists exactly once with a window covering a reported total above it, and a listing still short of that total fails the same way, because an incomplete listing is not the open set. +A reported total above `FM_PR_LIST_CEILING` fails instead of re-listing, so a pathological forge cannot grow the window without bound. The result is recorded in the task's own metadata as `overlap=`, plus `overlap_refs=` when candidates exist and `overlap_ack=` when an acknowledgement was given, which keeps "nothing was compared" and "compared, found nothing" distinguishable afterwards. The local, gitignored `config/spawn-overlap` file selects the posture and is inherited by secondmate homes. @@ -145,7 +147,7 @@ Absent or `advisory` prints and records the result and never refuses. `enforce` additionally requires `bin/fm-spawn.sh --overlap-ack <ref>[,<ref>...]` to name every surfaced ref before the dispatch proceeds; the refusal prints the exact value to reuse. `off` skips the scan and says so. Any other value refuses the spawn, because a safety knob that cannot be read must never be treated as an absent one. -`FM_SPAWN_OVERLAP_PR_LIMIT` bounds the pull requests listed per spawn (default 600), `FM_SPAWN_OVERLAP_MIN_TOKENS` sets the shared-token floor (default 2), and `FM_SPAWN_OVERLAP_MIN_PERCENT` sets the coverage threshold (default 50). +`FM_SPAWN_OVERLAP_PR_LIMIT` sets the initial pull request window per spawn (default 600), `FM_PR_LIST_CEILING` caps the self-sized re-listing (default 5000), `FM_SPAWN_OVERLAP_MIN_TOKENS` sets the shared-token floor (default 2), and `FM_SPAWN_OVERLAP_MIN_PERCENT` sets the coverage threshold (default 50). ## Gate defaults (.no-mistakes.yaml) @@ -539,7 +541,8 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops FM_BACKEND_CMUX_COMPOSER_LINES=20 # cmux-only: tail lines scanned to locate the composer row for submit verification FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer placeholder regex after border/prompt stripping CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) -FM_SPAWN_OVERLAP_PR_LIMIT=600 # open pull requests listed per spawn by the duplicate-work overlap scan; a forge total above it reads as unavailable, never as none +FM_SPAWN_OVERLAP_PR_LIMIT=600 # initial pull request window per spawn for the duplicate-work overlap scan; the reader re-lists once to cover a larger forge-reported total, and a listing still short of the total reads as unavailable, never as none +FM_PR_LIST_CEILING=5000 # upper bound on the self-sized pull request re-listing; a forge-reported open total above it reads as unavailable rather than growing the window without bound FM_SPAWN_OVERLAP_MIN_TOKENS=2 # shared normalised tokens required before a candidate is surfaced as an overlap FM_SPAWN_OVERLAP_MIN_PERCENT=50 # percent of the smaller vocabulary those shared tokens must cover FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh diff --git a/tests/fm-spawn-overlap.test.sh b/tests/fm-spawn-overlap.test.sh index 4f824c243fb..e242c381a6e 100755 --- a/tests/fm-spawn-overlap.test.sh +++ b/tests/fm-spawn-overlap.test.sh @@ -30,8 +30,14 @@ UNRELATED_TASK=posix-launcher-platform-surface # --- fixtures --------------------------------------------------------------- # A gh-axi whose `pr list` prints the TOON block the real one prints, or fails -# the way an unreachable or unauthenticated forge fails. FM_FAKE_GH_FAIL -# selects the outage. +# the way an unreachable or unauthenticated forge fails. It honours --limit and +# keeps its count header truthful the way the real tool does: "count: N of M +# total" when the listing is bounded below the open total, a bare "count: N" +# when it is complete. FM_FAKE_GH_FAIL selects the outage, FM_FAKE_GH_TOTAL +# sets the reported open total (default 2), FM_FAKE_GH_ROWS starves the forge +# so it never delivers more than that many rows however large the window, and +# FM_FAKE_GH_CALLS appends each call's limit to a file so a test can count the +# listings behind one answer. make_fake_gh() { local fakebin=$1 cat > "$fakebin/gh-axi" <<'SH' @@ -42,14 +48,34 @@ if [ "${1:-}" = pr ] && [ "${2:-}" = list ]; then echo "error: could not reach github.com: dial tcp: lookup github.com: no such host" >&2 exit 1 fi - cat <<'TOON' -count: 2 of 523 total -pull_requests[2]{number,title,state,author,draft,review}: - 1614,"fix(bin): refuse merges without verified green checks",open,someone,no,none - 1885,"Make filed work reach the captain by name, not just the record",open,other,no,none -help[1]: - Run `gh-axi pr view <number>` to view details -TOON + limit=30 + prev= + for a in "$@"; do + [ "$prev" = --limit ] && limit=$a + prev=$a + done + [ -z "${FM_FAKE_GH_CALLS:-}" ] || echo "$limit" >> "$FM_FAKE_GH_CALLS" + total=${FM_FAKE_GH_TOTAL:-2} + avail=${FM_FAKE_GH_ROWS:-$total} + n=$avail + [ "$limit" -lt "$n" ] && n=$limit + if [ "$n" -lt "$total" ]; then + echo "count: $n of $total total" + else + echo "count: $n" + fi + echo "pull_requests[$n]{number,title,state,author,draft,review}:" + i=1 + while [ "$i" -le "$n" ]; do + case $i in + 1) echo ' 1614,"fix(bin): refuse merges without verified green checks",open,someone,no,none' ;; + 2) echo ' 1885,"Make filed work reach the captain by name, not just the record",open,other,no,none' ;; + *) echo " $((1885 + i)),\"unrelated filler request $i\",open,other,no,none" ;; + esac + i=$((i + 1)) + done + echo 'help[1]:' + echo ' Run `gh-axi pr view <number>` to view details' exit 0 fi exit 0 @@ -207,11 +233,13 @@ test_truncated_pr_listing_is_unavailable_not_none() { rec=$(make_case pr-window "$UNRELATED_TASK") read_case_record "$rec" - # The fake gh-axi's count header reports 523 open in total, so a two-row - # window is a bounded look at a larger open set. The unrelated task is - # deliberate: with a window covering the whole total this same fixture - # reports overlap=none, so the only thing under test is the truncation. - out=$(FM_SPAWN_OVERLAP_PR_LIMIT=2 run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$UNRELATED_TASK" "$PROJ_DIR") + # A starved forge: it reports 523 open in total but never delivers more than + # two rows, so even the self-sized re-list comes back short of the total. + # The unrelated task is deliberate: with a forge that delivers its whole + # total this same fixture reports overlap=none, so the only thing under test + # is the shortfall. + out=$(FM_FAKE_GH_TOTAL=523 FM_FAKE_GH_ROWS=2 FM_SPAWN_OVERLAP_PR_LIMIT=2 \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$UNRELATED_TASK" "$PROJ_DIR") status=$? expect_code 0 "$status" "advisory mode must not refuse on a truncated listing" assert_contains "$out" "overlap=unavailable" "a truncated pull request listing was not reported as unavailable" @@ -225,6 +253,34 @@ test_truncated_pr_listing_is_unavailable_not_none() { pass "a pull request listing smaller than the forge's open total yields overlap=unavailable and never overlap=none" } +test_unreadable_brief_is_unavailable_not_none() { + local rec out status meta + rec=$(make_case unreadable-brief "$NEW_TASK") + read_case_record "$rec" + if [ "$(id -u)" = 0 ]; then + pass "skipped: running as root ignores the unreadable-brief permission bits" + return 0 + fi + # The brief passes the existence check but cannot be read, so the subject + # would silently shrink to the bare task id. A degraded subject must surface + # as a gap, never as a clean overlap=none computed from id tokens alone. + chmod 000 "$HOME_DIR/data/$NEW_TASK/brief.md" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$NEW_TASK" "$PROJ_DIR") + status=$? + chmod 644 "$HOME_DIR/data/$NEW_TASK/brief.md" + expect_code 0 "$status" "advisory mode must not refuse on an unreadable brief" + assert_contains "$out" "overlap=unavailable" "an unreadable brief was not surfaced as a gap in the subject" + assert_not_contains "$out" "overlap=none" "a subject degraded to the bare id must never read as a checked-and-empty set" + assert_contains "$out" "unavailable,subject," "the degraded subject was not named as the unavailable source" + assert_contains "$out" "$NEW_TASK/brief.md" "the reason did not name the brief that could not be read" + + meta="$HOME_DIR/state/$NEW_TASK.meta" + assert_grep "overlap=unavailable" "$meta" "the spawn record does not preserve the degraded subject" + assert_no_grep "overlap=none" "$meta" "the spawn record downgraded a degraded subject to a pass" + pass "a brief that exists but cannot be read yields overlap=unavailable and never overlap=none" +} + test_missing_forge_cli_is_unavailable_not_none() { local rec out fakebin rec=$(make_case forge-missing "$UNRELATED_TASK") @@ -474,19 +530,66 @@ test_forge_reader_separates_no_rows_from_no_answer() { assert_contains "$out" "err=" "an unreachable forge reported no reason" assert_not_contains "$out" "1614" "a failed listing must yield no rows at all" - # A window smaller than the forge's reported open total must fail the call, - # naming both, and yield no rows: a truncated listing is not the open set. - out=$(PATH="$fakebin:$PATH" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 2; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") - assert_contains "$out" "rc=1" "a truncated listing returned success" - assert_contains "$out" "523" "the truncation reason did not name the forge's open total" - assert_not_contains "$out" "1614" "a truncated listing must yield no rows at all" - pass "the shared forge reader separates 'no open requests' from 'no answer' and from 'a bounded window'" + # A listing that accounts for less than the forge's reported open total must + # fail the call even when the total sits inside the window: "count: 2 of 523 + # total" in a 600-row window is a shortfall, not the open set. + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_TOTAL=523 FM_FAKE_GH_ROWS=2 bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 600; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") + assert_contains "$out" "rc=1" "a listing short of the reported open total returned success" + assert_contains "$out" "523" "the shortfall reason did not name the forge's open total" + assert_not_contains "$out" "1614" "a short listing must yield no rows at all" + pass "the shared forge reader separates 'no open requests' from 'no answer' and from 'a listing short of the open total'" +} + +test_forge_reader_window_self_sizes() { + local dir fakebin calls out status + dir="$TMP_ROOT/forge-window" + fakebin=$(make_fakebin "$dir/fake") + mkdir -p "$dir/repo" + calls="$dir/calls" + + # A total within the window is answered by one listing. + : > "$calls" + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_CALLS="$calls" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 600' _ "$ROOT" "$dir/repo") + status=$? + expect_code 0 "$status" "a total within the window must pass" + assert_contains "$out" "1614" "a within-window listing dropped its rows" + [ "$(grep -c . "$calls")" -eq 1 ] || fail "a total within the window must be answered by one listing, saw limits: $(tr '\n' ' ' < "$calls")" + + # A total above the window re-lists exactly once with a window covering it, + # and the complete second listing passes with every row. + : > "$calls" + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_CALLS="$calls" bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 1' _ "$ROOT" "$dir/repo") + status=$? + expect_code 0 "$status" "a total above the window must self-size and pass" + assert_contains "$out" "1614" "the re-listed window dropped the first row" + assert_contains "$out" "1885" "the re-listed window did not cover the whole open total" + [ "$(grep -c . "$calls")" -eq 2 ] || fail "a total above the window must be answered by exactly two listings, saw limits: $(tr '\n' ' ' < "$calls")" + + # A re-list that still comes up short fails, naming the total and what was + # listed, and never loops beyond its one re-list. + : > "$calls" + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_CALLS="$calls" FM_FAKE_GH_TOTAL=523 FM_FAKE_GH_ROWS=2 bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 2; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") + assert_contains "$out" "rc=1" "a starved re-list returned success" + assert_contains "$out" "523" "the shortfall reason did not name the forge's open total" + assert_not_contains "$out" "1614" "a short listing must yield no rows at all" + [ "$(grep -c . "$calls")" -eq 2 ] || fail "the re-list must run exactly once, saw limits: $(tr '\n' ' ' < "$calls")" + + # A reported total above the ceiling fails with the reason instead of + # re-listing at all. + : > "$calls" + out=$(PATH="$fakebin:$PATH" FM_FAKE_GH_CALLS="$calls" FM_PR_LIST_CEILING=300 FM_FAKE_GH_TOTAL=400 FM_FAKE_GH_ROWS=2 bash -c '. "$1/bin/fm-pr-lib.sh"; fm_pr_open_request_titles "$2" 2; echo "rc=$?"; echo "err=$FM_PR_LIST_ERROR"' _ "$ROOT" "$dir/repo") + assert_contains "$out" "rc=1" "a total above the ceiling returned success" + assert_contains "$out" "400" "the ceiling reason did not name the reported total" + assert_contains "$out" "FM_PR_LIST_CEILING" "the ceiling reason did not name the knob that raises it" + [ "$(grep -c . "$calls")" -eq 1 ] || fail "a total above the ceiling must never be re-listed, saw limits: $(tr '\n' ' ' < "$calls")" + pass "the forge reader self-sizes its window once, bounded by the ceiling, and fails a listing still short of the total" } test_replay_surfaces_the_recorded_duplicate test_unrelated_task_surfaces_no_overlap test_forge_outage_is_unavailable_not_none test_truncated_pr_listing_is_unavailable_not_none +test_unreadable_brief_is_unavailable_not_none test_missing_forge_cli_is_unavailable_not_none test_enforce_requires_acknowledgement_and_records_it test_enforce_refuses_an_incomplete_set_without_acknowledgement @@ -499,5 +602,6 @@ test_a_branch_merely_ending_with_the_task_id_stays_visible test_a_long_subject_keeps_short_branches_and_drops_coincidences test_strongest_evidence_is_listed_first test_forge_reader_separates_no_rows_from_no_answer +test_forge_reader_window_self_sizes echo "# all fm-spawn-overlap tests passed" From c8aa00b52dfece2dbaad34eccda9449082deeb70 Mon Sep 17 00:00:00 2001 From: Shane Bracewell <sbracewell64@gmail.com> Date: Sat, 8 Aug 2026 19:47:35 -0400 Subject: [PATCH 4/5] no-mistakes(document): refresh fm-pr-lib inventory row for open-PR listing role --- docs/scripts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scripts.md b/docs/scripts.md index 0cc65147590..a49b62a88da 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -99,7 +99,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | | `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | -| `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication and identity-bound retirement | +| `fm-pr-lib.sh` | Own canonical task and PR validation, the fail-loud open pull request listing, private atomic PR-poll publication, and identity-bound retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | From 5bc2bb22c033c394823e783ecf1b51430f227548 Mon Sep 17 00:00:00 2001 From: Shane Bracewell <sbracewell64@gmail.com> Date: Sat, 8 Aug 2026 19:54:53 -0400 Subject: [PATCH 5/5] no-mistakes(document): fix overlap comment drift: measurement window and ratio denominator --- bin/fm-spawn.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 55ddbeb5215..840b0b08a6a 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1664,14 +1664,15 @@ OVERLAP_STOPWORDS='the and for are was were has have had been being this that wi # matching. # # A candidate must clear BOTH a floor and a ratio, and the ratio is what makes -# this usable. Measured against a real 140-item backlog, 254 branches, and 95 -# open pull requests, a bare two-token floor surfaced 61 candidates for one -# task: in a fleet whose work all shares a house vocabulary, "two words in -# common" is met by almost everything, and a set nobody can read is a set -# nobody reads. Requiring a candidate to cover half of the SUBJECT's own -# tokens is scale-free - it stays satisfied by the short ids of the recorded -# incident, where two shared tokens are two thirds of the subject, and drops -# the coincidental matches, whose share is a quarter or less. +# this usable. Measured against a real 140-item backlog, 254 branches, and the +# newest 95 of 534 open pull requests, a bare two-token floor surfaced 61 +# candidates for one task: in a fleet whose work all shares a house +# vocabulary, "two words in common" is met by almost everything, and a set +# nobody can read is a set nobody reads. Requiring the shared tokens to cover +# half of the SMALLER of the two token vocabularies is scale-free - it stays +# satisfied by the short ids of the recorded incident, where the two shared +# tokens are half of the smaller vocabulary, and drops the coincidental +# matches, whose share is a quarter or less. overlap_match() { # <subject-text> <min-tokens> <min-percent>; candidate rows on stdin awk -v subject="$1" -v min="$2" -v pct="$3" -v stop=" $OVERLAP_STOPWORDS " ' function norm(w) {