diff --git a/AGENTS.md b/AGENTS.md index 123a83a7c45..360c8307c63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ config/checkout-refresh optional extra checkout and shallow scan-root directive config/worktree-provision task-worktree dependency provisioning switch; LOCAL, gitignored; absent or "on" provisions each acquired worktree's declared project dependencies before launch, "off" disables it for this home; see docs/configuration.md "Worktree provisioning" config/secondmate-harness PRIMARY launch ` [] []`; LOCAL, gitignored, fallback config/crew-harness then firstmate, not inherited (section 4) config/account-routing-mode `off|observe|enforce`; direct account directories for new observe/enforce launches, legacy recovery for existing managed metadata; LOCAL, gitignored, default off, inherited (docs/configuration.md "Agent Fleet account routing") +config/provision/.json optional per-project worktree provisioning manifest that overrides declaration-driven detection for that project; LOCAL, gitignored; absent uses the declaration-driven provisioner; see docs/configuration.md "Worktree provisioning" config/secondmate-account-pool optional Agent Fleet pool the PRIMARY uses for SECONDMATE launches when routing is enabled; LOCAL, gitignored; selection-only and NOT inherited Direct account-directory launch covers ship/scout crewmates and secondmate launches; a secondmate binds only the selected account, never the ship/scout worktree-identity contract. 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) @@ -398,11 +399,12 @@ bin/fm-spawn.sh --resume-account # sticky legacy managed recove bin/fm-spawn.sh --continue-account # fresh legacy managed session from verified task-owned continuation state bin/fm-spawn.sh projects/ --backend # explicit new-task runtime backend (docs/configuration.md "Runtime backend") bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta +bin/fm-spawn.sh projects/ --provision|--no-provision # force or skip worktree provisioning for this spawn bin/fm-spawn.sh [] --secondmate # launch a persistent secondmate in its home bin/fm-spawn.sh =projects/ =projects/ [--scout] # batch: one call, several tasks ``` -Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, `--backend`, `--account-pool`, `--account-profile`, and `--no-account-routing` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero. +Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, `--backend`, `--account-pool`, `--account-profile`, `--no-account-routing`, and `--provision`/`--no-provision` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero. When `config/crew-dispatch.json` exists, include an explicit resolved harness for every crewmate or scout spawn or batch after consulting the dispatch rules (section 4). `bin/fm-spawn.sh`'s header owns harness and runtime-backend resolution, spawn-capable backends and `codex-app` rejection, launch templates, delivery-mode resolution, recorded meta fields, and turn-end hooks. A backend spawn refusal - a missing dependency, an unauthenticated socket, or a version gate - must be surfaced to the captain as a blocker; never silently retry the spawn on a different backend to work around it. @@ -414,6 +416,7 @@ An unignored install directory is a pre-installer FAILURE; an unignored `.fm-pro A successful non-zero `uv pip check` NOTE records `:=installed+` or `:=cached+`; `inconsistent-dependency-metadata` means the check found inconsistency, while `unverified-dependency-metadata` means it did not run and must never be phrased as a finding. Never convert a capability limit into a spawn refusal: route it through `fm_provision_gap`; `docs/configuration.md` "Worktree provisioning" points operators to the exhaustive header-owned set instead of owning a synchronized copy. A provisioning refusal is a blocker to surface, not something to work around by retrying with `--no-provision`: launching anyway produces a lane that cannot prove its own work. +An optional `config/provision/.json` overrides declaration-driven detection for one project with explicit runtime checks, probes, and failure policy; `bin/fm-provision.sh` owns its readiness and exit contracts, and `docs/configuration.md` "Worktree provisioning" owns the schema and precedence. For `kind=secondmate`, it launches in the registered or explicit firstmate home with the charter brief as the launch prompt, after the guarded home sync and inheritable-config propagation owned by `secondmate-provisioning`. Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch. For a genuinely new ship or scout task, `bin/fm-spawn.sh` asserts an In flight or Queued backlog row before endpoint creation. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 211882a91a2..ed4e6b14fb0 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3530,7 +3530,7 @@ fm_backend_herdr_wait_transition() { # /dev/null || true return 2 fi - fm_backend_herdr_scrubbed_exec "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null & + fm_backend_herdr_scrubbed_exec exec "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null & reader_pid=$! if ! exec 9< "$fifo"; then kill "$reader_pid" 2>/dev/null || true diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 3154dc16d21..dfec74b689e 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -258,7 +258,7 @@ fm_afk_launch_namespace_guard_release() { return "$result" } -fm_afk_launch_namespace_guard_acquire() { +fm_afk_launch_namespace_guard_acquire() { # 0 acquired, 2 busy local ready response helper_status=0 [ -z "$FM_AFK_LAUNCH_NAMESPACE_GUARD_PID" ] \ && [ "$FM_AFK_LAUNCH_NAMESPACE_GUARD_HELD" -eq 0 ] || return 1 @@ -296,7 +296,11 @@ if not stat.S_ISREG(held.st_mode) or not stat.S_ISREG(named.st_mode): raise RuntimeError("namespace guard is not a regular file") if (held.st_dev, held.st_ino) != (named.st_dev, named.st_ino): raise RuntimeError("namespace guard pathname changed before flock") -fcntl.flock(17, fcntl.LOCK_EX) +try: + fcntl.flock(17, fcntl.LOCK_EX | fcntl.LOCK_NB) +except BlockingIOError: + os.write(1, b"busy\n") + raise SystemExit(75) confirmed = os.lstat(sys.argv[1]) if (held.st_dev, held.st_ino) != (confirmed.st_dev, confirmed.st_ino): raise RuntimeError("namespace guard pathname changed while acquiring flock") @@ -315,7 +319,7 @@ finally: ' "$FM_AFK_LAUNCH_NAMESPACE_GUARD" "$FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR" >&18 18>&- \ 2> "$FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR/error" & FM_AFK_LAUNCH_NAMESPACE_GUARD_PID=$! - if ! IFS= read -r -t 5 -u 18 response || [ "$response" != ready ]; then + if ! IFS= read -r -t 5 -u 18 response; then fm_afk_launch_namespace_guard_release >/dev/null 2>&1 || true return 1 fi @@ -324,6 +328,11 @@ finally: { exec 18>&-; } 2>/dev/null || true rm -rf "$FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR" 2>/dev/null || helper_status=1 FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR= + if [ "$response" = busy ] && [ "$helper_status" -eq 75 ]; then + { exec 17>&-; } 2>/dev/null || true + return 2 + fi + [ "$response" = ready ] || helper_status=1 if [ "$helper_status" -ne 0 ]; then { exec 17>&-; } 2>/dev/null || true return 1 @@ -590,7 +599,14 @@ fm_afk_launch_lock_acquire() { FM_AFK_LAUNCH_LOCK_INCOMPLETE=0 FM_AFK_LAUNCH_LOCK_LAST_IDENTITY= for i in $(seq 1 200); do - fm_afk_launch_namespace_guard_acquire || return 1 + fm_afk_launch_namespace_guard_acquire + result=$? + if [ "$result" -eq 2 ]; then + sleep 0.05 + continue + elif [ "$result" -ne 0 ]; then + return 1 + fi fm_afk_launch_lock_try_guarded "$i" "$ownerless_grace" result=$? fm_afk_launch_namespace_guard_release || return 1 @@ -618,7 +634,7 @@ fm_afk_launch_lock_release_guarded() { } fm_afk_launch_lock_release() { - local result=0 + local i acquire_result result=0 # EXIT/TERM may land after publication but before the guarded attempt returns. # In that case the current shell already owns the sibling guard; reacquiring # it would self-deadlock, so clean the exact token while fd 17 still retains @@ -628,7 +644,13 @@ fm_afk_launch_lock_release() { fm_afk_launch_namespace_guard_release || result=1 return "$result" fi - fm_afk_launch_namespace_guard_acquire || return 1 + for i in $(seq 1 200); do + fm_afk_launch_namespace_guard_acquire + acquire_result=$? + [ "$acquire_result" -ne 2 ] && break + sleep 0.05 + done + [ "$acquire_result" -eq 0 ] || return 1 fm_afk_launch_lock_release_guarded || result=1 fm_afk_launch_namespace_guard_release || result=1 return "$result" diff --git a/bin/fm-gate-refuse-lib.sh b/bin/fm-gate-refuse-lib.sh index d01fd33ec45..ce4564bb9e6 100644 --- a/bin/fm-gate-refuse-lib.sh +++ b/bin/fm-gate-refuse-lib.sh @@ -59,6 +59,7 @@ # fm-checkout-refresh.sh, fm-ensure-agents-md.sh, fm-fleet-sync.sh, # fm-home-seed.sh, fm-lock.sh, # fm-crosscheck.sh, fm-merge-local.sh, fm-pr-check.sh, fm-pr-merge.sh, fm-promote.sh, +# fm-provision.sh, # fm-report-retention.sh, fm-report-stack.mjs, fm-review-diff.sh, fm-send.sh, # fm-session-start.sh, fm-spawn.sh, fm-supervise-daemon.sh, # fm-task-file-append.mjs, fm-teardown.sh, fm-update.sh, fm-wake-drain.sh, @@ -84,9 +85,9 @@ # and fm-x-lib.sh. # Excluded pure helpers are not entrypoints: backends/herdr-eventwait.py, # fm-contained-read.cjs, fm-contained-read.py, fm-file-transaction.cjs, -# fm-markdown-structure.cjs, and fm-prompt-exec.py carry no fleet dispatch of -# their own and are reached only through the entrypoints and libraries above, -# so they inherit the caller's refusal. +# fm-launch-pinned.sh, fm-markdown-structure.cjs, and fm-prompt-exec.py carry no +# fleet dispatch of their own and are reached only through the entrypoints and +# libraries above, so they inherit the caller's refusal. # fm-herdr-lab.sh is excluded because it accepts only isolated fm-lab-* sessions # and protects the live default session with its own tripwire. # fm-install-shellcheck.sh and fm-lint.sh are excluded developer verification diff --git a/bin/fm-launch-pinned.sh b/bin/fm-launch-pinned.sh new file mode 100755 index 00000000000..f6ab36108b3 --- /dev/null +++ b/bin/fm-launch-pinned.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# fm-launch-pinned.sh - start a crewmate's launch command with a project's proven +# runtime pin ahead of PATH, WITHOUT that pin ever reaching the PATH that +# resolved the command. +# +# Usage: +# fm-launch-pinned.sh [NAME=VALUE ...] [arg ...] +# +# WHY THIS EXISTS +# Worktree provisioning can prove a project's runtime and hand back the +# directory that must lead PATH for the project's own tools. Putting that +# directory on the PATH the pane shell uses to evaluate firstmate's typed +# launch line would let a manifest decide which binary EVERY bare word in that +# line means: the harness name, a wrapper, that wrapper's target, an +# interpreter carrying a continuation prompt. Those were found and pinned one +# word at a time across five review rounds, which is a search that does not +# converge - the next launch shape adds the next word. +# +# This closes it as a class instead. The crewmate PATH firstmate exports +# carries no manifest-supplied entry, so the whole launch line resolves from +# firstmate's own resolution order whatever words it happens to contain, and +# the pin is applied HERE, after the command has been resolved, to the +# environment the agent and every one of its children inherit. Moving WHERE the +# pin applies is the point: the project's own tools still resolve it first. +# +# CONTRACT +# - is a colon-joined list of directories, already validated by +# fm-provision.sh (each exists and carries no space, quote, or colon). +# - Leading NAME=VALUE arguments are the launch line's own environment prefix. +# They are consumed here rather than by the pane shell so that a launch line +# can be handed over whole, and they are applied to the command only. +# - The command word is resolved with `type -P`, a PATH-only lookup, against +# the PATH this process INHERITED - the un-pinned crewmate PATH. A shell +# function or alias of the same name cannot answer for it, and neither can +# the pin, which is not exported until after the lookup. +# - A command word that already carries a slash is not PATH-resolved at all. +# - An unresolvable command exits 127 with a reason, the same way a shell +# reports a command it cannot find, rather than launching something else. +set -u + +usage() { + sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//' +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac + +die() { + printf 'fm-launch-pinned: %s\n' "$*" >&2 + exit 2 +} + +[ "$#" -ge 2 ] || die "usage: fm-launch-pinned.sh [NAME=VALUE ...] [arg ...]" + +PREPEND=$1 +shift +[ -n "$PREPEND" ] || die "the path prefix to apply is empty" + +ASSIGNMENTS=() +while [ "$#" -gt 0 ]; do + case "$1" in + [A-Za-z_]*=*) + ASSIGNMENTS[${#ASSIGNMENTS[@]}]=$1 + shift + ;; + *) break ;; + esac +done + +[ "$#" -gt 0 ] || die "no command to run after the environment prefix" + +TARGET=$1 +shift + +case "$TARGET" in + */*) RESOLVED=$TARGET ;; + *) RESOLVED=$(type -P -- "$TARGET" 2>/dev/null) || RESOLVED= ;; +esac + +if [ -z "$RESOLVED" ] || [ ! -x "$RESOLVED" ]; then + printf 'fm-launch-pinned: %s\n' \ + "'$TARGET' is not an executable on this PATH, so there is nothing to launch" >&2 + exit 127 +fi + +# Only now, with the command already resolved, does the manifest's directory +# reach PATH. Everything below inherits it; nothing above it ever saw it. +PATH="$PREPEND:$PATH" +export PATH + +exec /usr/bin/env ${ASSIGNMENTS[@]+"${ASSIGNMENTS[@]}"} "$RESOLVED" "$@" diff --git a/bin/fm-provision.sh b/bin/fm-provision.sh new file mode 100755 index 00000000000..b8ca0fa34e1 --- /dev/null +++ b/bin/fm-provision.sh @@ -0,0 +1,1082 @@ +#!/usr/bin/env bash +# fm-provision.sh - bring a leased task worktree to a proven-ready state before +# an agent is launched into it, so a crewmate can validate its OWN work. +# +# Usage: +# fm-provision.sh [--task ] [--kind ] +# [--force] [--manifest ] [--quiet] +# fm-provision.sh --manifest-path print the resolved manifest path +# --force overrides the manifest's kind gate and rebuilds every component +# instead of reusing a matching fingerprint. +# +# WHY THIS EXISTS +# A Treehouse lease delivers a clean Git worktree and nothing else. The +# environments validation actually needs - a project's virtualenv, its +# node_modules, the interpreter and runtime those were built for - are +# gitignored, so a fresh lease never carries them, and Treehouse exposes no +# setup hook. Host installs and repo pins (.nvmrc, engines, lockfiles) describe +# the desired state but never create or activate it inside a lease. An agent +# that starts in an unprovisioned worktree cannot run the project's checks, so +# it either borrows evidence from a second agent in a different worktree or +# reports work it never verified. This script closes that gap at the one seam +# where the worktree is known and no agent is running yet. +# +# WHAT IT KNOWS +# Nothing project-specific. All project knowledge lives in a per-project JSON +# manifest resolved from $FM_HOME/config/provision/.json, which is +# local and gitignored exactly like the other config/ knobs. No manifest means +# this is a no-op, so every project and every home that has not opted in is +# unaffected and pays only one file-existence check. docs/configuration.md +# "Worktree provisioning" owns the manifest schema; that section and +# docs/examples/provision-relvino.json are the places to read it. +# +# WHAT IT GUARANTEES +# - A merely existing environment directory is never assumed healthy. Reuse +# requires BOTH a matching fingerprint AND passing probes; probes run on +# every invocation, including a fingerprint hit. +# - Every list the manifest declares is read whole, and proved whole, before +# any of it runs: the record count is reconciled against the length jq +# reports for that same list, and a list that is not an array is refused +# rather than iterated into nothing. A malformed field can therefore make a +# run FAIL, but it can never make it quietly do less than it claims. +# - No manifest step is ever run while the list it came from is still being +# read, and every step runs with stdin on /dev/null, so a step that reads +# stdin cannot consume the records that drive the run. A step that genuinely +# needs input redirects it itself, e.g. ["sh","-c","cmd < file"]. +# - A fingerprint whose inputs cannot be read, or whose version commands fail +# or print nothing, is unavailable rather than empty, and an unavailable +# fingerprint forces a rebuild. No verdict is ever derived from an empty +# computed value. +# - Runtime checks run BEFORE install, so a component is never built under the +# wrong runtime. This is what stops npm delegating a native build to whatever +# node happens to be first on PATH. +# - A fingerprint file lives inside the tree it describes (for example under +# .venv/), so it cannot outlive that tree. This script never creates that +# parent directory just to record a fingerprint. +# - A fingerprint version command must be independent of the thing it +# fingerprints. The value is recomputed after a build, and a fingerprint is +# recorded only when both values are non-empty and equal. A changed value is +# reported instead of silently recording a digest that can never match and +# rebuilding on every lease. +# (`uv python find 3.11` is exactly this trap: it resolves the project's own +# .venv once one exists. Use `uv python find --system 3.11`.) +# - Every step is bounded by its local limit and the whole-run budget, and +# every reset is bounded by the whole-run budget. The whole child process +# group stays supervised through completion or TERM-then-KILL escalation, +# including when its leader exits before a background descendant. +# - Manifest paths are contained: existing ancestors are resolved, symlinks +# are refused, and component paths must stay physically inside the worktree. +# Reset and fingerprint paths must be strict descendants of their component, +# so no normalized reset can ever target the component root itself. +# +# FAILURE POLICY +# The manifest's on_failure decides, and the DEFAULT IS "warn": a provisioning +# failure is loud and durable but does not block the spawn. Provisioning +# depends on package registries, and spawn is the fleet's availability-critical +# path; a readiness improvement must not become a single point of failure for +# dispatching work at all. The defect being fixed is silence, not the absence +# of a toolchain, so a failure that firstmate sees in-band at dispatch and that +# the crewmate is told about in its brief already removes the silence. Projects +# where unverifiable work is worse than no work set "on_failure": "block". +# +# EXIT CODES +# 0 ready, or skipped because nothing applied (no manifest, or kind excluded) +# 2 usage error +# 3 provisioning failed and the policy is warn (caller should continue loudly) +# 4 provisioning failed and the policy is block (caller must abort the spawn) +# An ABSENT on_failure still defaults to "warn". But a manifest that cannot be +# read or parsed, or whose on_failure is present and is neither "warn" nor +# "block", fails under BLOCK and exits 4, because a policy that cannot be read +# is exactly the ambiguity that must not fail open. Only the one project whose +# manifest is malformed is affected; a project with no manifest is untouched. +# docs/configuration.md "Worktree provisioning" owns this contract. +# +# OUTPUT +# stdout: exactly one compact JSON verdict (schema fm-provision.v1). +# stderr: human progress and failure reasons. +# With --task , an applicable run also writes the verdict to +# $FM_STATE/.provision and the full step log to +# $FM_STATE/.provision.log, so a failure stays diagnosable long after the +# spawn output scrolled away. A skipped run creates neither artifact. +# A step's captured value - what "expect" compares against, and what a +# fingerprint version command contributes - is the last non-empty line the step +# printed on STANDARD OUTPUT, trimmed. Standard error is logged for diagnosis +# but is never part of the value, because the tools these manifests call +# routinely trail their answer with an unrelated notice on stderr (npm's update +# banner, uv's warnings), and merging the two streams let that notice decide +# both an expect comparison and a fingerprint input. Commands that trail their +# value with chatter on stdout still need a wrapper that prints only the value. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# A reset directive removes a whole environment tree, so a confused gate agent +# reaching for this against a shared worktree is destructive, not merely noisy. +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +fm_refuse_if_gate_agent + +usage() { + sed -n '2,108p' "$0" | sed 's/^# \{0,1\}//' +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac + +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" + +DEFAULT_STEP_TIMEOUT=600 +DEFAULT_TOTAL_TIMEOUT=1800 + +die_usage() { + printf 'fm-provision: %s\n' "$*" >&2 + exit 2 +} + +QUIET=0 + +note() { + [ "$QUIET" = 1 ] || printf 'fm-provision: %s\n' "$*" >&2 +} + +LOG_FILE= + +log_line() { + [ -n "$LOG_FILE" ] || return 0 + printf '%s\n' "$*" >> "$LOG_FILE" +} + +# --- arguments -------------------------------------------------------------- + +PROJECT= +WORKTREE= +TASK_ID= +KIND=ship +FORCE=0 +MANIFEST= + +manifest_for_project() { + local project=$1 name + name=$(basename "$project") + [ -n "$name" ] || return 1 + printf '%s/provision/%s.json\n' "$CONFIG" "$name" +} + +if [ "${1:-}" = "--manifest-path" ]; then + [ -n "${2:-}" ] || die_usage "--manifest-path needs a project directory" + manifest_for_project "$2" || die_usage "cannot derive a project name from '$2'" + exit 0 +fi + +while [ "$#" -gt 0 ]; do + case "$1" in + --task) TASK_ID=${2:-}; [ -n "$TASK_ID" ] || die_usage "--task needs a task id"; shift 2 ;; + --kind) KIND=${2:-}; [ -n "$KIND" ] || die_usage "--kind needs a value"; shift 2 ;; + --manifest) MANIFEST=${2:-}; [ -n "$MANIFEST" ] || die_usage "--manifest needs a file"; shift 2 ;; + --force) FORCE=1; shift ;; + --quiet) QUIET=1; shift ;; + -*) die_usage "unknown flag: $1" ;; + *) + if [ -z "$PROJECT" ]; then PROJECT=$1 + elif [ -z "$WORKTREE" ]; then WORKTREE=$1 + else die_usage "unexpected argument: $1" + fi + shift ;; + esac +done + +[ -n "$PROJECT" ] || die_usage "a project directory is required" +[ -n "$WORKTREE" ] || die_usage "a worktree directory is required" +case "$TASK_ID" in + *[!A-Za-z0-9._-]*) die_usage "task id must be [A-Za-z0-9._-]" ;; +esac + +if [ -z "$MANIFEST" ]; then + MANIFEST=$(manifest_for_project "$PROJECT") || die_usage "cannot derive a manifest path" +fi +if [ ! -f "$MANIFEST" ]; then + printf '{"schema":"fm-provision.v1","status":"skipped","reason":"no provisioning manifest","path_prepend":"","components":[]}\n' + exit 0 +fi + +PROJECT_REAL=$(cd "$PROJECT" 2>/dev/null && pwd -P) || die_usage "project directory is unreadable: $PROJECT" +WORKTREE_REAL=$(cd "$WORKTREE" 2>/dev/null && pwd -P) || die_usage "worktree directory is unreadable: $WORKTREE" +PROJECT_NAME=$(basename "$PROJECT_REAL") +[ -n "$PROJECT_NAME" ] || die_usage "cannot derive a project name from '$PROJECT'" + +RECORD_FILE= +if [ -n "$TASK_ID" ] && [ -d "$STATE" ]; then + RECORD_FILE="$STATE/$TASK_ID.provision" +fi + +# --- verdict emission ------------------------------------------------------- + +COMPONENT_RECORDS= +WORK_DIR= + +# shellcheck disable=SC2329 +cleanup() { + [ -z "$COMPONENT_RECORDS" ] || /bin/rm -f "$COMPONENT_RECORDS" + [ -z "$WORK_DIR" ] || /bin/rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +# Until the manifest's own on_failure has been read and validated the policy is +# unknown, and an unknown policy must not fail open, so every failure emitted +# before that point carries block. Reading a valid on_failure is what relaxes it. +POLICY=block +PATH_PREPEND= + +activate_artifacts() { + [ -z "$LOG_FILE" ] || return 0 + [ -n "$RECORD_FILE" ] || return 0 + LOG_FILE="$STATE/$TASK_ID.provision.log" + : > "$LOG_FILE" 2>/dev/null || LOG_FILE= +} + +init_work_area() { + COMPONENT_RECORDS=$(mktemp "${TMPDIR:-/tmp}/fm-provision-components.XXXXXX") || return 1 + WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-provision.XXXXXX") || return 1 +} + +# emit : print the one JSON verdict, mirror it to the task +# record, and exit with the code that status maps to. A non-ready verdict never +# publishes path_prepend, so a caller cannot inherit a runtime pin from a run +# that did not finish proving it. +emit() { + local status=$1 reason=$2 verdict code=0 components='[]' + [ "$status" = skipped ] || activate_artifacts + if [ -n "$COMPONENT_RECORDS" ] && [ -s "$COMPONENT_RECORDS" ]; then + components=$(jq -c -s '.' "$COMPONENT_RECORDS" 2>/dev/null) || components='[]' + [ -n "$components" ] || components='[]' + fi + [ "$status" = ready ] || PATH_PREPEND= + verdict=$(jq -c -n \ + --arg status "$status" \ + --arg reason "$reason" \ + --arg policy "$POLICY" \ + --arg project "$PROJECT_NAME" \ + --arg worktree "$WORKTREE_REAL" \ + --arg manifest "$MANIFEST" \ + --arg task "$TASK_ID" \ + --arg log "$LOG_FILE" \ + --arg path_prepend "$PATH_PREPEND" \ + --argjson components "$components" \ + '{schema:"fm-provision.v1",status:$status,reason:$reason,policy:$policy,project:$project,worktree:$worktree,manifest:$manifest,task:$task,log:$log,path_prepend:$path_prepend,components:$components}' \ + 2>/dev/null) + [ -n "$verdict" ] || verdict="{\"schema\":\"fm-provision.v1\",\"status\":\"failed\",\"reason\":\"verdict could not be encoded\",\"policy\":\"$POLICY\",\"path_prepend\":\"\",\"components\":[]}" + printf '%s\n' "$verdict" + if [ "$status" != skipped ] && [ -n "$RECORD_FILE" ]; then + printf '%s\n' "$verdict" > "$RECORD_FILE" 2>/dev/null || true + fi + log_line "verdict: $verdict" + case "$status" in + ready|skipped) code=0 ;; + *) if [ "$POLICY" = block ]; then code=4; else code=3; fi ;; + esac + exit "$code" +} + +record_component() { + local name=$1 result=$2 detail=$3 + jq -c -n --arg name "$name" --arg result "$result" --arg detail "$detail" \ + '{name:$name,result:$result,detail:$detail}' >> "$COMPONENT_RECORDS" 2>/dev/null || true +} + +# --- manifest --------------------------------------------------------------- + +if ! command -v jq >/dev/null 2>&1; then + emit failed "jq is required to read the provisioning manifest" +fi + +MANIFEST_JSON=$(jq -c '.' "$MANIFEST" 2>/dev/null) || emit failed "manifest is not valid JSON: $MANIFEST" +[ -n "$MANIFEST_JSON" ] || emit failed "manifest parsed to nothing: $MANIFEST" + +mq() { + printf '%s' "$MANIFEST_JSON" | jq -r "$1" 2>/dev/null +} + +# manifest_records : fill MANIFEST_RECORDS with one +# record per element of the declared list, and prove the list was read whole. +# +# This is the single boundary every manifest list crosses, and it is where two +# silent failures are turned into loud ones. jq's `length` is defined for values +# that cannot be iterated (a string "abc" has length 3, the number 7 has length +# 7), so trusting a length alone let a non-array `components` report work it +# never did: iteration failed, the loop body never ran, and the run still +# emitted ready. And a stream that stops early - a jq error partway through, a +# truncated pipe - used to end the loop indistinguishably from a complete read. +# A list that is absent is an empty list; a list that is present and is not an +# array, or that yields fewer records than it declared, fails. +# +# Reading the whole list up front is also what keeps a step from consuming the +# records that drive it: nothing is executed while this pipe is open. Callers +# copy MANIFEST_RECORDS into their own array before acting on it, because a +# nested read replaces it. +MANIFEST_RECORDS=() +manifest_records() { + local json=$1 expr=$2 declared record + MANIFEST_RECORDS=() + declared=$(printf '%s' "$json" \ + | jq -r "$expr"' as $list + | if $list == null then 0 + elif ($list | type) == "array" then ($list | length) + else -1 end' 2>/dev/null) + case "$declared" in ''|*[!0-9]*) return 1 ;; esac + [ "$declared" -gt 0 ] || return 0 + while IFS= read -r -d '' record; do + MANIFEST_RECORDS[${#MANIFEST_RECORDS[@]}]=$record + done < <(printf '%s' "$json" | jq -j "$expr"'[] | tostring + "\u0000"' 2>/dev/null) + [ "${#MANIFEST_RECORDS[@]}" -eq "$declared" ] +} + +policy_raw=$(mq '.on_failure // "warn"') +case "$policy_raw" in + warn|block) POLICY=$policy_raw ;; + *) emit failed "on_failure must be \"warn\" or \"block\" (got '$policy_raw')" ;; +esac + +STEP_TIMEOUT=$(mq ".step_timeout_seconds // $DEFAULT_STEP_TIMEOUT") +TOTAL_TIMEOUT=$(mq ".timeout_seconds // $DEFAULT_TOTAL_TIMEOUT") +case "$STEP_TIMEOUT" in ''|*[!0-9]*) emit failed "step_timeout_seconds must be a positive integer" ;; esac +case "$TOTAL_TIMEOUT" in ''|*[!0-9]*) emit failed "timeout_seconds must be a positive integer" ;; esac +[ "$STEP_TIMEOUT" -gt 0 ] || emit failed "step_timeout_seconds must be greater than zero" +[ "$TOTAL_TIMEOUT" -gt 0 ] || emit failed "timeout_seconds must be greater than zero" + +KINDS=$(mq '(.kinds // ["ship"]) | join(" ")') +[ -n "$KINDS" ] || emit failed "kinds must be a non-empty array of task kinds" +if [ "$FORCE" != 1 ]; then + case " $KINDS " in + *" $KIND "*) ;; + *) emit skipped "kind $KIND is not in the manifest kinds ($KINDS)" ;; + esac +fi + +activate_artifacts +init_work_area || emit failed "temporary provisioning workspace could not be created" + +COMPONENT_LIST=() +manifest_records "$MANIFEST_JSON" '.components' \ + || emit failed "manifest components must be an array of component objects" +[ "${#MANIFEST_RECORDS[@]}" -eq 0 ] || COMPONENT_LIST=("${MANIFEST_RECORDS[@]}") +COMPONENT_COUNT=${#COMPONENT_LIST[@]} +[ "$COMPONENT_COUNT" -gt 0 ] || emit failed "manifest declares no components" + +STARTED=$(date +%s) + +# --- token expansion and containment ---------------------------------------- +# +# Command arguments, expected output, environment values, and path fields may +# reference exactly two tokens, so one manifest can name a host runtime directory +# or a path inside the lease without being rewritten per worktree: ${HOME} and +# ${WORKTREE}. Labels and descriptions do not expand, and no value is implicitly +# evaluated as a shell command. +expand_tokens() { + local out=$1 + out=${out//\$\{HOME\}/$HOME} + out=${out//\$\{WORKTREE\}/$WORKTREE_REAL} + printf '%s' "$out" +} + +# contained_path : echo a physically contained path, refusing +# absolute inputs, symlinks, non-directory ancestors, and traversal escapes. +contained_path() { + local base=$1 rel=$2 base_real resolved rest part next physical + case "$rel" in + ''|/*) return 1 ;; + esac + base_real=$(cd "$base" 2>/dev/null && pwd -P) || return 1 + resolved=$base_real + rest=$rel + while :; do + part=${rest%%/*} + if [ "$rest" = "$part" ]; then + rest= + else + rest=${rest#*/} + fi + case "$part" in + ''|.) ;; + ..) + [ "$resolved" != "$base_real" ] || return 1 + resolved=${resolved%/*} + case "$resolved" in + "$base_real"|"$base_real"/*) ;; + *) return 1 ;; + esac + ;; + *) + next="$resolved/$part" + [ ! -L "$next" ] || return 1 + if [ -e "$next" ]; then + if [ -n "$rest" ] && [ ! -d "$next" ]; then + return 1 + fi + if [ -d "$next" ]; then + physical=$(cd "$next" 2>/dev/null && pwd -P) || return 1 + else + physical=$next + fi + case "$physical" in + "$base_real"|"$base_real"/*) ;; + *) return 1 ;; + esac + resolved=$physical + else + resolved=$next + fi + ;; + esac + [ -n "$rest" ] || break + done + printf '%s' "$resolved" +} + +strict_descendant_path() { + local base=$1 rel=$2 base_real resolved + case "$rel" in + ''|.|/*) return 1 ;; + esac + base_real=$(cd "$base" 2>/dev/null && pwd -P) || return 1 + resolved=$(contained_path "$base_real" "$rel") || return 1 + case "$resolved" in + "$base_real"/*) printf '%s' "$resolved" ;; + *) return 1 ;; + esac +} + +# --- bounded execution ------------------------------------------------------ +# +# This host has neither timeout(1) nor gtimeout(1), and the failure this script +# exists to prevent was an unbounded hang, so the bound is implemented here. +# set -m gives the child its own process group, which stays supervised as one +# unit rather than orphaning grandchildren when its leader exits. +# +# Every bounded child reads from /dev/null. Whether an asynchronous command +# inherits the caller's stdin is a bash-version and job-control detail, and what +# it would inherit here is whatever fd the caller happens to be reading, so a +# step that reads stdin could otherwise consume its own caller's records. This +# makes that impossible rather than incidental; a step that wants input opens it +# itself. +run_bounded() { + local seconds=$1 + shift + local pid status=0 ticks=0 limit grace=0 + set -m + "$@" /dev/null; do + if [ "$ticks" -ge "$limit" ]; then + kill -TERM -"$pid" 2>/dev/null || true + while kill -0 -"$pid" 2>/dev/null && [ "$grace" -lt 20 ]; do + sleep 0.1 + grace=$((grace + 1)) + done + if kill -0 -"$pid" 2>/dev/null; then + kill -KILL -"$pid" 2>/dev/null || true + fi + wait "$pid" 2>/dev/null || status=$? + return 124 + fi + sleep 0.1 + ticks=$((ticks + 1)) + done + wait "$pid" 2>/dev/null || status=$? + return "$status" +} + +remaining_budget() { + local now elapsed left + now=$(date +%s) + elapsed=$((now - STARTED)) + left=$((TOTAL_TIMEOUT - elapsed)) + [ "$left" -gt 0 ] || left=0 + printf '%s' "$left" +} + +# --- steps ------------------------------------------------------------------ + +STEP_ARGV=() +STEP_OUT= +STEP_DIAGNOSTIC= +STEP_FAILURE= +COMPONENT_ENV=() + +# last_value_line : the last non-empty line of , trimmed. +last_value_line() { + awk 'NF { last = $0 } END { if (last != "") print last }' "$1" 2>/dev/null \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + +# step_argv : fill STEP_ARGV with the step's token-expanded argv. A +# partially read argv would run a DIFFERENT command from the one declared, so an +# argv that cannot be read whole is treated as no argv at all. +step_argv() { + local step=$1 i + STEP_ARGV=() + manifest_records "$step" '.argv' || return 1 + for ((i = 0; i < ${#MANIFEST_RECORDS[@]}; i++)); do + STEP_ARGV[${#STEP_ARGV[@]}]=$(expand_tokens "${MANIFEST_RECORDS[$i]}") + done + [ "${#STEP_ARGV[@]}" -gt 0 ] +} + +# run_step