diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 2db2af3af8c..bc3e6a3585b 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -135,6 +135,7 @@ export default function (pi: ExtensionAPI) { // continuations, retries, or compaction that stay inside the same run. let agentRunActive = false; let workingShipShown = false; + let workingShipWidgetDisposed = false; // One animation instance per extension lifetime. Hiding the working widget freezes // this state; the next working period resumes it. session_start resets it so a fresh // Pi session starts at the normal initial position. Never module-global. @@ -142,6 +143,8 @@ export default function (pi: ExtensionAPI) { // Single owner of Calm's working-row presentation choice. The widget is only created // or removed on a real transition, so repeated starts cannot duplicate its timer. + // The slot is shared with standalone Pi Calm; the dispose signal prevents turning + // Firstmate Calm off from clearing a widget that the other extension installed. const applyWorkingPresentation = ( ui: ExtensionUIContext, forceStockVisibility = false, @@ -149,14 +152,23 @@ export default function (pi: ExtensionAPI) { const showShip = agentRunActive && calmPresentationIsActive(); if (showShip !== workingShipShown) { workingShipShown = showShip; - ui.setWidget( - CALM_WORKING_SHIP_WIDGET_KEY, - showShip - ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation) - : undefined, - ); - ui.setWorkingVisible(!showShip); - } else if (forceStockVisibility && !showShip) { + if (showShip) { + ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, (tui) => { + workingShipWidgetDisposed = false; + const widget = createCalmWorkingShipWidget(tui, workingShipAnimation); + const dispose = widget.dispose; + widget.dispose = () => { + workingShipWidgetDisposed = true; + dispose(); + }; + return widget; + }); + ui.setWorkingVisible(false); + } else if (!workingShipWidgetDisposed) { + ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined); + ui.setWorkingVisible(true); + } + } else if (forceStockVisibility && !showShip && !workingShipWidgetDisposed) { ui.setWorkingVisible(true); } }; diff --git a/.pi/extensions/lib/fm-calm-working-ship.ts b/.pi/extensions/lib/fm-calm-working-ship.ts index d8f8ede4695..2b28633bfa0 100644 --- a/.pi/extensions/lib/fm-calm-working-ship.ts +++ b/.pi/extensions/lib/fm-calm-working-ship.ts @@ -44,7 +44,12 @@ const ANSI_FOREGROUND: Record, string> = // Restores the default foreground so color never bleeds into padding or later frames. const RESET = "\u001b[39m"; -export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship"; +// The working-row widget slot is deliberately shared with the standalone Pi Calm +// extension, which installs its boat under the same "calm-working-ship" key. Pi +// replaces widgets under one key, so a session that loads both Calms renders a +// single boat and a session loading either alone is unchanged. Rename the slot +// in both implementations together, or dual-install sessions duplicate the boat. +export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship"; export type CalmWorkingShipAnimation = Omit & { /** Render one frame that exactly fits `width`, clamping the track to it first. */ diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 9bdefba7b20..ece2579749a 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -76,6 +76,9 @@ # worker account pin (bin/fm-worker-account-lib.sh) here, so a pin # that no longer resolves or is signed out refuses before the old # agent stops. +# The same pre-stop refusal applies to this home's worker tool +# exclusions (bin/fm-exclude-tools-lib.sh): a malformed list, or a +# replacement runtime that cannot hide the listed tools. # --note is required for a ship or scout, whose replacement # inherits the local copy but none of the conversation; a # secondmate reconciles its own home's records at startup, so its @@ -180,6 +183,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-worker-account-lib.sh" # shellcheck source=bin/fm-dock-lib.sh . "$SCRIPT_DIR/fm-dock-lib.sh" +# shellcheck source=bin/fm-exclude-tools-lib.sh +. "$SCRIPT_DIR/fm-exclude-tools-lib.sh" POLL=${FM_CONTROL_POLL:-0.5} SETTLE_WAIT=${FM_CONTROL_SETTLE_WAIT:-5} @@ -569,9 +574,65 @@ do_interrupt() { printf '%s cancel=%s' "$proof" "$cancel" } +# Drop busy_gen from the task record when it still names . +# fm-busy-event.sh owns the sidecar and the record; fm_backlog_atomic_transition +# publish owns the task record. Clearing the line inside the busy writer would +# take the task-record lock that teardown and spawn already hold; the busy +# writer is their child process, so it would wait on a live holder that is +# itself waiting on the child, and neither would ever proceed. +clear_retired_meta_busy_gen() { # + local gen=$1 meta="$STATE/$ID.meta" lock tmp current line + [ -n "$gen" ] || return 0 + [ -f "$meta" ] && [ ! -L "$meta" ] || return 0 + if ! declare -F fm_backlog_atomic_transition >/dev/null 2>&1; then + # shellcheck source=bin/fm-tasks-axi-lib.sh + . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" + # shellcheck source=bin/fm-backlog-transition-lib.sh + . "$SCRIPT_DIR/fm-backlog-transition-lib.sh" + fi + lock=$(fm_meta_lock_path "$meta") || return 1 + fm_lock_acquire_wait "$lock" + current=$(fm_meta_get "$meta" busy_gen) + if [ "$current" != "$gen" ]; then + fm_lock_release "$lock" + return 0 + fi + tmp=$(mktemp "$STATE/.$ID.meta.retire.XXXXXX") || { + fm_lock_release "$lock" + return 1 + } + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + busy_gen=*) ;; + *) + printf '%s\n' "$line" >> "$tmp" || { + rm -f "$tmp" + fm_lock_release "$lock" + return 1 + } + ;; + esac + done < "$meta" || { + rm -f "$tmp" + fm_lock_release "$lock" + return 1 + } + if ! fm_backlog_atomic_transition publish "$tmp" "$meta" "task record" "$STATE"; then + rm -f "$tmp" + fm_lock_release "$lock" + return 1 + fi + fm_lock_release "$lock" +} + retire_busy_incarnation() { + local gen= if [ -f "$STATE/$ID.busy-gen" ]; then - "$SCRIPT_DIR/fm-busy-event.sh" retire "$STATE" "$ID" --current-gen >/dev/null 2>&1 || true + gen=$(fm_busy_current_gen "$STATE" "$ID" 2>/dev/null || true) + if [ -n "$gen" ] \ + && "$SCRIPT_DIR/fm-busy-event.sh" retire "$STATE" "$ID" --gen "$gen" >/dev/null 2>&1; then + clear_retired_meta_busy_gen "$gen" || true + fi fi } @@ -913,6 +974,12 @@ resolve_relaunch_profile() { [ "$account_model" != default ] || account_model= fm_worker_account_select "$TARGET_HARNESS" "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" \ "$account_model" "$TARGET_HARNESS" >/dev/null || return 1 + # Likewise config/crew-exclude-tools: a malformed file, or a replacement + # runtime that cannot hide the listed tools, refuses here, before the old + # agent stops. Secondmate agents are not covered. + if [ "$KIND" != secondmate ]; then + fm_exclude_tools_check "$TARGET_HARNESS" 0 "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" >/dev/null || return 1 + fi } # safe_checkpoint: prove, before anything is stopped, that the work a relaunch diff --git a/bin/fm-exclude-tools-lib.sh b/bin/fm-exclude-tools-lib.sh new file mode 100644 index 00000000000..0306a7e8026 --- /dev/null +++ b/bin/fm-exclude-tools-lib.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# fm-exclude-tools-lib.sh - shared parsing and runtime-support checks for +# config/crew-exclude-tools, sourced by bin/fm-spawn.sh and bin/fm-control.sh. +# +# docs/configuration.md "Worker tool exclusions" owns the file format and +# operator contract; bin/fm-spawn.sh's header owns launch-flag mechanics. +# Name verification belongs inside the worker's loaded-tool registry, never +# in an out-of-band server connection from these launch-time checks. + +# fm_exclude_tools_names : print the comma-joined names, empty when +# the file is absent or lists nothing. Non-zero with the reason on stderr when +# the file or an entry is invalid. +fm_exclude_tools_names() { + local config=$1 file line names='' present + file=$config/crew-exclude-tools + present=$(perl -MErrno=ENOENT -e ' + if (lstat $ARGV[0]) { print 1 } + elsif ($! == ENOENT) { print 0 } + else { die "error: cannot inspect config/crew-exclude-tools: $!\n" } + ' -- "$file") || return 1 + [ "$present" = 1 ] || return 0 + if [ ! -f "$file" ] || [ ! -r "$file" ]; then + echo "error: config/crew-exclude-tools must be a readable regular file" >&2 + return 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + line=${line#"${line%%[![:space:]]*}"} + line=${line%"${line##*[![:space:]]}"} + case "$line" in + '' | '#'*) continue ;; + esac + if [ -n "${line//[A-Za-z0-9_.-]/}" ]; then + echo "error: config/crew-exclude-tools has a malformed entry '$line'; expected one tool name per line using only letters, digits, _ . and - (blank lines and # comment lines are allowed)" >&2 + return 1 + fi + names="${names:+$names,}$line" + done <"$file" || { + echo "error: cannot read config/crew-exclude-tools" >&2 + return 1 + } + printf '%s' "$names" +} + +# fm_exclude_tools_check : succeed when +# this launch can honor the home's list (empty list, or a runtime that hides +# tools); otherwise refuse naming the file. Prints the names on stdout. +fm_exclude_tools_check() { + local harness=$1 raw=$2 config=$3 names + names=$(fm_exclude_tools_names "$config") || return 1 + if [ -n "$names" ]; then + if [ "$raw" = 1 ]; then + echo "error: config/crew-exclude-tools lists tools to hide, but a raw launch command cannot hide tools; remove the entries or launch a runtime that supports them (pi, pi-signed)" >&2 + return 1 + fi + case "$harness" in + pi | pi-signed) ;; + *) + echo "error: config/crew-exclude-tools lists tools to hide, but the $harness runtime cannot hide tools, so this launch is refused rather than running with them available; empty the file or launch a runtime that supports exclusion (pi, pi-signed)" >&2 + return 1 + ;; + esac + fi + printf '%s' "$names" +} diff --git a/bin/fm-procevent-remote-reply.sh b/bin/fm-procevent-remote-reply.sh index 7d545c0508b..a7d82904aa7 100755 --- a/bin/fm-procevent-remote-reply.sh +++ b/bin/fm-procevent-remote-reply.sh @@ -128,6 +128,7 @@ source_id() { cursor_path() { printf '%s/%s.cursor\n' "$CURSOR_DIR" "$1"; } ingest_receipt_path() { printf '%s/%s.%s.ingested\n' "$CURSOR_DIR" "$1" "$2"; } +retirement_count_path() { printf '%s/%s.retirements\n' "$CURSOR_DIR" "$1"; } mirrored_source_path() { printf '%s/.remote-reply-mirrored-%s\n' "$STATE" "$1"; } read_cursor() { # ; sets CURSOR_OFFSET and CURSOR_HASH @@ -164,6 +165,43 @@ write_cursor() { # mv -f -- "$tmp" "$path" } +# A missing file is zero and is not created. Ingest only reads this. +# Retirement is the one writer, so a crash during ingest cannot change it. +read_retirement_count() { # ; sets RETIREMENT_COUNT + local path count lines + path=$(retirement_count_path "$1") + RETIREMENT_COUNT=0 + [ -e "$path" ] || [ -L "$path" ] || return 0 + [ -f "$path" ] && [ ! -L "$path" ] || die "reply retirement count is unsafe: $path" + lines=$(grep -c '^count=' "$path" 2>/dev/null || true) + [ "$lines" = 1 ] || die "reply retirement count is invalid: $path" + count=$(sed -n 's/^count=//p' "$path") + case "$count" in ''|*[!0-9]*) die "reply retirement count is invalid: $path" ;; esac + RETIREMENT_COUNT=$count +} + +write_retirement_count() { # + local id=$1 count=$2 path tmp + case "$count" in ''|*[!0-9]*) return 1 ;; esac + mkdir -p "$CURSOR_DIR" || return 1 + chmod 700 "$CURSOR_DIR" 2>/dev/null || true + path=$(retirement_count_path "$id") + [ ! -L "$path" ] || return 1 + tmp=$(umask 077; mktemp "$CURSOR_DIR/.retirements.XXXXXX") || return 1 + printf 'count=%s\n' "$count" > "$tmp" || { rm -f -- "$tmp"; return 1; } + chmod 600 "$tmp" || { rm -f -- "$tmp"; return 1; } + if ! mv -f -- "$tmp" "$path"; then + rm -f -- "$tmp" + return 1 + fi +} + +# Twelve characters distinguish breaks in the status line. The cursor keeps +# the full digest the reader uses. +continuity_prefix() { + printf '%.12s' "$CURSOR_HASH" +} + ingest_receipt_matches() { # local path stored actual count path=$(ingest_receipt_path "$1" "$2") @@ -544,7 +582,12 @@ cmd_ingest() { die "result does not continue the current cursor for $id" fi if [ "$class" = continuity-broken ]; then - line="blocked [key=remote-reply-continuity-$id]: remote reply continuity broke for $id ($reason)" + # The same offset, prefix, and retirement count build the same line, so a + # retry appends nothing. Retirement removes the cursor before it records + # the next count, so a later break is a new line even when the restored + # bytes match, and a stop between those steps leaves the count unchanged. + read_retirement_count "$id" + line="blocked [key=remote-reply-continuity-$id]: remote reply continuity broke for $id ($reason) at offset ${CURSOR_OFFSET} prefix $(continuity_prefix) retirements ${RETIREMENT_COUNT}" append_rc=0 if status_event_recorded "$status_file" "$line"; then append_rc=1 @@ -726,7 +769,7 @@ cmd_retire_quiesce_locked() { } cmd_retire_finalize_locked() { - local id=${1:-} force=${2:-} sid path + local id=${1:-} force=${2:-} sid path cursor validate_id "$id" [ -z "$force" ] || [ "$force" = --force ] || die "invalid retirement option: $force" sid=$(source_id "$id") @@ -742,7 +785,16 @@ cmd_retire_finalize_locked() { done fi fi - rm -f -- "$(cursor_path "$id")" + # Remove the cursor first. A stop before the count write leaves that count + # unchanged, so the same break still builds the same line. + cursor=$(cursor_path "$id") + rm -f -- "$cursor" || die "cannot remove remote reply cursor" + if [ -e "$cursor" ] || [ -L "$cursor" ]; then + die "cannot remove remote reply cursor" + fi + read_retirement_count "$id" + write_retirement_count "$id" "$((RETIREMENT_COUNT + 1))" \ + || die "cannot record remote reply retirement" rm -f -- "$CURSOR_DIR/$id".*.ingested rm -f -- "$(fm_pending_reply_remote_channel_watermark_path "$STATE" "$id")" } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index e828f5366e9..0be52f3de63 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh --mode --yolo [--branch-prefix ] [--base-branch ] [--harness |harness|launch-command] [--model ] [--effort ] [--seat luna|main] [--backend ] -# fm-spawn.sh --scout [--base-branch ] [--harness |harness|launch-command] [--model ] [--effort ] [--seat luna|main] [--backend ] +# Usage: fm-spawn.sh --mode --yolo [--branch-prefix ] [--base-branch ] [--harness |harness|launch-command] [--model ] [--effort ] [--seat luna|main] [--backend ] [--herdr-resume-lock-wait] +# fm-spawn.sh --scout [--base-branch ] [--harness |harness|launch-command] [--model ] [--effort ] [--seat luna|main] [--backend ] [--herdr-resume-lock-wait] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--seat luna|main] [--backend ] --secondmate # --mode and --yolo are this task's delivery contract, REQUIRED for every ship # spawn and refused on --scout and --secondmate spawns. Firstmate resolves both @@ -146,14 +146,23 @@ # authority, and every ambiguous recovery stays on the flat fallback after # duplicate-agent risk is independently absent. Treehouse allocation and task # metadata are unchanged. -# A clean projected create or exact resume makes one bounded attempt to hold -# the one session-scoped presentation-order lock (keyed by named session plus -# canonical socket, outside any home's state/) through launch handoff. Lock -# contention warns and falls back to the ordinary flat layout before any -# projection mutation. The exact response-derived new workspace is inserted -# immediately after its owning parent (firstmate or 2ndmate-) contiguous -# child block. Ordering never authorizes lifecycle cleanup, and any -# unavailable, ambiguous, or failed move warns while the spawn continues. +# A clean projected create and an exact resume both hold the one +# session-scoped presentation-order lock (keyed by named session plus +# canonical socket, outside any home's state/) through launch handoff. +# On contention a create makes one bounded attempt and falls back to the +# ordinary flat layout before any projection mutation. A resume refuses by +# default on the same contention (it does not degrade flat; a concurrent +# resume is a hard failure). Pass --herdr-resume-lock-wait to opt that +# resume into waiting for the lock instead, so two concurrent recoveries +# can serialize and each still replace its own exact husk. The flag acts +# only on that fresh ship or scout spawn path: --relaunch reuses the +# recorded endpoint without taking this lock, so the flag has no effect +# there, and a secondmate spawn never projects. Unbounded +# blocking on a third-party session lock is never the default. The exact +# response-derived new workspace is inserted immediately after its owning +# parent (firstmate or 2ndmate-) contiguous child block. Ordering never +# authorizes lifecycle cleanup, and any unavailable, ambiguous, or failed +# move warns while the spawn continues. # Every projected create, prune, and move captures and verifies the named # session's exact active workspace and tab. A detected focus change restores # only that exact tab id; an ambiguous pre-operation snapshot refuses the @@ -342,6 +351,10 @@ # worktree, or record exists and names the accepted values. The file is read # on every spawn and relaunch, so a change reaches the next launch without a # restart, and it is inherited into secondmate homes (bin/fm-config-inherit-lib.sh). +# Worker tool exclusions: +# docs/configuration.md "Worker tool exclusions" owns config/crew-exclude-tools +# and its operator contract. Resolve it with bin/fm-exclude-tools-lib.sh +# before provisioning; __PIEXCLUDE__ below owns the Pi launch substitution. # Worker account pin (config/claude-account, config/pi-account): # Opt-in. With no file, a Claude or Pi launch is unchanged: Claude still # receives this process's own CLAUDE_CONFIG_DIR when it is set, and Pi the @@ -369,6 +382,9 @@ # __PIAPPROVE__ optional --approve on a seeded Pi/pi-signed secondmate when # that executable advertises the flag (empty otherwise; session # trust for the launch cwd only, never a trust.json rewrite) +# __PIEXCLUDE__ optional ` --exclude-tools ''` from +# config/crew-exclude-tools on Pi/pi-signed ship and scout +# launches (supplies its own leading space, empty otherwise) # __PIRESUME__ optional relaunch-only `--session ` that keeps a # Pi replacement on the session the endpoint's runtime already # reports (relaunch_resume_args below owns it; it supplies its @@ -553,6 +569,8 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-exclude-tools-lib.sh +. "$SCRIPT_DIR/fm-exclude-tools-lib.sh" if ! LAUNCH_ENV_ENABLED=$(fm_config_source_present "$CONFIG/launch-env-allowlist"); then exit 1 fi @@ -693,6 +711,9 @@ BASE_BRANCH= BASE_BRANCH_SET=0 TRACEPARENT_SET=0 RELAUNCH=0 +# Opt-in only: exact-resume presentation-order lock waits instead of refusing. +# Absent/unset keeps upstream refuse-on-contention. See header. +HERDR_RESUME_LOCK_WAIT=0 POS=() want_value= for a in "$@"; do @@ -765,6 +786,7 @@ for a in "$@"; do KIND_SET=1 ;; --relaunch) RELAUNCH=1 ;; + --herdr-resume-lock-wait) HERDR_RESUME_LOCK_WAIT=1 ;; --harness) want_value=harness ;; --harness=*) HARNESS_ARG=${a#--harness=} @@ -1501,24 +1523,50 @@ spawn_abort_cleanup() { } trap spawn_abort_cleanup EXIT -# One bounded lock per live Herdr session/socket, shared across all homes. -# is required so secondmate and primary spawns serialize against the -# same session without writing any other home's state directory. +# One lock per live Herdr session/socket, shared across all homes. +# is required so secondmate and primary spawns serialize against the same +# session without writing any other home's state directory. +# +# The header owns contention modes and the --herdr-resume-lock-wait scope. +# Never wait for presentation while holding the Treehouse project lock: the +# presentation owner may need that project lock to finish its treehouse get. +# The task spawn lock remains held across this gap. After project relocking, +# recovery rechecks metadata and the journal before provisioning; slot ownership +# is separately revalidated after treehouse get before claiming the worktree. spawn_herdr_presentation_order_lock_acquire() { - local session=${1:-} attempt lock_path + local session=${1:-} mode=${2:-} attempt lock_path acquired=1 + local project_lock_held=$SPAWN_TREEHOUSE_PROJECT_LOCK_HELD [ -n "$session" ] || session=$(fm_backend_herdr_session) lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") || return 1 HERDR_PRESENTATION_ORDER_LOCK="$lock_path" - attempt=0 - while [ "$attempt" -lt 50 ]; do - if fm_lock_try_acquire "$HERDR_PRESENTATION_ORDER_LOCK"; then + if [ "$project_lock_held" = 1 ]; then + if ! fm_lock_release "$SPAWN_TREEHOUSE_PROJECT_LOCK"; then + echo "error: could not release Firstmate's Treehouse project lock before herdr presentation for $PROJ_ABS" >&2 + exit 1 + fi + SPAWN_TREEHOUSE_PROJECT_LOCK_HELD=0 + fi + if [ "$mode" = wait ]; then + if fm_lock_acquire_wait "$HERDR_PRESENTATION_ORDER_LOCK"; then HERDR_PRESENTATION_ORDER_LOCK_HELD=1 - return 0 + acquired=0 fi - sleep 0.1 - attempt=$((attempt + 1)) - done - return 1 + else + attempt=0 + while [ "$attempt" -lt 50 ]; do + if fm_lock_try_acquire "$HERDR_PRESENTATION_ORDER_LOCK"; then + HERDR_PRESENTATION_ORDER_LOCK_HELD=1 + acquired=0 + break + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + fi + if [ "$project_lock_held" = 1 ]; then + fm_spawn_treehouse_project_lock_acquire || exit 1 + fi + return "$acquired" } clear_relaunch_harness_wiring() { @@ -1586,6 +1634,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * [ "$YOLO_SET" -eq 0 ] || shared_args+=(--yolo "$YOLO") [ "$BRANCH_PREFIX_SET" -eq 0 ] || shared_args+=(--branch-prefix "$BRANCH_PREFIX") [ "$BASE_BRANCH_SET" -eq 0 ] || shared_args+=(--base-branch "$BASE_BRANCH") + [ "$HERDR_RESUME_LOCK_WAIT" -eq 0 ] || shared_args+=(--herdr-resume-lock-wait) for pair in "${POS[@]}"; do case "$pair" in *=*) : ;; @@ -2170,7 +2219,7 @@ launch_template() { ;; opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}__EFFORTFLAG__}'\'' opencode __MODELFLAG__--prompt __BRIEFPROMPT__' ;; pi | pi-signed) - printf '%s' '__PIBIN____PITUIMODE____PIAPPROVE____PIRESUME__' + printf '%s' '__PIBIN____PITUIMODE____PIAPPROVE____PIEXCLUDE____PIRESUME__' if [ "$kind" = secondmate ]; then printf '%s' ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ __BRIEFPROMPT__' else @@ -2457,6 +2506,13 @@ if [ "$KIND" = secondmate ] && [ "$HARNESS" = rovo ]; then exit 1 fi +# config/crew-exclude-tools (header above): refuse before worker provisioning +# if this launch cannot honor the list. Secondmate agents are not covered. +EXCLUDE_TOOLS= +if [ "$KIND" != secondmate ]; then + EXCLUDE_TOOLS=$(fm_exclude_tools_check "$HARNESS" "$RAW_LAUNCH" "$CONFIG") || exit 1 +fi + case "$HARNESS" in devin) DEVIN_BIN=$(command -v devin) || { @@ -2483,6 +2539,9 @@ pi | pi-signed) PI_APPROVE=' --approve' fi LAUNCH=${LAUNCH//__PIAPPROVE__/$PI_APPROVE} + PI_EXCLUDE= + [ -z "$EXCLUDE_TOOLS" ] || PI_EXCLUDE=" --exclude-tools $(shell_quote "$EXCLUDE_TOOLS")" + LAUNCH=${LAUNCH//__PIEXCLUDE__/$PI_EXCLUDE} LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" ;; cursor) @@ -3886,10 +3945,19 @@ else echo "error: herdr presentation recovery could not ensure its exact named session" >&2 exit 1 } - spawn_herdr_presentation_order_lock_acquire "$HERDR_SES" || { - echo "error: herdr presentation recovery could not acquire its session lock; refusing a concurrent resume" >&2 - exit 1 - } + # Refuse-by-default on contention. Wait only when the caller opted in + # with --herdr-resume-lock-wait (see header). + if [ "$HERDR_RESUME_LOCK_WAIT" = 1 ]; then + spawn_herdr_presentation_order_lock_acquire "$HERDR_SES" wait || { + echo "error: herdr presentation recovery could not resolve its session lock" >&2 + exit 1 + } + else + spawn_herdr_presentation_order_lock_acquire "$HERDR_SES" || { + echo "error: herdr presentation recovery could not acquire its session lock; refusing a concurrent resume" >&2 + exit 1 + } + fi if [ -e "$STATE/$ID.meta" ] || [ -L "$STATE/$ID.meta" ]; then herdr_projection_existing_meta_allows_flat "$STATE/$ID.meta" || exit 1 fi @@ -4903,6 +4971,10 @@ EOF // tool calls) and stays a wake NOTIFICATION touch for the watcher, never // current-state truth. import { execFile } from "node:child_process"; +import { appendFileSync } from "node:fs"; +const excludeTools = "$EXCLUDE_TOOLS".split(",").filter(Boolean); +const excludeFile = $(perl -MJSON::PP -MEncode=decode_utf8 -e 'print encode_json(decode_utf8($ARGV[0]))' -- "$CONFIG/crew-exclude-tools"); +const statusFile = $(perl -MJSON::PP -MEncode=decode_utf8 -e 'print encode_json(decode_utf8($ARGV[0]))' -- "$STATE/$ID.status"); const busyEvent = (state: string, event: string) => new Promise((resolve) => { execFile("$FM_ROOT/bin/fm-busy-event.sh", [ @@ -4911,7 +4983,22 @@ const busyEvent = (state: string, event: string) => ], () => resolve()); }); export default function (pi: any) { - pi.on("agent_start", () => busyEvent("busy", "agent-start")); + let checkedExclusions = false; + pi.on("agent_start", async () => { + await busyEvent("busy", "agent-start"); + // Verify only this worker's registry, never connect servers from Firstmate. + // Check before actions so the warning cannot supersede this turn's terminal status. + if (!checkedExclusions && excludeTools.length) { + const loaded = new Set(pi.getAllTools().map((tool: any) => tool.name)); + const unmatched = excludeTools.filter((name) => !loaded.has(name)); + if (unmatched.length) { + appendFileSync(statusFile, "note [at=" + Math.floor(Date.now() / 1000) + "]: warning: " + excludeFile + + " unmatched exclusion entries (unverified: absent from the worker's loaded-tool registry; excluded tools or unavailable servers cannot be verified): " + + unmatched.join(", ") + "\n"); + } + checkedExclusions = true; + } + }); pi.on("agent_settled", (_event: any, ctx: any) => { if (ctx && typeof ctx.isIdle === "function" && !ctx.isIdle()) return; return busyEvent("idle", "agent-settled"); diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 4d19693996e..9cab1a3b44c 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -429,17 +429,17 @@ hash_pane() { if command -v md5 >/dev/null 2>&1; then md5 -q; else md5sum | cut -d' ' -f1; fi } -# Bash may defer its native TERM until a command substitution's child exits. -# Bound tmux pane captures well inside stop_home_watcher's 5s deadline, leaving -# room for timeout's kill grace and the watcher's EXIT cleanup. A failed -# capture is already treated as no evidence. Only pane captures are bounded: +# Bound tmux pane captures so a wedged pane read cannot stall polling. The +# caller runs this read in a waited process group that TERM can stop promptly. +# fm_exec_timed forwards stop signals to its bounded subtree. A failed capture +# is already treated as no evidence. Only captures are bounded: # agent-state, composer-state, alive, and window-meta tmux queries can still # defer TERM if the tmux server wedges. -watcher_capture() { # [expected-label] +watcher_capture_read() { # [expected-label] local backend=$1 target=$2 lines=$3 if [ "$backend" = tmux ]; then _fm_wake_require_timeout || return 1 - fm_run_timed 2 tmux capture-pane -p -t "$target" -S "-$lines" + fm_exec_timed 2 1 tmux capture-pane -p -t "$target" -S "-$lines" else fm_backend_capture "$@" fi @@ -574,7 +574,8 @@ inbox_steer_check() { # return 0 ;; esac - tail40=$(watcher_capture "$backend" "$w" 40 "$(window_label "$w")" 2>/dev/null) || tail40= + watcher_capture "$backend" "$w" 40 "$(window_label "$w")" || WATCHER_CAPTURE= + tail40=$WATCHER_CAPTURE if window_is_busy "$w" "$tail40"; then [ "$verb" != retry ] || return 0 if ! count=$(fm_task_inbox_record_busy "$STATE" "$task" "$rec"); then @@ -788,7 +789,8 @@ signal_turnend_panes_churned() { # ... [ "$hash_bytes" = 32 ] || return 1 prev=$(cat "$hash_file" 2>/dev/null) || return 1 [[ $prev =~ ^[0-9a-f]{32}$ ]] || return 1 - now=$(watcher_capture "$backend" "$w" 40 "$label" 2>/dev/null) || return 1 + watcher_capture "$backend" "$w" 40 "$label" || return 1 + now=$WATCHER_CAPTURE [ -n "$now" ] || return 1 [ "$(printf '%s' "$now" | hash_pane)" != "$prev" ] || return 1 churned_keys+=("$key") @@ -894,27 +896,29 @@ secondmate_in_active_turn() { # local w=$1 idle=$2 tail40 [ -n "$w" ] || return 1 [ "$idle" -lt "$BUSY_TURN_MAX_SECS" ] || return 1 - tail40=$(watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || return 1 + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || return 1 + tail40=$WATCHER_CAPTURE window_is_busy "$w" "$tail40" } -# First token of the semantic busy classification for : busy, idle, -# unknown, or dead. Capture failure and a missing window are unknown, never -# idle. Empty inbox and a fresh watcher beacon are not consulted. +# Set SECONDMATE_BUSY_CLASS to the first token of the semantic busy +# classification for : busy, idle, unknown, or dead. Capture failure and +# a missing window are unknown, never idle. Empty inbox and a fresh watcher +# beacon are not consulted. Call it directly, never in $(...), so its pane +# capture runs in the watcher's own shell. +SECONDMATE_BUSY_CLASS= secondmate_busy_class() { # local w=$1 task meta tail40 verdict + SECONDMATE_BUSY_CLASS=unknown task=$(window_to_task "$w" "$STATE") meta="$STATE/$task.meta" if [ -z "$w" ] || [ -z "$task" ] || [ ! -f "$meta" ]; then - printf 'unknown' return 0 fi - tail40=$(watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || { - printf 'unknown' - return 0 - } + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || return 0 + tail40=$WATCHER_CAPTURE verdict=$(fm_busy_classify_meta "$meta" "$task" "$STATE" "$tail40") - printf '%s' "${verdict%% *}" + SECONDMATE_BUSY_CLASS=${verdict%% *} } # 0 iff a child ring is authorized: exact idle, a live agent, and a composer @@ -924,7 +928,8 @@ secondmate_busy_class() { # secondmate_idle_ring_safe() { # local w=$1 backend agent_state cstate [ -n "$w" ] || return 1 - [ "$(secondmate_busy_class "$w")" = idle ] || return 1 + secondmate_busy_class "$w" + [ "$SECONDMATE_BUSY_CLASS" = idle ] || return 1 backend=$(window_backend "$w") agent_state=$(fm_backend_agent_state "$backend" "$w" 2>/dev/null || true) [ "$agent_state" = alive ] || return 1 @@ -2510,8 +2515,10 @@ fm_active_check_stop() { # parse of the next command substitution, the body then fails to parse ("trap: # line 2: unexpected EOF while looking for matching `)'", or nothing at all), # and the signal is consumed, so a stop request could leave this watcher -# polling forever while its stopper waits (fixed upstream in bash 5.3). INT -# keeps its trap because bash ignores a direct SIGINT while a child runs. +# polling forever while its stopper waits (fixed upstream in bash 5.3). Bash +# 3.2 holds HUP and TERM until a running command substitution's child exits, so +# fm_backend_capture pane reads go through watcher_capture instead. INT keeps +# its trap because bash ignores a direct SIGINT while a child runs. watcher_stop_signals() { trap - HUP TERM trap 'exit 1' INT @@ -2548,6 +2555,50 @@ run_check_capture() { fm_check_output_cleanup } +FM_CAPTURE_OUTPUT= +WATCHER_CAPTURE= + +fm_capture_output_cleanup() { + [ -z "$FM_CAPTURE_OUTPUT" ] || rm -f -- "$FM_CAPTURE_OUTPUT" + FM_CAPTURE_OUTPUT= +} + +# watcher_capture: fm_backend_capture into WATCHER_CAPTURE with its exit status. +# The read runs as a waited background process group rather than inside $(...), +# so a stop is not held for a blocked read (watcher_stop_signals). The group is +# recorded like a check's, so watcher_cleanup stops a read still in flight. +watcher_capture() { # [expected-label] + local rc pgid + fm_capture_output_cleanup + WATCHER_CAPTURE= + FM_CAPTURE_OUTPUT=$(mktemp "$STATE/.fm-capture-output.XXXXXX") || return 1 + FM_CHECK_SIGNAL_PENDING= + trap 'FM_CHECK_SIGNAL_PENDING=1' HUP INT TERM + # The group's stderr is redirected before the fork: bash 3.2 on macOS can + # print a harmless "child setpgid ... Operation not permitted" race from the + # child before the command's own redirections apply. + set -m + { watcher_capture_read "$@" < /dev/null > "$FM_CAPTURE_OUTPUT" & } 2>/dev/null + FM_ACTIVE_CHECK_PID=$! + FM_ACTIVE_CHECK_PGID=$FM_ACTIVE_CHECK_PID + set +m + watcher_stop_signals + [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 + pgid=$(ps -o pgid= -p "$FM_ACTIVE_CHECK_PID" 2>/dev/null | tr -d '[:space:]') + if [ -n "$pgid" ] && [ "$pgid" != "$FM_ACTIVE_CHECK_PGID" ]; then + fm_active_check_stop || true + fm_capture_output_cleanup + return 1 + fi + wait "$FM_ACTIVE_CHECK_PID" + rc=$? + FM_ACTIVE_CHECK_PID= + fm_active_check_stop || { fm_capture_output_cleanup; return 1; } + WATCHER_CAPTURE=$(cat "$FM_CAPTURE_OUTPUT" 2>/dev/null || true) + fm_capture_output_cleanup + return "$rc" +} + # 0 when any signaled status file carries a captain-relevant event in the bytes # appended since this watcher last classified it. The start offset is the # classified-position field in that file's .seen-* marker, and fm-classify-lib.sh's @@ -2911,6 +2962,7 @@ watcher_cleanup() { fi fm_active_check_stop || cleanup_status=1 fm_check_output_cleanup + fm_capture_output_cleanup fm_custom_check_snapshot_cleanup if [ "$owns_lock" -eq 1 ] \ && ! fm_recovery_transition "$WATCHER_DOWNTIME_MARKER" "$transition" "$WATCH_LOCK" \ @@ -3407,7 +3459,8 @@ EOF if [ "$kind" = secondmate ] && ! status_is_paused_or_captain_held "$last"; then continue fi - tail40=$(watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || continue + tail40=$WATCHER_CAPTURE h=$(printf '%s' "$tail40" | hash_pane) hf="$STATE/.hash-$key" cf="$STATE/.count-$key" diff --git a/docs/agent-control.md b/docs/agent-control.md index d6f0b5e5fd2..51f52d21200 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -65,7 +65,7 @@ A relaunch does take one session reference when the endpoint's own runtime recor ## Transactional relaunch -`relaunch` is the only verb that changes durable records, so it runs as a transaction with a journal at `state/.control-relaunch`, the prior record preserved beside it, and a ship or scout's prior instructions preserved when a progress note is appended. +`relaunch` replaces the recorded launch profile, so it runs as a transaction with a journal at `state/.control-relaunch`, the prior record preserved beside it, and a ship or scout's prior instructions preserved when a progress note is appended. 1. **Resolve the profile.** An explicit `--harness`, `--model`, or `--effort` wins. @@ -74,6 +74,7 @@ A relaunch does take one session reference when the endpoint's own runtime recor A recorded raw-command basename that differs from its resolved adapter cannot reproduce the command actually running, so relaunch refuses before the checkpoint unless the caller passes an explicit `--harness` to choose the replacement runtime deliberately. A harness change resets model and effort unless they are named too, because a model chosen for one adapter does not transfer to another. A Claude or Pi replacement must also pass the home's [worker account pin](configuration.md#worker-account-pin-configclaude-account-configpi-account), so a pin that no longer resolves or is signed out refuses before the old agent stops. + Ship and scout replacements also pass the [worker tool exclusion checks](configuration.md#worker-tool-exclusions-configcrew-exclude-tools) at this step. 2. **Safe checkpoint.** The recorded worktree must exist and be a worktree root; its head and dirty state are recorded. For a `kind=secondmate` task, the home's identity marker must match and its child records must be readable, so a relaunch can never strand child work behind an unreadable home. diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index 89e84796271..f1515eef7bd 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -154,10 +154,10 @@ Calm replaces Pi's stock working row with a small animated boat while Calm is on This path uses only public extension API and patches nothing: `ExtensionUIContext.setWorkingVisible(false)` hides the stock row, and `setWidget()` installs a temporary component factory above the editor. Pi's documented custom working-indicator frames are static and width-blind, so they cannot own responsive geometry; a widget component receives `render(width)` and can. -`.pi/extensions/fm-calm.ts` remains the sole owner of the presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns Pi's ANSI painting and the widget over the sprite geometry, bounce track, cadences, and freeze/resume state in `.claude/mods/firstmate-calm/lib/fm-calm-working-ship-sprite.ts`, the harness-neutral core the Claude Code mod also draws from (reached from the Pi tree through a tracked symlink, because Claude Code refuses a hooks-module import from outside the plugin folder). +Within Firstmate Calm, `.pi/extensions/fm-calm.ts` remains the sole owner of its presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns Pi's ANSI painting and the widget over the sprite geometry, bounce track, cadences, and freeze/resume state in `.claude/mods/firstmate-calm/lib/fm-calm-working-ship-sprite.ts`, the harness-neutral core the Claude Code mod also draws from (reached from the Pi tree through a tracked symlink, because Claude Code refuses a hooks-module import from outside the plugin folder). Visibility follows `agent_start` through `agent_settled` rather than turns or tool calls. Pi emits `agent_settled` from a `finally` block once a run will not continue automatically, so retries, automatic continuations, queued follow-ups, and compaction inside one run never remove the boat, while settle, abort, and failure all reach the same cleanup. -Repeated `agent_start` events inside one run are idempotent, and Pi disposes the previous component before installing a replacement under the same key and when it clears extension widgets, so the frame timer cannot duplicate or outlive the widget. +Repeated `agent_start` events inside one run are idempotent, and both Calm extensions use the shared `calm-working-ship` widget key, so Pi disposes the previous component before installing a replacement under that key and when it clears extension widgets, while Firstmate Calm's disposal signal prevents its cleanup from clearing a standalone Calm widget that replaced its own widget. Pi's above-editor widget container reserves one spacer row whether or not a widget is present, so removing the boat leaves no residual blank row. The sprite is two rows when the usable width admits the complete hull: an asymmetric three-cell `◿│◣` sail centered over a five-cell `╲▁▁▁╱` hull that sits inside the water row rather than adding a third row. diff --git a/docs/calm.md b/docs/calm.md index dca32ffe27b..ce63557c5db 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -32,6 +32,7 @@ Streaming text and the genuine reply that ends a response remain visible. While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place. No separate Calm status row is added. +The boat claims the same Pi working-row widget slot as the standalone Pi Calm extension, so a session that loads both Calms shows one boat rather than two, and turning Firstmate Calm off does not clear the standalone boat. While Calm is off, Pi's stock working row is left exactly as Pi renders it. The boat looks like this: diff --git a/docs/configuration.md b/docs/configuration.md index 0b40a57221b..f9735902d89 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ Start with the directory layout, then use the setting reference for the behavior | --- | --- | | Firstmate's code, private files, or project location | [FM_HOME](#fm_home) and [operational home layout](#operational-home-layout-and-state) | | Task windows and worker tools | [Runtime backend](#runtime-backend-configbackend--fm_backend) and [harness support](#harness-support) | -| Worker permissions, accounts, or environment | [Claude permission mode](#claude-permission-mode-configclaude-permission-mode), [worker account pin](#worker-account-pin-configclaude-account-configpi-account), and [worker launch environment](#worker-launch-environment-configlaunch-env-allowlist) | +| Worker permissions, accounts, or environment | [Claude permission mode](#claude-permission-mode-configclaude-permission-mode), [worker account pin](#worker-account-pin-configclaude-account-configpi-account), [worker tool exclusions](#worker-tool-exclusions-configcrew-exclude-tools), and [worker launch environment](#worker-launch-environment-configlaunch-env-allowlist) | | Backlog, preferences, and memory | [Backlog backend](#backlog-backend-taskstoml--configbacklog-backend), [captain preferences](#captain-preferences-datacaptainmd--datacaptain-sharedmd), and [startup memory budget](#startup-memory-budget-configstartup-memory-budget) | | Supervision and presentation | [Pi supervision branch](#pi-supervision-branch), [supervision host](#supervision-host-configsupervision-host), [ready-session timeout](#ready-session-timeout-configready-session-timeout), and [Calm preference](#calm-preference-configcalm) | | Persistent secondmates | [Secondmate routes](#secondmate-routes-datasecondmatesmd) | @@ -974,6 +974,43 @@ The file is a captain-wide safety preference, so it is inherited into secondmate The [Claude adapter reference](../.agents/skills/harness-adapters/references/harness/claude.md) records the permission-mode observations and the distinct startup dialogs. +## Worker tool exclusions (config/crew-exclude-tools) + +The optional local, gitignored `config/crew-exclude-tools` hides named tools from this home's ship and scout workers, for example to keep an MCP server's write tools out of reach while its read tools stay available. +The contract is runtime-neutral: a runtime must support hiding the listed tool names or refuse the launch, and a non-empty list is never silently ignored. +With no file, or a file with no entries, every launch on every runtime is unchanged. + +Create the file with one tool name per line, such as `mcp____` for an MCP tool. +Blank lines and lines beginning with `#` are allowed, and surrounding whitespace on a line is trimmed; a trailing comment on an entry line is not allowed. +The file is read from this home's own configuration directory on every launch, so a change reaches the next worker or relaunch without a restart. +It is not in the inherited configuration set, so no other home, including a secondmate home, receives it; create the file in each home that wants it. +It does not apply to a secondmate's own agent, which neither reads nor refuses on it. + +### Runtime support + +| Runtime | With a non-empty list | +| --- | --- | +| `pi`, `pi-signed` | Hides listed tool names, MCP tools included, on every ship and scout spawn and relaunch. | +| Every other runtime, and a raw launch command | The launch refuses with an error naming `config/crew-exclude-tools`, because that runtime has no verified way to hide tools. | + +A relaunch validates the list and the replacement runtime's support before stopping the running worker, so an exclusion-list refusal preserves the running agent. + +### Validation + +An entry may use only `A-Z`, `a-z`, `0-9`, `_`, `.`, and `-`. +An entry with any other character, including internal whitespace, a comma, or a `*`, refuses the launch and names the offending entry. +An unreadable or nonregular file, or a path inspection error, also refuses and names the configuration file. +For a new worker, these checks run before its endpoint, local copy, or task record is created; Firstmate never launches with a partial list. +Only exact tool names are accepted, not wildcard patterns. +Firstmate checks syntax and runtime support before launch but never runs `pi mcp list` or otherwise connects to servers to validate names. +When its first agent run starts, after Pi's startup tool-loading boundary, the worker extension compares the launch's exclusion list with its own loaded-tool registry and appends a timestamped warning note to `state/.status` naming the configuration file and every unmatched entry for the supervisor. +The check runs before worker actions so it cannot supersede a terminal status emitted during the turn. +An unmatched entry is reported as **unverified**, not valid: Pi versions that omit excluded tools from the registry cannot distinguish a correct exclusion from a typo, and a server that has not connected cannot verify its tools either. +Names present in the registry produce no report; unknown or unverified names do not refuse the launch. +Each relaunch installs a fresh worker extension with the home's current list, so the replacement performs the same check. + +[`bin/fm-exclude-tools-lib.sh`](../bin/fm-exclude-tools-lib.sh) implements parsing and pre-launch validation for this contract; [`bin/fm-spawn.sh`](../bin/fm-spawn.sh)'s header owns the launch-flag mechanics. + ## Worker account pin (config/claude-account, config/pi-account) A home that mixes accounts for one runner, such as a work login and a personal one, can pin the account its own Claude and Pi workers launch on. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index ba154d52def..540bc5d9c97 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -503,8 +503,13 @@ Any of these preserves the candidate and lets session startup continue with at m - A failed journal publication or projected workspace create stops that spawn instead of falling back flat. So a Herdr create failure surfaces as a spawn failure in every Herdr home, rather than only in homes that opted in. Every earlier degradation on the fresh projected-create path (no session server, contended presentation lock, absent or ambiguous parent) still warns and continues flat. -- Recovery of an existing presentation journal deliberately refuses the spawn when the shared presentation lock is contended, rather than falling back flat. - Default-on makes that refusal reachable in any Herdr home. +- Recovery of an existing presentation journal refuses by default when the shared presentation lock is contended, rather than falling back flat. + Pass `fm-spawn.sh --herdr-resume-lock-wait` to opt that recovery into waiting for the lock instead, so concurrent recoveries can serialize. + The flag applies to a fresh ship or scout spawn that recovers a journal. + The multi-task path forwards the flag to each per-pair spawn. + `fm-spawn.sh --relaunch` and `--secondmate` take no exact-resume presentation-order lock, so the flag has no effect there. + Dead-owner reclaim still stops the wait when a holder crashed. + Unbounded blocking on the session lock is never the default. - Existing layouts are not force-renamed or rearranged. - Missing or ambiguous restart bindings fall back to the ordinary home workspace while the old projection remains untouched. - Crashes, lost responses, failed exact-pane cleanup, or human renames can leave quarantined spaces. diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index fff346e8141..035e5976ecb 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -632,6 +632,12 @@ The [process-to-event operating contract](configuration.md#process-to-event-sour The source log is never truncated or consumed. A shortened or changed prefix stops the relay and surfaces a continuity failure instead of silently resetting the cursor. +The failure appends one `blocked` line to the parent status stream, which opens a decision. +The line records the reason, the reader position (the cursor offset and the first 12 characters of the prefix hash), and the retirement count (how many times the route has been retired). +Reading the same break again, with the cursor where it was and the count unchanged, appends nothing. +A later break at a different reader position, or after another retirement, appends a new `blocked` line and opens the decision again. +A line written before that position was recorded does not match, so the next break appends the new line once. + ### SSH exit 255 and unavailable homes An SSH exit status of 255 always means transport failure or unknown remote completion. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 7bd5badf9ac..02fce3389b0 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1486,7 +1486,8 @@ ok - real Herdr lab validation completed on Herdr 0.8.0 with the default-session ``` The projected spawn in that run used the historical empty opt-in file, so a home that had already enabled the projection keeps it without any migration step. -One concurrent cross-home recovery case refused under contention on a loaded machine and passed on an immediate rerun; recovery-path presentation lock contention is a deliberate hard refusal rather than a flat fallback, which default-on now makes reachable from any Herdr home. +One concurrent cross-home recovery case refused under contention on a loaded machine and passed on an immediate rerun; recovery-path presentation lock contention remains a deliberate hard refusal by default rather than a flat fallback, which default-on makes reachable from any Herdr home. +The current opt-in recovery wait behavior is owned by [Herdr's operational compromises](../herdr-backend.md#operational-compromises); the run above predates that option. That run measured the default-on projection on Herdr 0.8.0 only, while the focus-flash regression below was last run on 0.7.5 before the flip, so neither run covered a defective release under default-on projection; the version floor and the focus-flash suite's Part C close that gap. The per-project task space suite ran on 2026-09-26 against Herdr 0.9.1, with `config/herdr-presentation-spaces` set to `project`: diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index a319ad755fb..4eddbd53d2f 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -402,9 +402,9 @@ Only the watcher process touches `state/.last-watcher-beat`. No helper process can make a wedged watcher appear healthy. An arm whose own script path sits under a disposable no-mistakes validation checkout (`.no-mistakes/worktrees/`) refuses with the typed failure line before touching any state, because a watcher started there outlives the validation step and keeps writing the real home's state from a checkout about to be deleted. Once per poll the watcher checks that its home, its state directory, and its own code root still exist, and exits with a logged reason when one is gone, scoped to itself alone, so a torn-down temporary home or a discarded checkout never leaves an orphan watcher behind. -The watcher uses bash's native fatal handling for HUP and TERM, so both run its EXIT cleanup. -Bash can defer that native TERM until a blocked command substitution's child exits, so the watcher bounds its tmux pane captures; other tmux queries in a poll are not bounded and can still defer TERM if the tmux server wedges. -`watcher_stop_signals` and `watcher_capture` in `bin/fm-watch.sh` own the signal-handling and capture-bound rationale. +The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked check or a blocked pane read, so both run its EXIT cleanup and stop that read. +Pane captures run in a waited process group, with tmux captures additionally bounded to two seconds; other tmux queries in a poll are not bounded and can still defer TERM if the tmux server wedges. +`watcher_stop_signals`, `watcher_capture`, and `watcher_capture_read` in `bin/fm-watch.sh` own the signal-handling and capture-bound rationale. The EXIT cleanup bounds its wait for `state/.watcher-down.lock` while persisting recovery state with `FM_WATCHER_CLEANUP_LOCK_BOUND` (default 2 seconds). Only positive decimal integers are accepted, including leading-zero forms such as `08`; empty, non-numeric, and zero values (including `00`) fall back to 2 seconds. A live foreign holder therefore cannot strand a TERM'd watcher in this marker-lock wait: on timeout the recovery transition fails without releasing the singleton, leaving dead-pid stale evidence for the next arm to republish and clear. @@ -469,6 +469,7 @@ They also prove that a legacy or handoff-phase watcher marker from an absent rep `tests/fm-watch-triage.test.sh` proves TERM stops a watcher blocked inside a poll's pane capture and still releases its lock and records an acknowledgeable stop. It also exercises a single TERM with a live foreign downtime-marker lock holder, retained stale singleton and subsequent arm-style recovery, including decimal `08` and zero `00` cleanup bounds. It checks that a newly appended keyed decision is classified without rereading earlier status bytes, so signal handling can return to the watcher's beacon refresh even when the status history is long. +`tests/fm-wake-queue.test.sh` proves TERM likewise stops a watcher blocked in the drain-ring idle check's pane capture. `tests/fm-watcher-lock.test.sh` covers: diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 54483947a37..a5790b18b3a 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -8,6 +8,8 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HERDR_LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} +# shellcheck source=bin/fm-timeout-lib.sh +. "$ROOT/bin/fm-timeout-lib.sh" fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } pass() { printf 'ok - %s\n' "$1"; } @@ -278,13 +280,18 @@ export HERDR_SESSION="$HERDR_LAB_SESSION" HERDR_LAB_SESSION LAB_READY=0 RECORDED_WORKTREES="" LOCK_CONTENTION_OWNER_PID= +LOCK_REFUSE_HOLDER_PID= +LOCK_WAIT_HOLDER_PID= cleanup_all() { - local wt - if [ -n "$LOCK_CONTENTION_OWNER_PID" ]; then - kill "$LOCK_CONTENTION_OWNER_PID" 2>/dev/null || true - wait "$LOCK_CONTENTION_OWNER_PID" 2>/dev/null || true - LOCK_CONTENTION_OWNER_PID= - fi + local wt pid + for pid in "$LOCK_CONTENTION_OWNER_PID" "$LOCK_REFUSE_HOLDER_PID" "$LOCK_WAIT_HOLDER_PID"; do + [ -n "$pid" ] || continue + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + LOCK_CONTENTION_OWNER_PID= + LOCK_REFUSE_HOLDER_PID= + LOCK_WAIT_HOLDER_PID= while IFS= read -r wt; do [ -n "$wt" ] || continue [ -d "$wt" ] || continue @@ -406,10 +413,14 @@ Verify projected workspace behavior for $id. EOF } -spawn_task() { # +spawn_task() { # [extra fm-spawn args...]; SPAWN_DEADLINE_SECONDS bounds the run local id=$1 home=$2 project=$3 + shift 3 + local -a deadline_cmd=() + [ -z "${SPAWN_DEADLINE_SECONDS:-}" ] || deadline_cmd=(fm_run_timed "$SPAWN_DEADLINE_SECONDS") FM_GATE_REFUSE_BYPASS=1 FM_SPAWN_NO_GUARD=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ - "$ROOT/bin/fm-spawn.sh" "$id" "$project" "sh -c 'while :; do sleep 60; done'" --mode no-mistakes --yolo off --backend herdr + ${deadline_cmd[@]+"${deadline_cmd[@]}"} "$ROOT/bin/fm-spawn.sh" "$id" "$project" "sh -c 'while :; do sleep 60; done'" \ + --mode no-mistakes --yolo off --backend herdr "$@" } finish_concurrent_spawn() { # @@ -1349,6 +1360,129 @@ teardown_task "$BRAVO_WAVE_ID" "$SECOND_HOME_B" > "$TMP_ROOT/bravo-wave-teardown "$REAL_TREEHOUSE" return --force "$BRAVO_WAVE_NEW_WT" >/dev/null 2>&1 || true pass "real Herdr lab: concurrent cross-home recoveries replace exact husks under one session lock with no focus drift" +# Exact-resume presentation-lock contention refuses by default. Hold the +# shared session lock from an unrelated process past the bounded-retry window +# and assert the default resume hard-refuses without the opt-in flag. +LOCK_REFUSE_ID=lock-refuse-resume-r1 +mkdir -p "$HOME_DIR/data/$LOCK_REFUSE_ID" +write_ship_brief "$HOME_DIR" "$LOCK_REFUSE_ID" 'Resume lock-refuse fixture.' +spawn_task "$LOCK_REFUSE_ID" "$HOME_DIR" "$RECOVERY_PROJECT_DIR" > "$TMP_ROOT/lock-refuse-first.out" 2> "$TMP_ROOT/lock-refuse-first.err" \ + || fail "lock-refuse recovery fixture failed: $(cat "$TMP_ROOT/lock-refuse-first.err")" +LOCK_REFUSE_META="$HOME_DIR/state/$LOCK_REFUSE_ID.meta" +LOCK_REFUSE_OLD_WT=$(remember_meta_worktree "$LOCK_REFUSE_META") +LOCK_REFUSE_OLD_PANE=$(grep '^herdr_pane_id=' "$LOCK_REFUSE_META" | cut -d= -f2-) +PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" stop "$HERDR_LAB_SESSION" >/dev/null \ + || fail "could not stop the isolated session for resume lock-refuse" +PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" \ + || fail "could not reprovision the isolated session for resume lock-refuse" + +LOCK_REFUSE_READY="$TMP_ROOT/lock-refuse-ready" +LOCK_REFUSE_HOLD_SECONDS=15 +LOCK_REFUSE_PATH=$(session_presentation_lock_path) \ + || fail "could not resolve session lock for resume lock-refuse" +ROOT="$ROOT" READY="$LOCK_REFUSE_READY" HOLD="$LOCK_REFUSE_HOLD_SECONDS" LOCK="$LOCK_REFUSE_PATH" bash -c ' + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$LOCK" || exit 1 + : > "$READY" + sleep "$HOLD" + fm_lock_release "$LOCK" +' & +LOCK_REFUSE_HOLDER_PID=$! +while [ ! -e "$LOCK_REFUSE_READY" ] && kill -0 "$LOCK_REFUSE_HOLDER_PID" 2>/dev/null; do sleep 0.01; done +[ -e "$LOCK_REFUSE_READY" ] || fail "could not hold the session presentation lock for resume lock-refuse" + +LOCK_REFUSE_FOCUS=$(focus_snapshot) +if spawn_task "$LOCK_REFUSE_ID" "$HOME_DIR" "$RECOVERY_PROJECT_DIR" \ + > "$TMP_ROOT/lock-refuse-resume.out" 2> "$TMP_ROOT/lock-refuse-resume.err"; then + LOCK_REFUSE_STATUS=0 +else + LOCK_REFUSE_STATUS=$? +fi +if [ "$LOCK_REFUSE_STATUS" -eq 0 ]; then + kill "$LOCK_REFUSE_HOLDER_PID" 2>/dev/null || true + wait "$LOCK_REFUSE_HOLDER_PID" 2>/dev/null || true + fail "default resumed identity succeeded under session lock contention instead of refusing: $(cat "$TMP_ROOT/lock-refuse-resume.out")" +fi +wait "$LOCK_REFUSE_HOLDER_PID" || fail "resume lock-refuse lock holder failed" +LOCK_REFUSE_HOLDER_PID= +[ "$LOCK_REFUSE_STATUS" -ne 0 ] \ + || fail "default resumed identity returned success under contention" +grep -F "refusing a concurrent resume" "$TMP_ROOT/lock-refuse-resume.err" >/dev/null 2>&1 \ + || fail "default resume under contention did not refuse with the concurrent-resume message: $(cat "$TMP_ROOT/lock-refuse-resume.err")" +# Fixture metadata and husk must be unchanged after the refused resume. +[ "$(grep '^herdr_pane_id=' "$LOCK_REFUSE_META" | cut -d= -f2-)" = "$LOCK_REFUSE_OLD_PANE" ] \ + || fail "refused resume mutated the recorded pane id" +assert_focus_is "$LOCK_REFUSE_FOCUS" "resume lock-refuse" +# Leave the journal/meta in place so the opt-in wait path below can resume the +# same identity after another stop/reprovision cycle. +pass "real Herdr lab: default resumed identity refuses session lock contention" + +# With --herdr-resume-lock-wait, the same exact resume WAITS for session lock +# contention rather than treating a short bounded window as fatal. Hold the +# shared session lock from an unrelated process for a duration well past any +# plausible bounded-retry window so the assertion below is deterministic +# rather than a race that could pass by luck on a fast machine. +LOCK_WAIT_ID=$LOCK_REFUSE_ID +LOCK_WAIT_META=$LOCK_REFUSE_META +LOCK_WAIT_OLD_WT=$LOCK_REFUSE_OLD_WT +LOCK_WAIT_WSID=$(grep '^herdr_workspace_id=' "$LOCK_WAIT_META" | cut -d= -f2-) +LOCK_WAIT_OLD_PANE=$LOCK_REFUSE_OLD_PANE +PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" stop "$HERDR_LAB_SESSION" >/dev/null \ + || fail "could not stop the isolated session for resume lock-wait" +PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" \ + || fail "could not reprovision the isolated session for resume lock-wait" + +LOCK_WAIT_READY="$TMP_ROOT/lock-wait-ready" +LOCK_WAIT_HOLD_SECONDS=30 +LOCK_WAIT_PATH=$(session_presentation_lock_path) \ + || fail "could not resolve session lock for resume lock-wait" +ROOT="$ROOT" READY="$LOCK_WAIT_READY" HOLD="$LOCK_WAIT_HOLD_SECONDS" LOCK="$LOCK_WAIT_PATH" bash -c ' + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$LOCK" || exit 1 + : > "$READY" + sleep "$HOLD" + fm_lock_release "$LOCK" +' & +LOCK_WAIT_HOLDER_PID=$! +while [ ! -e "$LOCK_WAIT_READY" ] && kill -0 "$LOCK_WAIT_HOLDER_PID" 2>/dev/null; do sleep 0.01; done +[ -e "$LOCK_WAIT_READY" ] || fail "could not hold the session presentation lock for resume lock-wait" + +LOCK_WAIT_DEADLINE_SECONDS=$((LOCK_WAIT_HOLD_SECONDS + 60)) +LOCK_WAIT_FOCUS=$(focus_snapshot) +LOCK_WAIT_START=$(date +%s) +if SPAWN_DEADLINE_SECONDS=$LOCK_WAIT_DEADLINE_SECONDS \ + spawn_task "$LOCK_WAIT_ID" "$HOME_DIR" "$RECOVERY_PROJECT_DIR" --herdr-resume-lock-wait \ + > "$TMP_ROOT/lock-wait-resume.out" 2> "$TMP_ROOT/lock-wait-resume.err"; then + LOCK_WAIT_STATUS=0 +else + LOCK_WAIT_STATUS=$? +fi +LOCK_WAIT_ELAPSED=$(( $(date +%s) - LOCK_WAIT_START )) +wait "$LOCK_WAIT_HOLDER_PID" || fail "resume lock-wait lock holder failed" +LOCK_WAIT_HOLDER_PID= +if [ "$LOCK_WAIT_STATUS" -eq 124 ]; then + fail "opt-in resumed recovery hung for over ${LOCK_WAIT_DEADLINE_SECONDS}s instead of waiting out a ${LOCK_WAIT_HOLD_SECONDS}s session lock hold" +fi +[ "$LOCK_WAIT_STATUS" -eq 0 ] \ + || fail "opt-in resumed identity refused instead of waiting out session lock contention: $(cat "$TMP_ROOT/lock-wait-resume.err")" +[ "$LOCK_WAIT_ELAPSED" -ge $((LOCK_WAIT_HOLD_SECONDS - 5)) ] \ + || fail "opt-in resumed recovery returned after ${LOCK_WAIT_ELAPSED}s, too soon to have genuinely waited out a ${LOCK_WAIT_HOLD_SECONDS}s hold" +LOCK_WAIT_NEW_WT=$(remember_meta_worktree "$LOCK_WAIT_META") +[ "$(grep '^herdr_workspace_id=' "$LOCK_WAIT_META" | cut -d= -f2-)" = "$LOCK_WAIT_WSID" ] \ + || fail "opt-in resume lock-wait flattened the task into a different workspace" +LOCK_WAIT_NEW_PANE=$(grep '^herdr_pane_id=' "$LOCK_WAIT_META" | cut -d= -f2-) +[ "$LOCK_WAIT_NEW_PANE" != "$LOCK_WAIT_OLD_PANE" ] \ + || fail "opt-in resume lock-wait reused the old husk pane" +if lab pane get "$LOCK_WAIT_OLD_PANE" >/dev/null 2>&1; then + fail "opt-in resume lock-wait left the old husk pane behind" +fi +assert_focus_is "$LOCK_WAIT_FOCUS" "resume lock-wait" +teardown_task "$LOCK_WAIT_ID" "$HOME_DIR" > "$TMP_ROOT/lock-wait-teardown.out" 2> "$TMP_ROOT/lock-wait-teardown.err" \ + || fail "resume lock-wait fixture teardown failed: $(cat "$TMP_ROOT/lock-wait-teardown.err")" +"$REAL_TREEHOUSE" return --force "$LOCK_WAIT_OLD_WT" >/dev/null 2>&1 || true +"$REAL_TREEHOUSE" return --force "$LOCK_WAIT_NEW_WT" >/dev/null 2>&1 || true +pass "real Herdr lab: --herdr-resume-lock-wait waits out session lock contention instead of refusing" + # Seed a legacy old-format primary projection and a flat secondmate tab; correction must not migrate them. LEGACY_OUT=$(lab workspace create --cwd "$PROJECT_DIR" --label "firstmate/legacy-seed · p:AbCdEfGhIjKlMnOpQrStUv" --no-focus) \ || fail "could not seed a legacy old-format presentation space" diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 7331647e5f0..9b3f9c167c9 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -2956,7 +2956,7 @@ TS } test_working_ship_geometry_and_lifecycle() { - local fixture out status version + local fixture out status version standalone_ship if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then echo "skip: node or npm not found for Pi Calm working-ship test" return 0 @@ -2968,6 +2968,17 @@ test_working_ship_geometry_and_lifecycle() { version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") record_pi_version_evidence "$version" "Pi Calm working-ship assumptions" + # The standalone Pi Calm extension is a separate project that installs its own boat + # in the same Pi working-row slot, so the dual-install check below reads its real + # module when it is installed: a rename on either side then renders two boats and + # fails there, instead of passing against a key this test invented. The pinned slot + # contract inside the program covers a machine without that extension. + standalone_ship=${FM_STANDALONE_CALM_SHIP:-} + if [ -z "$standalone_ship" ] && [ -f "${HOME:-}/.pi/agent/extensions/calm/lib/working-ship.ts" ]; then + standalone_ship=${HOME:-}/.pi/agent/extensions/calm/lib/working-ship.ts + fi + [ -f "$standalone_ship" ] || standalone_ship= + fixture="$TMP_ROOT/working-ship" mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works" cp "$EXT" "$fixture/fm-calm.ts" @@ -2984,7 +2995,7 @@ test_working_ship_geometry_and_lifecycle() { ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" printf '%s\n' '{"type":"module"}' >"$fixture/package.json" - out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' + out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" STANDALONE_CALM_SHIP="$standalone_ship" node --input-type=module 2>&1 <<'JS' import { pathToFileURL } from "node:url"; const packageRoot = process.env.PI_PACKAGE_DIR; @@ -3642,6 +3653,39 @@ const reset = () => { }; const shipWidget = () => ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY); +let standaloneDisposed = false; +const standaloneWidget = { + render: () => ["standalone boat"], + dispose: () => { standaloneDisposed = true; }, +}; +const firstmateWidget = { + render: () => ["firstmate boat"], + dispose: () => {}, +}; +// The standalone Pi Calm extension installs its boat in the same Pi working-row +// widget slot. Where that extension is installed, the slot key comes from its own +// module, so a rename on either side registers two widgets and fails here rather +// than passing against a key this test invented; the pinned slot is the shared +// contract both implementations must keep. +const STANDALONE_SLOT = "calm-working-ship"; +let standaloneSlot = STANDALONE_SLOT; +if (process.env.STANDALONE_CALM_SHIP) { + const standaloneShip = await import( + `${pathToFileURL(process.env.STANDALONE_CALM_SHIP).href}?standalone=${Date.now()}` + ); + standaloneSlot = standaloneShip.CALM_WORKING_SHIP_WIDGET_KEY; +} +ui.setWidget(standaloneSlot, () => standaloneWidget); +ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => firstmateWidget); +const renderedDualInstallWidgets = [...ui.widgets.values()].map((widget) => widget.render(80)); +check( + standaloneDisposed && + renderedDualInstallWidgets.length === 1 && + renderedDualInstallWidgets[0][0] === "firstmate boat", + `dual Calm install rendered ${renderedDualInstallWidgets.length} working widgets instead of one`, +); +ui.setWidget(standaloneSlot, undefined); + // --- Calm off leaves Pi's stock working behavior completely untouched ------------- await fire("session_start", { reason: "startup" }); reset(); @@ -3849,6 +3893,25 @@ check(liveTimers === 1, "a later run did not use the boat after an idle Calm tog await fire("agent_settled"); check(liveTimers === 0, "the later run did not clean up"); +await fire("agent_start"); +let survivingStandaloneDisposed = false; +const survivingStandaloneWidget = { + render: () => ["standalone boat"], + dispose: () => { survivingStandaloneDisposed = true; }, +}; +ui.setWidget(standaloneSlot, () => survivingStandaloneWidget); +reset(); +await calmCommand.handler("", ctx); +check( + !survivingStandaloneDisposed && + ui.widgets.size === 1 && + ui.widgets.get(standaloneSlot) === survivingStandaloneWidget && + ui.widgetOps.length === 0 && + ui.workingVisible.length === 0, + "turning Firstmate Calm off cleared or exposed the standalone working ship", +); +ui.setWidget(standaloneSlot, undefined); + // --- The visual-only widget never touches session, transcript, or export data ------ check( sessionWrites.length === 0, @@ -3863,6 +3926,11 @@ JS [ "$status" -eq 0 ] || fail "Pi Calm working-ship checks failed: $out" [ -z "$out" ] || fail "Pi Calm working-ship test printed output: $out" pass "Pi Calm working ship keeps its centered two-row asymmetric Unicode boat inside a deterministic long-wave trough, paints all water standard blue and the whole boat standard yellow with balanced resets, keeps ANSI-stripped width exact, reverses cleanly at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and leaves Calm-off visibility untouched" + if [ -n "$standalone_ship" ]; then + pass "Pi Calm dual-install coverage read the installed standalone Pi Calm extension's own working-ship slot from $standalone_ship" + else + pass "SKIP: no standalone Pi Calm extension is installed, so dual-install coverage used the pinned shared-slot contract; set FM_STANDALONE_CALM_SHIP to check one" + fi } # The rendered-DOM assertions below depend on a real browser, so the render step diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 06770e8d25c..8e9dfdce83d 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -907,6 +907,54 @@ test_worker_account_pin_follows_the_relaunch() { pass "fm-control relaunch: the replacement follows the home's current worker account pin" } +test_pi_exclude_tools_follow_the_relaunch() { + local dir out rc id=rl-pi-excl + dir=$(new_case pi-exclude "$id") + add_ship_task "$dir" "$id" pi + printf pi > "$dir/fake/command" + printf pi > "$dir/fake/becomes" + printf '#!/usr/bin/env bash\nprintf "Options: --tui-mode\\n"\n' > "$dir/fakebin/pi" + chmod +x "$dir/fakebin/pi" + mkdir -p "$dir/home/config" + printf '%s\n' '# hide writes' 'mcp__srv__writeTool' 'mcp__srv__adminTool' > "$dir/home/config/crew-exclude-tools" + out=$(run_control "$dir" "$id" relaunch --note "keep exclusions"); rc=$? + expect_code 0 "$rc" "a Pi relaunch with exclusions should succeed"$'\n'"$out" + assert_contains "$(cat "$dir/fake/literal")" "--exclude-tools 'mcp__srv__writeTool,mcp__srv__adminTool'" \ + "the relaunched Pi worker must keep the home's tool exclusions" + rm "$dir/home/config/crew-exclude-tools" + : > "$dir/fake/literal" + printf pi > "$dir/fake/command" + out=$(run_control "$dir" "$id" relaunch --note "exclusions removed"); rc=$? + expect_code 0 "$rc" "a Pi relaunch after the file is removed should succeed"$'\n'"$out" + assert_not_contains "$(cat "$dir/fake/literal")" "--exclude-tools" \ + "a relaunch without the file must launch with no exclusions" + pass "fm-control relaunch: a Pi replacement keeps the home's tool exclusions" +} + +test_exclude_tools_refusals_happen_before_the_agent_stops() { + local dir out rc id=rl-excl-refuse + dir=$(new_case excl-refuse "$id") + add_ship_task "$dir" "$id" pi + printf pi > "$dir/fake/command" + printf pi > "$dir/fake/becomes" + printf '#!/usr/bin/env bash\nprintf "Options: --tui-mode\\n"\n' > "$dir/fakebin/pi" + chmod +x "$dir/fakebin/pi" + mkdir -p "$dir/home/config" + printf '%s\n' 'two words' > "$dir/home/config/crew-exclude-tools" + out=$(run_control "$dir" "$id" relaunch --note "bad list"); rc=$? + expect_code 1 "$rc" "a malformed exclusion list must refuse the relaunch" + assert_contains "$out" "config/crew-exclude-tools has a malformed entry" "refusal must name the entry" + [ "$(cat "$dir/fake/command")" = pi ] || fail "a malformed exclusion list stopped the running agent" + [ ! -s "$dir/fake/literal" ] || fail "a refused relaunch sent lifecycle input" + printf '%s\n' 'mcp__srv__writeTool' > "$dir/home/config/crew-exclude-tools" + out=$(run_control "$dir" "$id" relaunch --harness codex --note "switch runtime"); rc=$? + expect_code 1 "$rc" "relaunching onto a runtime that cannot hide tools must refuse" + assert_contains "$out" "config/crew-exclude-tools" "refusal must name the config file" + [ "$(cat "$dir/fake/command")" = pi ] || fail "an unhonorable exclusion list stopped the running agent" + [ ! -s "$dir/fake/literal" ] || fail "a refused relaunch sent lifecycle input" + pass "fm-control relaunch: exclusion-list refusals happen before the running agent stops" +} + test_explicit_model_wins_over_the_recorded_one() { local dir out rc dir=$(new_case explicit rl7) @@ -2598,6 +2646,8 @@ test_native_ultra_relaunch_preserves_profile_and_rejects_before_stop test_signed_out_worker_account_pin_refuses_before_stop test_seated_codex_relaunch_preflights_current_dock_before_stop test_worker_account_pin_follows_the_relaunch +test_pi_exclude_tools_follow_the_relaunch +test_exclude_tools_refusals_happen_before_the_agent_stops test_explicit_model_wins_over_the_recorded_one test_relaunch_onto_an_unverified_harness_is_refused test_prior_harness_turnend_registry_entry_is_cleared diff --git a/tests/fm-control.test.sh b/tests/fm-control.test.sh index 36982c947fb..a90e4d14abf 100755 --- a/tests/fm-control.test.sh +++ b/tests/fm-control.test.sh @@ -1109,6 +1109,23 @@ test_footer_busy_pi_on_tmux_refuses_exit() { pass "fm-control exit: a footer-busy Pi on tmux refuses instead of typing /quit" } +test_exit_drops_meta_busy_gen_with_the_sidecar() { + local dir out rc gen changed + dir=$(new_case codex-retire) + add_task "$dir" t1 codex + alive_as "$dir" codex + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$dir/home/state" t1) + printf 'busy_gen=%s\n' "$gen" >> "$dir/home/state/t1.meta" + grep -v '^busy_gen=' "$dir/home/state/t1.meta" > "$dir/expected.meta" + out=$(run_control "$dir" t1 exit); rc=$? + expect_code 0 "$rc" "exiting a codex agent should succeed"$'\n'"$out" + [ ! -e "$dir/home/state/t1.busy-gen" ] && [ ! -e "$dir/home/state/t1.busy-state" ] \ + || fail "exit should retire the busy sidecar and record" + changed=$(diff "$dir/expected.meta" "$dir/home/state/t1.meta") \ + || fail "exit should drop only busy_gen from the task record:"$'\n'"$changed" + pass "fm-control exit: retiring a codex incarnation drops busy_gen with the sidecar" +} + test_interrupt_without_acknowledgement_preserves_busy_state() { local dir gen before after out rc dir=$(new_case unconfirmed) @@ -1120,6 +1137,8 @@ test_interrupt_without_acknowledgement_preserves_busy_state() { out=$(run_control "$dir" t1 interrupt); rc=$? expect_code 0 "$rc" "an interrupt without acknowledgement should still deliver"$'\n'"$out" after=$(cat "$dir/home/state/t1.busy-state") + grep -q "^busy_gen=$gen$" "$dir/home/state/t1.meta" \ + || fail "an interrupt must leave the task record's busy_gen in place" [ "$after" = "$before" ] || fail "an unconfirmed interrupt must preserve adapter-owned busy state" assert_contains "$out" "verified=agent-alive cancel=unconfirmed" \ "the result should distinguish delivery proof from unconfirmed cancellation" @@ -1218,6 +1237,8 @@ test_agent_that_does_not_stop_fails_closed() { || fail "a stubborn busy agent should receive its interrupt sequence" [ "$(literals "$dir")" = /exit ] \ || fail "a stubborn busy agent should receive its exit command" + grep -q "^busy_gen=$gen$" "$dir/home/state/t1.meta" \ + || fail "a failed exit must leave busy_gen in the task record" pass "fm-control exit: a stubborn agent reports delivered input and an unconfirmed exit" } @@ -1366,6 +1387,7 @@ test_ambiguous_endpoint_refuses test_busy_agent_is_interrupted_before_the_exit_command test_idle_agent_is_not_interrupted test_footer_busy_pi_on_tmux_refuses_exit +test_exit_drops_meta_busy_gen_with_the_sidecar test_exit_refuses_an_open_background_picker test_exit_refuses_the_confirming_enter test_exit_names_a_picker_that_renders_after_the_submit diff --git a/tests/fm-remote-reply.test.sh b/tests/fm-remote-reply.test.sh index f5e8aef0c5d..92f6d9e73d0 100755 --- a/tests/fm-remote-reply.test.sh +++ b/tests/fm-remote-reply.test.sh @@ -1024,6 +1024,15 @@ assert_absent "$PARENT/state/procevent/$SID.source" "continuity break was re-arm remote_env "$ADAPTER" ingest ios "$RESULT_TWELVE" >/dev/null 2>&1 || true [ "$(grep -cF 'blocked [key=remote-reply-continuity-ios]' "$PARENT/state/ios.status")" -eq 1 ] \ || fail "continuity replay duplicated the escalation" +first_offset=$(sed -n 's/^offset=//p' "$PARENT/state/remote-replies/ios.cursor") +first_hash=$(sed -n 's/^prefix_sha256=//p' "$PARENT/state/remote-replies/ios.cursor" | tr 'A-F' 'a-f') +first_prefix=$(printf '%.12s' "$first_hash") +assert_grep "at offset ${first_offset} prefix ${first_prefix} retirements 0" "$PARENT/state/ios.status" \ + "continuity break did not record the reader position" +assert_no_grep "prefix ${first_hash}" "$PARENT/state/ios.status" \ + "continuity break recorded the full prefix hash" +assert_absent "$PARENT/state/remote-replies/ios.retirements" \ + "a route that has never been retired gained a retirement count" status_line_at_epoch "$(grep -F 'blocked [key=remote-reply-continuity-ios]' "$PARENT/state/ios.status")" >/dev/null \ || fail "new continuity escalation has unknown emission time" if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then @@ -1064,8 +1073,229 @@ remote_env "$ADAPTER" handle ios "$GEN" "$RESULT_TWELVE" >/dev/null 2>&1 || [ "$ || fail "pending continuity result could not be acknowledged after retirement refusal" remote_env "$ADAPTER" retire ios >/dev/null assert_absent "$PARENT/state/remote-replies/ios.cursor" "adapter retirement left its cursor" +recorded_retirements=$(cat "$PARENT/state/remote-replies/ios.retirements" 2>/dev/null || true) +[ "$recorded_retirements" = count=1 ] \ + || fail "adapter retirement did not record its count (got: ${recorded_retirements:-absent})" assert_absent "$PARENT/state/remote-replies/ios.caught-up" \ "adapter retirement left a caught-up watermark a later route could inherit" pass "remote reply retirement quiesces and refuses unhandled captured results" +# Empty the remote log under the committed cursor and handle the break the +# next blocking source reports. Sets RESULT_BREAK. +break_repaired_route() { #