Speed up worker teardown: seven fixes from the slowness investigation - #78
Open
timbarreto wants to merge 9 commits into
Open
timbarreto wants to merge 9 commits into
timbarreto wants to merge 9 commits into
Conversation
added 9 commits
September 30, 2026 17:18
On hosts that use the pure-Bash bound, a quick command whose output was captured could wait out its whole bound: the sleeper watchdog sometimes missed its stop signal and kept the capture pipe open. On Bash 4+ with a plain decimal bound the watchdog is now a coprocess with detached stdio that waits inside read on a release channel, so no sleeper is left to signal, and an orphaned watchdog still bounds the command to its deadline and clears its scratch files. Other bounds keep the sleeper watchdog, now with detached stdio. Monitor mode is dropped right after the forks so a finished command's job notice cannot leak into a capturing caller.
Teardown's backlog gate already settles tasks-axi compatibility in-process, but the fm-captain-hold.sh open child it runs to check for an open captain call re-ran the three compatibility probes. On a Windows/Git Bash host those probes cost several seconds per teardown. Pass the verdict through the existing consumed one-hop FM_TASKS_AXI_COMPATIBLE handoff owned by bin/fm-tasks-axi-lib.sh. Nothing is persisted and tool-availability failures are unchanged. The new teardown test counts every tasks-axi call and requires exactly one compatibility probe; it fails against the previous teardown with two.
Every teardown ended by synchronously refreshing the home summary, which rebuilds the whole fleet snapshot and repeats fleet-wide work after each cleanup. That wait dominated the completion tail on Windows/Git Bash. Teardown now records a coalesced request through fm-home-summary-refresh.sh --request --service --best-effort. --service starts one detached pending-only publisher (FM_HOME_SUMMARY_IF_PENDING=1) that serves the request and any burst behind it. It does nothing when no request is pending or the home has been retired, so it never recreates a removed home. The watcher and session start still service requests as before, so publication stays eventual even when the background publisher cannot start. A failed background publication keeps the request retryable and records its error in state/.home-summary-refresh.log, so the failure stays visible. Tests cover the coalescing, the one-shot background service, failure retention and logging, the retired-home no-op, and the --service flag contract. The fork-remote teardown test now waits for the background publication before checking the published summary.
A guard call that prints the full stale-supervision banner detected its own harness twice: once inside fm_watcher_supervision_verdict to choose the supervision model, and again when fm-supervision-instructions.sh rendered the repair line without --harness. On this Windows/Git Bash host one detection costs about 7-13 seconds, so the repair line alone took about 17.7 seconds with detection versus about 3.0 seconds with --harness, and teardown pays the guard more than once. fm_supervision_own_harness now records the detected identity and its status in non-exported variables of the calling shell. The verdict detects once, exposes the validated harness as FM_WATCHER_VERDICT_HARNESS, and fm_supervision_model --detected reuses that result. fm-guard passes --harness only when the verdict validated one, so an override model or a failed detection keeps today's behavior, where the instructions detect for themselves. The identity lives for one verdict call and is never exported or cached across processes, so the supervision model is unchanged. The new guard test counts harness detections through a stub and fails on the previous code with two detections.
Teardown and its pool-slot scan read each metadata field through a command substitution, which costs a fork per read; on a Windows/Git Bash host 100 such reads took about 37 seconds against under one second in-process. Add fm_meta_read, which loads a record's fields into named variables in one pass, and destination forms of fm_meta_get and fm_backend_of_meta, then use them on teardown's hot path and in the slot scan, which now reads a record's worktree and home together. Canonical-directory and live-slot lookups write into destination variables for the same reason. Every refusal, ownership, and landed-work check reads the same fields with the same values as before; only the way each value reaches its variable changes.
Every non-scout cleanup ran a full fetch and fast-forward of its project's clone after the worker was already gone, so a batch of cleanups paid one clone refresh each, serially, on their return path. Add fm-fleet-sync.sh --request, which records a request file, starts one detached server, and returns at once. The server serializes on a service lock, keeps at most one waiter, and serves each snapshot of request files once per distinct project, so a cleanup batch for one project costs one refresh. Each project refresh is bounded, output is appended to a bounded state/.fleet-sync.log, and any skipped:, STUCK:, or recovered: line that session start would relay raises one check wake naming that log, so the diagnostics a synchronous call printed still reach firstmate. A path that is not a directory, a missing state directory, or a request that cannot be recorded falls back to the foreground single-project sync, so clone freshness never depends on the server, and session start's full refresh stays the backstop. The request form skips the guard the cleanup already ran. Direct invocations and every other caller are unchanged.
The fleet snapshot producer feeds the home summary that teardown republishes, and a producer trace showed per-task command substitutions and pipelines dominating its runtime on hosts where a fork costs 0.1-0.3s. - status_log_scan reads the latest non-blank event line and, only when metadata carries no pr=, the first PR display URL in one in-shell pass, replacing the grep|tail and grep -Eo|head pipelines. - Backend, target, verb, note, and open-decision reads assign through destination variables (fm_backend_target_of_meta, status_open_decisions, _fm_status_kind) instead of $(...). - basename and head -1 become parameter expansions. - fm_afk_contract_path gains a destination form, so fm_afk_contract_present (reached per task through merge-authority resolution) no longer forks. Stdout forms and return codes of every changed helper are unchanged, and already-batched reads and fm_meta_read stay as they were. A partial summary is still never emitted as success. Equivalence: an 8-record fixture home (two PRs on one line, an Azure DevOps URL with a query, CRLF and no final newline, blank and whitespace-only lines, meta pr=, orca/herdr/tmux targets, a scout report, a missing status log) produced identical --json, --secondmate-home-summary, and --contribution-input output from base and this change after normalizing home paths and timestamps. Full --json on that fixture took 64.6s at base and 32.3s here, and the home summary 56.8s and 28.7s, on the same loaded host.
The fleet-snapshot producer change added a status-log scan test to tests/fm-fleet-snapshot-view.test.sh, and the stock macOS Bash 3.2 job pins the number of passing tests in that suite.
The herdr prerequisite fallback re-sourced bin/fm-wake-lib.sh under a directed source, so ShellCheck's external-source traversal expanded the whole wake-lib graph a second time inside the bin/fm-teardown.sh root, which already expands it once through its top-level directed source. That duplicate pushed the root past CI's per-root analysis memory bound once the teardown fixes grew the shared libraries. Mark the fallback source=/dev/null with the same analysis-boundary rationale bin/fm-lease-lib.sh and bin/fm-task-inbox-lib.sh already use for their lazy wake-lib fallbacks. Runtime sourcing is unchanged and wake-lib is still analyzed once in this root and as its own lint root. Local peak working set for the root drops from about 8336 MiB to about 7977 MiB, below the pre-branch 8167 MiB.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the seven suggested fixes from the slow worker cleanup investigation, one commit per fix, smallest first in the report's numbering.
Teardown on this Windows/Git Bash host ran a long synchronous chain of safety and bookkeeping steps, each spawning many processes, plus fleet-wide work repeated per teardown.
These changes remove repeated work and forks from that chain and move fleet-wide follow-up work off the return path.
They do not remove or reorder any safety step.
fm-teardown: request the home summary instead of awaiting itfm-home-summary-refresh.sh --request --service --best-effort. One detached pending-only publisher serves the request and any burst behind it, no-ops for an empty queue or a retired home, keeps a failed request retryable, and logs the error tostate/.home-summary-refresh.log. The watcher and session start still service requests, so publication stays eventual.fm-teardown: hand the tasks-axi verdict to the decision readfm-captain-hold.sh openchild gets teardown's already-settled verdict through the existing consumed one-hopFM_TASKS_AXI_COMPATIBLEhandoff instead of re-probing. Nothing is persisted, and tool-availability failures are unchanged.fm-teardown: read task metadata in-processfm_meta_read(one pass into named variables) and destination forms offm_meta_getandfm_backend_of_meta, used on teardown's hot path and in the pool-slot scan. Every refusal, ownership, and landed-work check reads the same fields with the same values.fm-timeout-lib: stop the bash watchdog from holding captured outputfm-guard: reuse the verdict's detected harness for the repair line--harnessto the repair line only when the verdict validated one. The identity is never exported or cached across processes, so the supervision model is unchanged.fm-fleet-snapshot: drop per-task forks from the producerfm_meta_readare kept, and a partial summary is still never emitted as success.fm-teardown: request the clone refresh instead of awaiting itfm-fleet-sync.sh --request. It records a request file, starts one detached serialized server, and returns. The server coalesces a batch to one refresh per project, bounds each refresh, logs tostate/.fleet-sync.log, and raises one check wake for anyskipped:,STUCK:, orrecovered:line. A non-directory path, missing state directory, or unrecordable request falls back to today's foreground sync, and session start's full refresh stays the freshness backstop.Safety invariants kept
--forceor bypass was added.Measurements
The isolated lab fixture follows the report's method:
fm-herdr-lab.sh(provision, run, teardown, withLAB_CLEANUP_VERIFIEDand the default-session tripwire passing every series);FM_HOMEunder the worktree;fm-tasks-axi.sh;kind=ship,mode=direct-PR,harness=copilot,backend=herdrrecord bound to the real lab pane;noproj), or a real lab-local clone with a bare origin (proj);treehousestubbed to refuse andno-mistakesremoved fromPATH.The timer encloses only
bash bin/fm-teardown.sh <id>."Pane gone" is when a 0.3s
pane getpoller first sees the lab pane disappear."Background settled" is when the after-change background publisher and clone-refresh server finished; base has no background work, so its value equals its total.
Base is
011c291fand after is this branch's HEAD, both as clean clones, interleaved per round so host-load drift hits both.Every run returned 0, removed the task record, and left the backlog row Done.
The report's reduced run took 146.4s at base on a less loaded host; this host was slower throughout, so only paired base/after comparisons are meaningful.
The host was heavily loaded by an unrelated test suite during the first series, which ended part-way through, so compare base and after within a round.
noproj, series 1 (seconds):noproj, series 2, after the unrelated load ended (seconds):Series 2 medians: total return 168.2s base versus 128.4s after (about 24% less), and pane gone 122.3s versus 94.1s (about 23% less).
Across all six
noprojpairs the median after/base total ratio is about 0.70.The return path after the pane closed (total minus pane gone) went from 46-114s at base to 20-43s after, mainly because the home summary is requested rather than rebuilt synchronously.
In the
noprojvariant the project path is not a directory, so fix 7 deliberately falls back to the foreground single-project sync, exactly like base; theprojvariant shows fix 7.proj(real clone; fix 7 applies) (seconds):The Herdr pane-close span itself (presentation lock, session and workspace reads, close, confirmation) is unchanged code and ranged from about 25s to 85s with load.
Per-fix measurements on the same host:
fm-fleet-snapshot.sh --jsonwent from 64.6s to 32.3s and--secondmate-home-summaryfrom 56.8s to 28.7s, with identical normalized output from base and after for--json,--secondmate-home-summary, and--contribution-input.Tests
New or updated colocated tests:
tests/fm-timeout-lib.test.sh: a captured quick command returns long before the bound (stdout, stderr, and plain invocation), hung commands are still bounded, fractional bounds with functions, and an orphaned watchdog still bounds the command and leaves no scratch files.tests/fm-teardown.test.sh: teardown makes exactly one tasks-axi compatibility probe (fails on base with two); the fork-remote case waits for the background summary publication.tests/fm-home-summary-request.test.sh: coalescing, the one-shot background service, failure retention and logging, the retired-home no-op, and the--serviceflag contract.tests/fm-guard-stale-banner.test.sh: the full banner detects its own harness once (fails on base with two).tests/fm-backend.test.shandtests/fm-teardown-endpoint-safety.test.sh:fm_meta_read, destination forms,fm_backend_target_of_meta, and a vanished neighbour in the sole-slot case.tests/fm-fleet-sync.test.sh: request coalescing while a refresh runs, actionable results relayed as one check wake, foreground fallback without a state directory, an immediate skip for a missing directory, and usage errors.tests/fm-fleet-snapshot-view.test.sh,tests/fm-classify-decision-key.test.sh,tests/fm-afk-contract.test.sh: status-log scan, decision-key destination forms, and AFK contract path and presence helpers.bin/fm-lint.sh(shellcheck) is clean on every changed script and test.Results on this host (Windows, Git Bash 5.3, symlink cases with
MSYS=winsymlinks:nativestrict):git archive HEADsnapshot: the sixfm-teardown-endpoint-safetyslot and endpoint refusal cases plusfm-teardownbacklog close, tasks-axi verdict hand-off, and local-only fork remote all passed (9/9).fm-home-summary-request10/10,fm-backend31/31,fm-fleet-snapshot-view20/20 (with a raised crew-state timeout, see below), andfm-afk-contract,fm-classify-decision-key,fm-classify-corr-token, andfm-wake-drain-open-decisions-cursorall pass.fm-fleet-sync33/34 andfm-timeout-libnew captured-output cases pass; the remaining failures are the pre-existing host-timing cases below.Pre-existing host failures (identical on base)
These fail on this Windows/Git Bash host both at
011c291fand on this branch:fm-backendslot cases,fm-wake-drain-open-decisionstest_status_symlink_is_not_followed, the endpoint-safety slot cases) needMSYS=winsymlinks:nativestrict; without itln -scopies.fm-fleet-synctest_bootstrap_relays_recovered_and_stucktimes out at its 20s bootstrap bound.fm-session-startruntime-bound truncation case,fm-home-summary-refreshinitial publication deadline, andfm-procevent-quotaprovider exhaustion, all host-timing.fm-fleet-snapshot-viewtest_fixture_snapshot_jsonneedsFM_SNAPSHOT_CREW_STATE_TIMEOUTabove its 10s default under load.Follow-ups noticed (not changed here)
home=path (for example.../worktree/../worktree) with a missing worktree; base behaves the same, so this branch keeps it.jq -nper row; batching those is the next producer win.fm-fleet-sync.sh --requestskips the guard the cleanup already ran; bootstrap's full sync remains the freshness backstop.CI notes
6dbab795updates the macOS snapshot count in.github/workflows/ci.ymlfrom 19 to 20 for the new snapshot test case.2350f3ackeeps the teardown lint root under the ShellCheck memory cap.bin/fm-teardown.shre-sourcedbin/fm-wake-lib.shwith a directed source inside its herdr prerequisite fallback even though the same file already sources it at top level, so ShellCheck expanded that library (and its timeout and classify dependencies) twice.The fallback now uses the existing
# shellcheck source=/dev/nullanalysis-boundary idiom fromfm-lease-lib.shandfm-task-inbox-lib.sh; the library is still analyzed once in this root and as its own lint root.Local peak ShellCheck RSS for the teardown root: base
011c291f8,167 MiB, branch before this commit 8,336 MiB, branch after 7,977 MiB.Upstream main's Lint 1 currently fails the same way on this root.
tests/fm-pi-branch-extension.test.sh,tests/fm-calm-pi-extension.test.sh) come from CI installing Pi unpinned: the base's last green run used Pi 0.87.1 and this branch's run got Pi 0.99.2.Upstream fixed them in fix: restore portable CI behavior across Pi rendering and remote provisioning kunchenguid/firstmate#6162, which this fork does not have yet; no file in this PR touches Pi rendering.