Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/bootstrap-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then
When any diagnostic needs captain attention, report the plain consequence and requested action using `AGENTS.md` section 9's captain-facing translation contract; do not name the diagnostic label unless the captain needs to paste it into a command or issue.

- `MISSING: <tool> (install: <command>)` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install <approved tools...>`.
For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request.
For `treehouse`, this also covers an installed version missing any of the pool-claim lease flags (`get --lease`, `get --lease-holder`, `return --if-lease-holder`); treat it as an upgrade request.
For `no-mistakes`, this also covers an installed version older than 1.46.0, because this repo's PR gate requires structured pipeline attestation that older builds do not write.
For essential axi-family tools - `gh-axi`, `tasks-axi`, `quota-axi` - an installed version below its floor is a plain upgrade request; [`bin/fm-bootstrap.sh`](../../../bin/fm-bootstrap.sh) owns the floor policy, and never argue the floor down to whatever the home happens to have installed.
For `tasks-axi`, this additionally covers an installed build that fails the separate feature probe (`bin/fm-tasks-axi-lib.sh` owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report.
Expand Down
3 changes: 2 additions & 1 deletion bin/backends/cmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
# (herdr-shape): `workspace list`'s `current_directory` field reflects a
# `cd` run directly in the surface's own top-level shell, but stays
# frozen at wherever that shell was when it launched a foreground
# subshell (exactly what `treehouse get` does) - verified live: a nested
# subshell (exactly what the spawn-time `(cd -- '<wt>' && exec $SHELL)`
# does) - verified live: a nested
# `bash -c 'cd /Users && exec bash'` left `current_directory` reporting
# the PARENT shell's last cwd, never following into the subshell. Fixed
# with zellij's own pwd-marker-probe workaround, reused verbatim in
Expand Down
16 changes: 10 additions & 6 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2081,8 +2081,10 @@ fm_backend_herdr_explicit_close_pane_confirmed() { # <session> <pane_id>
# shell - every foreground process is a recognized shell AND no
# descendant of the pane shell is a verified harness: positive
# proof the pane is shell-only. The descendant walk is what makes
# this safe for the crew shape, where a nested `treehouse get`
# shell sits under the pane's top shell.
# this safe for the crew shape, where the nested worktree
# shell fm-spawn.sh starts with
# `(cd -- '<wt>' && exec $SHELL)` sits under the pane's top
# shell.
# other - the foreground group holds something that is neither: a tool
# the agent is running in its own process group, a pager, a
# stranger's process. Not a shell-only pane. An idle shell
Expand Down Expand Up @@ -2933,15 +2935,16 @@ fm_backend_herdr_target_ready() { # <target>

# fm_backend_herdr_current_path: the live FOREGROUND process's cwd, or empty on
# any error. Mirrors tmux's pane_current_path poll used for worktree-path
# discovery after `treehouse get`.
# discovery after the pane enters its worktree.
#
# Verified pitfall: `pane get`'s `.result.pane.cwd` is the pane's cwd AT
# CREATION TIME - the top-level shell's cwd - and does NOT update when that
# shell `cd`s or enters a subshell (as `treehouse get` does). Reading it here
# shell `cd`s or enters a subshell (as fm-spawn.sh's
# `(cd -- '<wt>' && exec $SHELL)` does). Reading it here
# would make fm-spawn.sh's worktree-discovery poll never see the pane "leave"
# the project directory, since `cwd` stays frozen at the original path forever.
# `.result.pane.foreground_cwd` tracks the ACTUALLY RUNNING foreground
# process's cwd instead, which is what changes when `treehouse get` enters its
# process's cwd instead, which is what changes when that command enters its
# worktree subshell - confirmed live against a real treehouse acquisition.
fm_backend_herdr_current_path() { # <target>
fm_backend_herdr_target_ready "$1" || return 0
Expand All @@ -2951,7 +2954,8 @@ fm_backend_herdr_current_path() { # <target>

# fm_backend_herdr_send_text_line: send one line of TEXT then submit,
# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed
# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types
# spawn-time commands (the worktree-entry subshell, the GOTMPDIR export).
# `pane run` types
# the command and submits it in one call (verified).
fm_backend_herdr_send_text_line() { # <target> <text>
fm_backend_herdr_target_ready "$1" || return 1
Expand Down
8 changes: 5 additions & 3 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
# default (tmux, `backend=` absent) path stays byte-identical. Sourced only
# through bin/fm-backend.sh's fm_backend_source, never directly.
#
# Worktree acquisition (running `treehouse get` inside the pane, and polling
# its cwd) is unchanged by this extraction: P1 scopes only the session
# Worktree acquisition (fm-spawn.sh takes the pool's lease itself, sends
# `(cd -- '<wt>' && exec $SHELL)` into the pane, and polls its cwd) is
# unchanged by this extraction: P1 scopes only the session
# provider, not the worktree provider, so fm-spawn.sh still drives that part
# inline with these same send/current-path primitives.
#
Expand Down Expand Up @@ -115,7 +116,8 @@ fm_backend_tmux_current_path() { # <target>

# fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no
# composer verification - used for the fixed spawn-time commands
# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence
# (the `(cd -- '<wt>' && exec $SHELL)` worktree entry, the GOTMPDIR export)
# that already ran this exact sequence
# inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "<text>" Enter`.
fm_backend_tmux_send_text_line() { # <target> <text>
tmux send-keys -t "$1" "$2" Enter
Expand Down
10 changes: 5 additions & 5 deletions bin/backends/zellij.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
# 4. `list-panes --json`'s `pane_cwd` reflects a `cd` run DIRECTLY in the
# pane's own top-level shell within one poll (<0.3s) - but does NOT
# reflect a `cd` performed by a NESTED SUBSHELL the pane's shell
# launched as a foreground command (verified: `treehouse get` opens
# exactly such a subshell). `pane_cwd` stays frozen at wherever the
# launched as a foreground command (verified: the spawn-time
# `(cd -- '<wt>' && exec $SHELL)` opens exactly such a subshell). `pane_cwd` stays frozen at wherever the
# pane's shell was when it invoked that foreground command - worse than
# herdr's frozen-cwd trap (herdr at least exposes a `foreground_cwd`
# that tracks this; zellij's CLI exposes no live-process cwd field and
Expand Down Expand Up @@ -388,13 +388,13 @@ fm_backend_zellij_target_ready() { # <target> [expected-label]

# fm_backend_zellij_current_path: the live pane's cwd, or empty on any error.
# Mirrors tmux's pane_current_path poll used for worktree-path discovery after
# `treehouse get`.
# the pane enters its worktree.
#
# Verified pitfall (docs/zellij-backend.md "Worktree-path discovery: pane_cwd
# does not track a subshell"): `list-panes --json`'s `pane_cwd` DOES reflect a
# `cd` run directly in the pane's own top-level shell, but stays FROZEN at
# whatever directory the pane's shell was in when it launched `treehouse get`
# as a foreground command - it never follows that command's own internal `cd`
# whatever directory the pane's shell was in when it launched the
# worktree-entry command as a foreground command - it never follows that command's own internal `cd`
# into the acquired worktree, even after the subshell is fully interactive and
# a `pwd` typed into it prints the correct live path on screen. Zellij's CLI
# exposes no per-pane pid and no live-process cwd field to read instead
Expand Down
44 changes: 26 additions & 18 deletions bin/fm-backlog-transition-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,12 @@ fm_backlog_transition_applies() { # <config-dir> <data-dir> <kind>
return 0
}

# Run `tasks-axi` with an optional FM_TASKS_AXI_TIMEOUT bound. A caller that
# holds a lock across the call - the spawn commit and its preservation
# read-back run under the per-task meta lock - sets the bound, so an
# unresponsive tasks-axi cannot hold that lock open indefinitely; a timed-out
# Run a command with an optional timeout bound. Every caller that holds a lock
# across an external call uses this one runner: the spawn commit and its
# preservation read-back under the per-task meta lock (FM_TASKS_AXI_TIMEOUT),
# and the pool allocation under the shared Treehouse project lock
# (FM_TREEHOUSE_GET_TIMEOUT), so an unresponsive tool cannot hold a lock open
# indefinitely; a timed-out
# call exits 124, or 137 when the kill-after had to fire (GNU timeout's own
# status for a KILL-forced expiry), and the callers treat either as the bound
# expiring and report the timeout as the reason through their existing error
Expand All @@ -328,34 +330,35 @@ fm_backlog_transition_applies() { # <config-dir> <data-dir> <kind>
# elsewhere (a stock macOS host has perl but no timeout variant; perl is
# already a hard dependency of this library's byte validators, so the
# fallback adds no new tool). Every bounded path forces termination: a
# tasks-axi that ignores SIGTERM must not outlive the bound, since an
# command that ignores SIGTERM must not outlive the bound, since an
# unbounded call under the lock is exactly the hang the bound exists to
# prevent - so the GNU variants carry a kill-after of one further bound
# (TERM at the bound, KILL after that grace) and the watchdog kills the
# same way. When a bound was requested but no bounding mechanism exists at
# all, the call fails closed instead of running unbounded. Must be the last
# command of a subshell: the exec keeps the tasks-axi process exactly where
# command of a subshell: the exec keeps the bounded process exactly where
# the plain call sat, and the bound kills the child, not the caller.
fm_tasks_axi_timeout_expired() { # <status>
fm_run_bounded_timed_out() { # <status>
case $1 in
124 | 137) return 0 ;;
esac
return 1
}

fm_tasks_axi() {
local bound=${FM_TASKS_AXI_TIMEOUT:-}
fm_run_bounded() { # <bound-seconds-or-empty> <command> [arg...]
local bound=$1
shift
if [ -z "$bound" ]; then
exec tasks-axi "$@"
exec "$@"
fi
if command -v timeout >/dev/null 2>&1; then
exec timeout -k "$bound" "$bound" tasks-axi "$@"
exec timeout -k "$bound" "$bound" "$@"
elif command -v gtimeout >/dev/null 2>&1; then
exec gtimeout -k "$bound" "$bound" tasks-axi "$@"
exec gtimeout -k "$bound" "$bound" "$@"
elif command -v perl >/dev/null 2>&1; then
# Fork, run tasks-axi in the child, and poll waitpid(WNOHANG) until the
# Fork, run the command in the child, and poll waitpid(WNOHANG) until the
# child exits or the bound expires: the same contract as
# `timeout $bound tasks-axi ...`. Expiry kills the child with TERM, waits
# `timeout $bound <command> ...`. Expiry kills the child with TERM, waits
# one further bound of grace, then KILL, and exits 124 so the callers'
# timeout plumbing reports it. Polling rather than alarm+die keeps the
# bound off perl's platform-dependent syscall-restart signal semantics.
Expand Down Expand Up @@ -387,12 +390,17 @@ fm_tasks_axi() {
select undef, undef, undef, $step;
$elapsed += $step;
}
' -- "$bound" tasks-axi "$@"
' -- "$bound" "$@"
fi
printf 'fm_tasks_axi: cannot bound tasks-axi within %ss: none of timeout, gtimeout, or perl is available\n' "$bound" >&2
printf 'fm_run_bounded: cannot bound %s within %ss: none of timeout, gtimeout, or perl is available\n' "$1" "$bound" >&2
exit 127
}

# The tasks-axi-shaped wrapper the backlog paths call.
fm_tasks_axi() {
fm_run_bounded "${FM_TASKS_AXI_TIMEOUT:-}" tasks-axi "$@"
}

# Print one row's `tasks-axi show` output (plus stderr) from the addressing
# fm_backlog_tasks_axi_addressing resolved, with `--file` only for the markdown
# backend. Addressing or backend-resolution errors return before tasks-axi runs;
Expand Down Expand Up @@ -496,7 +504,7 @@ fm_backlog_row_probe() { # <data-dir> <id>
else
FM_BACKLOG_ROW_ERROR=$(printf '%s\n' "$out" | sed -n '1p')
if [ -z "$FM_BACKLOG_ROW_ERROR" ]; then
if fm_tasks_axi_timeout_expired "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then
if fm_run_bounded_timed_out "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then
FM_BACKLOG_ROW_ERROR="tasks-axi show $id did not finish within ${FM_TASKS_AXI_TIMEOUT}s"
else
FM_BACKLOG_ROW_ERROR="tasks-axi show $id failed with no output"
Expand Down Expand Up @@ -549,7 +557,7 @@ fm_backlog_mutate() { # <data-dir> <verb> <id> [flag...]
[ "$command_status" -ne 0 ] || return 0
FM_BACKLOG_TRANSITION_ERROR=$(printf '%s\n' "$out" | sed -n '1p')
if [ -z "$FM_BACKLOG_TRANSITION_ERROR" ]; then
if fm_tasks_axi_timeout_expired "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then
if fm_run_bounded_timed_out "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then
FM_BACKLOG_TRANSITION_ERROR="tasks-axi $verb $id did not finish within ${FM_TASKS_AXI_TIMEOUT}s"
else
FM_BACKLOG_TRANSITION_ERROR="tasks-axi $verb $id failed with no output"
Expand Down
13 changes: 12 additions & 1 deletion bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -938,8 +938,19 @@ NO_MISTAKES_MIN=1.46.0
GH_AXI_MIN=0.1.29
LAVISH_AXI_MIN=0.1.46

treehouse_advertises_flag() { # <subcommand> <flag>
treehouse "$1" --help 2>&1 |
grep -Eq "(^|[^[:alnum:]_-])$2([^[:alnum:]_-]|$)"
}

# Both halves of a task's pool claim, not just the half that takes it: the spawn
# leases the slot with `get --lease --lease-holder` and every release path -
# aborted spawn, operator recovery - matches on `return --if-lease-holder`, so a
# build carrying only the first leaves claims nothing can release.
treehouse_supports_lease() {
treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)'
treehouse_advertises_flag get --lease &&
treehouse_advertises_flag get --lease-holder &&
treehouse_advertises_flag return --if-lease-holder
}

# Shared semantic-version floor for the tool gates below. A version string that
Expand Down
20 changes: 11 additions & 9 deletions bin/fm-install-treehouse.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
# Usage:
# fm-install-treehouse.sh <destination-directory>
#
# Pins Treehouse v2.0.1, the version exercised by the local real-Herdr suite.
# Pins Treehouse v2.1.0, the version exercised by the local real-Herdr suite and
# the floor for the pool claim: `get --lease --lease-holder` and the
# holder-matched `return --if-lease-holder` release bin/fm-bootstrap.sh gates on.
set -eu

FM_TREEHOUSE_CI_VERSION=2.0.1
FM_TREEHOUSE_CI_VERSION=2.1.0
FM_TREEHOUSE_CI_TAG="v${FM_TREEHOUSE_CI_VERSION}"
# Bounded download ceiling (bytes). Official 2.0.1 archives are under 8 MiB.
# Bounded download ceiling (bytes). Official 2.1.0 archives are under 8 MiB.
FM_TREEHOUSE_CI_MAX_BYTES=15000000
FM_TREEHOUSE_CI_REPO=kunchenguid/treehouse

Expand All @@ -30,19 +32,19 @@ arch=$(uname -m)
case "${os}-${arch}" in
Linux-x86_64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-linux-amd64.tar.gz
SHA256=1d5a32751ab921670103fd201ddb2b91b47338cb13976f45642b827cf8976af2
SHA256=ff030255663bb5d384309cdf1b3a0bd62006e0ac978340d292039697cb70c225
;;
Linux-aarch64|Linux-arm64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-linux-arm64.tar.gz
SHA256=eaccc9c5b98125df8bd77425598eeecee66cb0371db4eb1cf75f0d813c18fab9
SHA256=d4e37ad11d50fb6381c060d34e7372e944e01af78b0c6707c6d0ff966c7c2c44
;;
Darwin-arm64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-darwin-arm64.tar.gz
SHA256=7ee5078f3d1f33c01196548797fce65408e459d53530b77d4ba56e074fa1c1a2
SHA256=a2e9bcba88d643a828062f3425dcf601886372fc2aca87b542b36fc8bbf2c992
;;
Darwin-x86_64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-darwin-amd64.tar.gz
SHA256=1cf44580a5837f995e1d3bb74f4fbd3112b642acd20406087d9735a8106112fd
SHA256=8eb2eb3b63f409a316bd9c19af5d3035e5b72f6feffc98f0fac658e1ed33a397
;;
*)
die "unsupported platform ${os}-${arch}; official Treehouse assets are linux/darwin amd64 and arm64"
Expand All @@ -69,7 +71,7 @@ fi
[ "$ACTUAL_SHA256" = "$SHA256" ] || die "checksum mismatch for $ARCHIVE (expected $SHA256, got $ACTUAL_SHA256)"

tar -xzf "$TMP/$ARCHIVE" -C "$TMP"
# Archive layout: a single `treehouse` binary at the archive root (verified for v2.0.1).
# Archive layout: a single `treehouse` binary at the archive root (verified for v2.1.0).
if [ -f "$TMP/treehouse" ]; then
BIN="$TMP/treehouse"
elif [ -f "$TMP/treehouse-v${FM_TREEHOUSE_CI_VERSION}/treehouse" ]; then
Expand All @@ -83,7 +85,7 @@ mkdir -p "$DESTINATION"
install -m 0755 "$BIN" "$DESTINATION/treehouse"

installed_version=$("$DESTINATION/treehouse" --version 2>/dev/null | tr -d '[:space:]')
# treehouse prints "v2.0.1" (leading v) on --version.
# treehouse prints "v2.1.0" (leading v) on --version.
case "$installed_version" in
"v${FM_TREEHOUSE_CI_VERSION}"|"${FM_TREEHOUSE_CI_VERSION}") ;;
*)
Expand Down
Loading
Loading