From daaffdb5116e264ca1e3b6bd2e0839a88adf5231 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Fri, 18 Sep 2026 14:48:15 -0400 Subject: [PATCH 001/188] fix(bin): create captain-hold rows when Beads requires due (#4854) Captain holds have no due semantics and are a hold kind, not a Beads issue type. The create path now waives due.required and maps to native type task. Co-authored-by: Cursor --- bin/fm-captain-hold.sh | 25 ++++++++++++------ docs/configuration.md | 3 +++ tests/fm-captain-hold-lifecycle.test.sh | 34 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index facc86505cb..0b7e3f9c1cb 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -40,12 +40,18 @@ # task first when no work item exists to hold (--title required to create; the # optional --origin records provenance in the new task's body and supplies the # default repo from that origin's metadata). Prefer holding the work item the -# question gates over minting a new row. The command records a UTC `Captain -# hold set:` timestamp in the task body: repeating an active hold preserves the -# existing timestamp, while re-holding released work starts a new lifecycle. -# A task already closed is refused rather than reopened. `--until` records the -# captain's own deferral date through `tasks-axi hold --until`, so a "revisit -# later" answer is stored as a date instead of a live card. +# question gates over minting a new row. Creating a missing row uses +# `tasks-axi add --kind captain`: that kind is backlog metadata, and the Beads +# adapter maps it to native issue type `task`. Captain holds have no due +# semantics (`--until` is the optional hold deferral), so the create waives +# Beads `due.required` through `BD_DUE_REQUIRED` rather than inventing a due +# date or registering a `types.custom` captain issue type. The command records +# a UTC `Captain hold set:` timestamp in the task body: repeating an active +# hold preserves the existing timestamp, while re-holding released work starts +# a new lifecycle. A task already closed is refused rather than reopened. +# `--until` records the captain's own deferral date through `tasks-axi hold +# --until`, so a "revisit later" answer is stored as a date instead of a live +# card. # # `answer` records the captain's exact words and resolves the call in the same # act. It requires a non-empty captain decision file of at most 8192 bytes and @@ -864,11 +870,14 @@ command_hold() { [ -n "$repo" ] || repo=firstmate validate_one_line repo "$repo" [ -z "$origin" ] || body=$(printf 'Origin: %s' "$origin") + # tasks-axi add never passes --due. Beads due.required would refuse this + # create, and captain holds have no due semantics, so waive it for this + # call only. --kind captain stays metadata; Beads native type is task. if [ -n "$body" ]; then - tasks_axi add "$id" "$title" --kind captain --repo "$repo" --body "$body" >/dev/null \ + BD_DUE_REQUIRED=false tasks_axi add "$id" "$title" --kind captain --repo "$repo" --body "$body" >/dev/null \ || fail "could not create task $id" else - tasks_axi add "$id" "$title" --kind captain --repo "$repo" >/dev/null \ + BD_DUE_REQUIRED=false tasks_axi add "$id" "$title" --kind captain --repo "$repo" >/dev/null \ || fail "could not create task $id" fi fi diff --git a/docs/configuration.md b/docs/configuration.md index f23b741243e..2ab5ddc66fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,6 +98,9 @@ Both choices are local to each Firstmate home and are not part of secondmate inh The tracked `.tasks.toml` pins the default `tasks-axi` markdown backend to `data/backlog.md`, with `done_keep = 10` and an archive at `data/done-archive.md`. A home may instead select another tasks-axi adapter such as Beads through its own `.tasks.toml` or `TASKS_AXI_BACKEND`; firstmate still uses only tasks-axi verbs for routine backlog reads and mutations, and the adapter maps `start` and evidence-bearing `done` transitions to its native statuses and evidence fields. +Captain-hold row creation is owned by [`bin/fm-captain-hold.sh`](../bin/fm-captain-hold.sh) `hold`: when no work item exists, it creates an ordinary backlog row (`--kind captain` metadata; Beads native type `task`) and then applies the captain hold. +Captain rows have no Beads due semantics, so that create path waives a Beads `due.required` setting rather than passing a synthetic `--due`; `--until` remains the optional hold deferral. +Do not register a Beads `types.custom` `captain` type for this: captain is a hold kind, and the fleet Beads `due.required` policy for ordinary work stays in the federated beads config. When the automatic transition gate applies, dispatch and completion are not separate operator actions: each moves its work item inside the same run that creates or removes the task's record, so the ordinary successful path cannot leave the backlog and live task set out of sync ([`bin/fm-backlog-transition-lib.sh`](../bin/fm-backlog-transition-lib.sh)). Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. Completion refuses to report success until the item is closed, and session start reconciles this home's own books after an interrupted run. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index fd496a82cd4..5f06db5b4b6 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -626,6 +626,39 @@ SH pass "captain-hold mutations address the beads backend without a markdown override" } +# A Beads workspace with due.required and no types.custom captain type is the +# live fleet shape. hold must still create a fresh captain row there: waive +# due rather than invent one, and map to native type task rather than register +# a Beads captain issue type. +test_hold_creates_a_captain_row_when_beads_requires_due_without_custom_type() { + local fixture home beads id show issue_type + require_tasks_axi_beads "captain-hold create under due.required without types.custom" || return 0 + fixture=$(make_beads_home due-required-no-custom-type) + home=${fixture%%|*} + beads=${fixture##*|} + printf '\ndue:\n required: true\n' >> "$beads/config.yaml" + if bdrow "$beads" create "raw task" --id fm-raw-task --type task --json >/dev/null 2>&1; then + fail "bd created a task without --due; the due.required fixture is not in force" + fi + if bdrow "$beads" create "raw captain" --id fm-raw-captain --type captain --due 2099-01-01 --json >/dev/null 2>&1; then + fail "bd accepted --type captain; the fixture still has a types.custom captain registration" + fi + id=fm-fresh-captain-call + run_captain "$home" hold "$id" --title "Choose the sample route" \ + --reason "captain must decide" --repo sample >/dev/null \ + || fail "hold could not create a captain row under due.required without types.custom" + show=$(tasks_in "$home" show "$id") || fail "the created captain row is missing" + assert_contains "$show" "hold_kind: captain" \ + "the created row is not captain-held" + assert_contains "$show" "kind: captain" \ + "the created row lost its captain backlog kind" + issue_type=$(bdrow "$beads" show "$id" --json \ + | jq -r 'if type == "array" then .[0].issue_type else .issue_type end') + [ "$issue_type" = task ] \ + || fail "create did not map to native Beads type task, got ${issue_type:-empty}" + pass "hold creates a captain row when Beads requires due and has no captain type" +} + # Reproduces the loss exactly with privacy-safe synthetic names: the investigation # and visual review have ended, the only genuine unresolved captain call is report # prose, no held backlog item or open status exists, and the authoritative @@ -4042,3 +4075,4 @@ test_complete_accepts_a_migrated_inventory_on_beads test_verify_names_the_unresolvable_legacy_id_once test_verify_resolves_a_pre_collapse_key_through_its_derived_marker test_captain_hold_mutations_address_the_beads_backend +test_hold_creates_a_captain_row_when_beads_requires_due_without_custom_type From 1bb72cc5f88014c86e3d03244efa0bb26c22d001 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:12:32 -0700 Subject: [PATCH 002/188] fix: disable compact adviser for spawned agents (#4877) * feat(bin): launch every spawned agent with the compact adviser disabled Every crewmate, scout, and secondmate Firstmate launches now starts with COMPACT_ADVISER_DISABLE=1, on a fresh spawn and on a relaunch alike, so an unattended session never activates the compact adviser. The value is unconditional: no configuration file gates it and there is no override, unlike the trace carrier beside it. Three carriers deliver it, because no single one covers every launch shape. The pane shell receives an export beside GOTMPDIR, so the agent's own children inherit it too. The launch command carries an explicit assignment, prepended outermost so it wins over any ambient value the pane already held. The cleared launch environment sets it again at the `env -i` boundary and keeps COMPACT_ADVISER_DISABLE in the fixed operational floor, which is what preserves the switch when config/launch-env-allowlist empties the environment, and what delivers it on a remote host that never had the value. bin/fm-control.sh relaunch, the bootstrap secondmate relaunch, and the remote secondmate transport all rebuild their launch through bin/fm-spawn.sh, so they inherit the same floor. The captain's own primary session is untouched. The two new suites drive the real spawn and then execute the launch command the pane actually received, with the harness replaced by a probe that prints its own environment, rather than matching script text. They cover ship and secondmate launches with the allowlist absent and enabled, the pane export and its ordering, fm-control.sh relaunch, and the full parent to remote-host chain. * no-mistakes(review): Export compact-adviser disable across compound launches * no-mistakes(document): Document spawned-agent compact-adviser environment guarantee --- bin/fm-spawn.sh | 37 +- bin/fm-test-run.sh | 2 + docs/configuration.md | 10 +- tests/fixtures.sh | 23 +- tests/fm-kimi-harness.test.sh | 4 +- ...awn-compact-adviser-disable-remote.test.sh | 187 ++++++++++ .../fm-spawn-compact-adviser-disable.test.sh | 346 ++++++++++++++++++ tests/fm-spawn-dispatch-profile.test.sh | 9 +- 8 files changed, 608 insertions(+), 10 deletions(-) create mode 100755 tests/fm-spawn-compact-adviser-disable-remote.test.sh create mode 100755 tests/fm-spawn-compact-adviser-disable.test.sh diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fa8da51d9ea..9afb13960c2 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -244,7 +244,10 @@ # TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH HERDR_PANE_ID # CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID CMUX_SOCKET_PATH # ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION, plus the task -# marker FM_TASK_ID that ship and scout panes receive above. +# marker FM_TASK_ID that ship and scout panes receive above, plus the +# compact-adviser kill switch COMPACT_ADVISER_DISABLE, which the floor also +# pins to 1 with a literal assignment so it survives the cleared environment +# even on a host that never had it set. # An enabled task trace also retains TRACEPARENT. Explicit Firstmate launch # assignments still apply inside the filtered environment. Raw commands must # be POSIX sh compatible under this opt-in; the absent-file path is unchanged. @@ -4412,6 +4415,19 @@ if [ "$KIND" = secondmate ]; then # injected carrier and this on/off snapshot are guaranteed to agree. LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=$sq_primary_home FM_HOME=$sq_home FM_TRACE_CONTEXT=$SPAWN_TRACE_EFFECTIVE FM_SUPERVISION_MODEL=$supervision_model $LAUNCH" fi +# Every agent this fleet launches - crewmate, scout, and secondmate, on a fresh +# spawn and on a relaunch alike - runs with the compact-adviser kill switch on. +# This is an export statement rather than a forwarded ambient name or a +# command-prefix assignment, so it carries the value across an entire compound +# raw launch expression. A pane that never had it, and a remote host whose +# transport never carried it, both still start the agent with it set. It is +# unconditional, with no config file or flag gating it, and is inserted outside +# every generated launch prefix; relaunch trace cleanup may execute first but +# cannot change this value. The cleared-environment floor in the +# LAUNCH_ENV_PREFIX construction below sets it again at the `env -i` boundary, +# so under an enabled allowlist the switch is established before the wrapping +# `/bin/sh` starts rather than only inside the command that shell runs. +LAUNCH="export COMPACT_ADVISER_DISABLE=1; $LAUNCH" if [ -z "$SPAWN_TRACEPARENT" ] && [ "$RELAUNCH" -eq 1 ]; then LAUNCH="unset TRACEPARENT; $LAUNCH" fi @@ -4446,6 +4462,10 @@ spawn_record_traceparent() { # process (go build, go test, ...) inherit it. Sent before the launch command so # the env is set when the agent starts; the brief sleep lets the export land. spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" +# Export the compact-adviser kill switch into the pane shell through the same +# pre-launch channel, so later commands in that shell inherit it too. The launch +# command independently establishes the value for the agent process itself. +spawn_send_text_line "$T" "export COMPACT_ADVISER_DISABLE=1" # Mark the pane as a task worker so bin/fm-test-run.sh can refuse to run the # suite in the repository's primary checkout. Ship and scout workers are the # ones assigned an isolated worktree; a secondmate runs its own home instead. @@ -4473,11 +4493,14 @@ if [ -n "$SPAWN_TRACEPARENT" ]; then fi if [ "$LAUNCH_ENV_ENABLED" = 1 ]; then LAUNCH_ENV_PREFIX='/usr/bin/env -i' + # COMPACT_ADVISER_DISABLE is the intentional declarative floor-membership + # entry; the explicit COMPACT_ADVISER_DISABLE=1 assignment below is the + # authoritative setter. for env_name in HOME PATH USER LOGNAME SHELL TERM COLORTERM LANG LC_ALL LC_CTYPE \ TMPDIR TMP TEMP GOTMPDIR TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH \ HERDR_PANE_ID CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID \ CMUX_SOCKET_PATH ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION \ - FM_TASK_ID \ + FM_TASK_ID COMPACT_ADVISER_DISABLE \ $LAUNCH_ENV_NAMES; do # Only validated names enter shell syntax. Values expand once, quoted, in # the pane shell and never become source text or spawn-process snapshots. @@ -4485,6 +4508,16 @@ if [ "$LAUNCH_ENV_ENABLED" = 1 ]; then printf -v env_arg '${%s+"%s=$%s"}' "$env_name" "$env_name" "$env_name" LAUNCH_ENV_PREFIX="$LAUNCH_ENV_PREFIX $env_arg" done + # COMPACT_ADVISER_DISABLE is retained by the floor loop above, which forwards + # whatever the pane export set, and then pinned here to the one value Firstmate + # launches on. The literal assignment comes last deliberately: `env` applies + # assignments left to right, so this one wins over a forwarded pane value, and + # it still delivers the switch on a pane whose export never landed. Unlike the + # trace carrier below it carries no gate, so it is appended unconditionally. + # Setting it here rather than relying on the assignment already carried by + # $LAUNCH is what gives the wrapping `/bin/sh` itself the switch, not only the + # agent command it runs. + LAUNCH_ENV_PREFIX="$LAUNCH_ENV_PREFIX COMPACT_ADVISER_DISABLE=1" if [ -n "$SPAWN_TRACEPARENT" ]; then # shellcheck disable=SC2016 LAUNCH_ENV_PREFIX="$LAUNCH_ENV_PREFIX "'${TRACEPARENT+"TRACEPARENT=$TRACEPARENT"}' diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index b939101c943..44e8da93bcc 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -371,6 +371,8 @@ family_for_basename() { fm-send-inbox.test.sh|fm-spawn-batch.test.sh|\ fm-spawn-dispatch-profile.test.sh|fm-claude-trust.test.sh|\ fm-trace-context-spawn.test.sh|fm-spawn-worktree-settle.test.sh|\ + fm-spawn-compact-adviser-disable.test.sh|\ + fm-spawn-compact-adviser-disable-remote.test.sh|\ fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch ;; diff --git a/docs/configuration.md b/docs/configuration.md index 2ab5ddc66fd..67b868a49d0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -370,7 +370,7 @@ The [Claude adapter reference](../.agents/skills/harness-adapters/references/har ## Worker launch environment (config/launch-env-allowlist) The optional local, gitignored `config/launch-env-allowlist` limits the ambient environment passed to newly launched workers, scouts, and secondmates, including relaunches. -With no file, launch behavior is unchanged: selected harness markers are cleared, while the provider, long-lived terminal daemon, and shell initialization determine which other variables reach the worker. +With no file, ambient inheritance remains unfiltered: selected harness markers are cleared, while the provider, long-lived terminal daemon, and shell initialization determine which other variables reach the worker. Do not assume every worker inherits the invoking Firstmate process's current environment. The file is inherited into secondmate homes through the [primary-authoritative configuration contract](../.agents/skills/secondmate-provisioning/SKILL.md). Changes apply to subsequent launches; existing processes keep their environment. @@ -388,7 +388,7 @@ OPENAI_API_KEY SSH_AUTH_SOCK ``` -Firstmate retains basic home, executable search, terminal, locale, temporary-directory, and backend routing variables, plus its explicit launch assignments, its ship and scout task marker, and enabled task trace. +Firstmate retains basic home, executable search, terminal, locale, temporary-directory, and backend routing variables, plus its explicit launch assignments, its ship and scout task marker, the compact-adviser kill switch described below, and enabled task trace. [`fm-spawn.sh --help`](../bin/fm-spawn.sh) owns the exact retained names and parsing mechanics. Other ambient names must be listed explicitly, including custom credential-store locations, proxy settings, and certificate overrides when required by the selected tools. The command shell and worker may still create their own variables. @@ -413,6 +413,12 @@ The filter runs at the worker command boundary, after the terminal daemon and pa This is not a sandbox: it cannot revoke same-user access to credential files, prevent tools or later shells from loading credentials again, or isolate processes from the same user's other processes. Regression coverage executes emitted launch commands with synthetic nonsecret values in [`tests/fm-spawn-dispatch-profile.test.sh`](../tests/fm-spawn-dispatch-profile.test.sh). +Every crewmate, scout, and secondmate Firstmate launches starts with `COMPACT_ADVISER_DISABLE=1` in its environment, on a fresh spawn and on a relaunch alike, so an unattended session never activates the compact adviser. +This guarantee also covers raw launch commands, remote secondmates, and launches filtered by `config/launch-env-allowlist`; it does not depend on the destination environment already containing the variable. +Firstmate provides no configuration or flag to change this value. +This applies only to agents Firstmate launches; the captain's own primary Firstmate session is never given the variable. +[`fm-spawn.sh --help`](../bin/fm-spawn.sh) owns the delivery mechanics, with focused regression coverage in [`tests/fm-spawn-compact-adviser-disable.test.sh`](../tests/fm-spawn-compact-adviser-disable.test.sh) and [`tests/fm-spawn-compact-adviser-disable-remote.test.sh`](../tests/fm-spawn-compact-adviser-disable-remote.test.sh). + Every claude launch's inline `--settings` JSON also carries `"attribution":{"commit":"","pr":"","sessionUrl":false}`, so a spawned worker never writes a Co-Authored-By trailer, Claude-Session link, or generated-with line into a commit or PR body regardless of which settings scopes end up loaded. ## Crew dispatch profiles (config/crew-dispatch.json) diff --git a/tests/fixtures.sh b/tests/fixtures.sh index 043d350012e..a28ef4f9e29 100755 --- a/tests/fixtures.sh +++ b/tests/fixtures.sh @@ -94,7 +94,9 @@ fm_test_fake_gh_axi() { # fm_test_fake_tmux_spawn # Spawn-world tmux: pane_current_path from FM_FAKE_PANE_PATH, session named # firstmate, window ops succeed, send-keys succeed. When FM_FAKE_LAUNCH_LOG is -# set, each send-keys -l payload is appended one per line. Optional +# set, each send-keys -l payload is appended one per line. When FM_FAKE_PANE_LOG +# is set, each send-keys TEXT-LINE payload (the pre-launch pane exports, which +# carry no -l) is appended there instead, one per line in send order. Optional # FM_FAKE_DUPLICATE_WINDOW is printed from list-windows. # # The pane path defaults to empty when FM_FAKE_PANE_PATH is unset. Window @@ -127,6 +129,25 @@ case "${1:-}" in prev=$a done fi + # The pre-launch pane exports ride the text-line form + # (`send-keys -t Enter`), which carries no -l flag, so a + # suite that asserts on what the pane shell received opts in with its own + # log. Skip the flags, the target, and the trailing key so only the payload + # is recorded, one per line, in send order. + if [ -n "${FM_FAKE_PANE_LOG:-}" ]; then + shift + skip_next= + literal= + for a in "$@"; do + if [ -n "$skip_next" ]; then skip_next=; continue; fi + case "$a" in + -t) skip_next=1; continue ;; + -l) literal=1; continue ;; + Enter|C-m) continue ;; + *) [ -n "$literal" ] || printf '%s\n' "$a" >> "$FM_FAKE_PANE_LOG" ;; + esac + done + fi exit 0 ;; esac diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index e08674d3fe8..1cdba59392a 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -286,7 +286,7 @@ test_kimi_launch_then_send_is_verified() { assert_contains "$out" "spawned $id harness=kimi" "kimi spawn did not report success" launch=$(cat "$CASE_DIR/launch.log") - [ "$launch" = "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI '$FAKEBIN_DIR/kimi' --model 'kimi-code/k3' --auto" ] \ + [ "$launch" = "export COMPACT_ADVISER_DISABLE=1; env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI '$FAKEBIN_DIR/kimi' --model 'kimi-code/k3' --auto" ] \ || fail "kimi launch did not use the absolute binary, model, and --auto only: $launch" assert_not_contains "$launch" "--effort" "kimi launch emitted a nonexistent effort flag" assert_not_contains "$launch" "turn-ended" "kimi launch embedded a turn-end path" @@ -545,7 +545,7 @@ test_kimi_falls_back_to_expanded_home_binary() { rc=$? expect_code 0 "$rc" "Kimi HOME fallback spawn should succeed" launch=$(cat "$CASE_DIR/launch.log") - [ "$launch" = "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI '$fallback' --auto" ] \ + [ "$launch" = "export COMPACT_ADVISER_DISABLE=1; env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI '$fallback' --auto" ] \ || fail "Kimi fallback did not expand HOME into an absolute executable: $launch" pass "fm-spawn: Kimi fallback expands the active HOME" } diff --git a/tests/fm-spawn-compact-adviser-disable-remote.test.sh b/tests/fm-spawn-compact-adviser-disable-remote.test.sh new file mode 100755 index 00000000000..b4ce6f459b9 --- /dev/null +++ b/tests/fm-spawn-compact-adviser-disable-remote.test.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# tests/fm-spawn-compact-adviser-disable-remote.test.sh - the compact-adviser +# kill switch must reach a second mate that Firstmate launches on another host. +# +# A remote second mate never reaches the local spawn path covered by +# tests/fm-spawn-compact-adviser-disable.test.sh: bin/fm-spawn.sh routes it +# through spawn_remote_secondmate, which hands the launch across the transport +# to the remote host's own fm-spawn. These assertions drive that real chain - +# parent fm-spawn -> fm-on -> the real remote entrypoint -> +# fm-remote-secondmate-control -> the remote host's fm-spawn - against a fake +# herdr CLI, so what the remote pane received is observable, and then execute +# that received command with a probe harness to read back the environment the +# remote agent would have started with. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=tests/remote-herdr-fixture.sh +. "$(dirname "${BASH_SOURCE[0]}")/remote-herdr-fixture.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +TMP_ROOT=$(fm_test_tmproot fm-remote-compact-adviser) +mkdir -p "$TMP_ROOT" +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +PARENT="$TMP_ROOT/parent" +REMOTE_ROOT="$TMP_ROOT/remote-root" +REMOTE_HOME="$TMP_ROOT/remote-home" +FAKEBIN=$(fm_fakebin "$TMP_ROOT/fake") +PROBEBIN="$TMP_ROOT/probebin" +HERDR_LOG="$TMP_ROOT/remote-herdr.log" +HERDR_STATE="$TMP_ROOT/remote-herdr.state" +CLAIMS="$TMP_ROOT/claims" +mkdir -p "$PARENT/data" "$PARENT/state" "$PARENT/config" "$PARENT/projects" \ + "$REMOTE_ROOT" "$CLAIMS" "$PROBEBIN" "$TMP_ROOT/pane-home" +trap 'FM_HOME="$PARENT" FM_PROCEVENT_CLAIM_ROOT="$CLAIMS" "$ROOT/bin/fm-procevent.sh" sweep-home >/dev/null 2>&1 || true; if [ -f "$TMP_ROOT/remote-jobs/worker.pid" ]; then kill "$(cat "$TMP_ROOT/remote-jobs/worker.pid")" 2>/dev/null || true; fi; rm -rf -- "$TMP_ROOT"' EXIT + +# A synthetic value the remote launch must override rather than inherit, so a +# launch that only forwarded the ambient environment cannot pass as a floor. +CONTRARY=0 + +# The remote host's tracked code root is this branch, as a real git repository: +# fm-on and the remote entrypoint both require the dispatched command to be +# tracked there, and the remote side runs the real scripts under test. +( + cd "$ROOT" || exit + tar --exclude=.git --exclude=.no-mistakes --exclude=data --exclude=state --exclude=config -cf - . +) | (cd "$REMOTE_ROOT" && tar -xf -) + +# The remote host's own non-second-mate tooling only has to stay resolvable; +# the second mate itself always launches on Herdr, whose fixture logs every +# invocation verbatim. +cat > "$REMOTE_ROOT/bin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +chmod +x "$REMOTE_ROOT/bin/tmux" +install_remote_herdr_fixture "$REMOTE_ROOT" "$HERDR_STATE" "$HERDR_LOG" \ + "$TMP_ROOT/herdr-send-fail" "$TMP_ROOT/herdr.sock" +git -C "$REMOTE_ROOT" init -q -b main +git -C "$REMOTE_ROOT" config user.email test@example.com +git -C "$REMOTE_ROOT" config user.name Test +git -C "$REMOTE_ROOT" add . +git -C "$REMOTE_ROOT" commit -qm 'remote fixture root' + +cat > "$FAKEBIN/fake-ssh" <<'SH' +#!/usr/bin/env bash +while [ "$#" -gt 0 ]; do + case "$1" in -o) shift 2 ;; --) shift; break ;; *) exit 90 ;; esac +done +host=$1 +entry=$2 +shift 2 +[ "$host" = remote-mac ] || exit 91 +[ "$entry" = fm-remote-entrypoint.sh ] || exit 92 +cd "$FM_FAKE_REMOTE_CWD" || exit 93 +# The readiness gate is answered here rather than by the real doctor, which +# would inspect the RUNNER's own account; tests/fm-remote-doctor.test.sh owns +# the doctor's behavior against controlled account fixtures. +if printf '%s' "$4" | base64 --decode 2>/dev/null | tr '\0' '\n' | head -1 | grep -q '^fm-remote-doctor.sh$'; then + printf 'ok: remote second-mate readiness confirmed on this host\n' + exit 0 +fi +exec "$FM_FAKE_REMOTE_ENTRYPOINT" "$@" +SH +chmod +x "$FAKEBIN/fake-ssh" + +# The harness the remote pane would have started, replaced by a probe that +# reports the one environment fact under test. +cat > "$PROBEBIN/codex" <<'SH' +#!/bin/sh +printf '%s\n' "${COMPACT_ADVISER_DISABLE-unset}" +SH +chmod +x "$PROBEBIN/codex" + +printf 'codex\n' > "$PARENT/config/secondmate-harness" +printf 'tmux\n' > "$PARENT/config/backend" +printf 'codex\n' > "$PARENT/config/crew-harness" +printf '## In flight\n\n## Queued\n\n## Done\n' > "$PARENT/data/backlog.md" +printf '%s\n' "$$" > "$PARENT/state/.lock" + +remote_env() { + FM_HOME="$PARENT" \ + FM_ROOT_OVERRIDE="$REMOTE_ROOT" \ + FM_PROCEVENT_CLAIM_ROOT="$CLAIMS" \ + FM_SSH_BIN="$FAKEBIN/fake-ssh" \ + FM_FAKE_REMOTE_ENTRYPOINT="$REMOTE_ROOT/bin/fm-remote-entrypoint.sh" \ + FM_REMOTE_JOB_PLATFORM_OVERRIDE=Linux \ + FM_REMOTE_JOB_STATE_ROOT="$TMP_ROOT/remote-jobs" \ + FM_FAKE_REMOTE_CWD="$TMP_ROOT" \ + FM_SEND_SETTLE=0 FM_SEND_SLEEP=0 \ + "$@" +} + +# What the remote pane received, read back from the fixture's verbatim log. The +# fixture logs one line per invocation as the joined argv, so each payload sits +# between the pane id and the trailing session selector. The Herdr adapter sends +# a pre-launch export as a `pane run` line and the launch command itself as the +# unsubmitted literal `pane send-text`. +remote_pane_payload() { # + sed -n "s/^pane $1 [^ ]* \\(.*\\) --session [^ ]*\$/\\1/p" "$HERDR_LOG" +} +remote_launch_command() { + remote_pane_payload send-text | grep 'encode launch-brief' | tail -1 +} +remote_pane_exports() { + remote_pane_payload run | grep '^export ' +} + +# Provision and register the remote route from the captain-facing primary. +FM_SECONDMATE_CHARTER='Own iOS delivery on the build Mac.' \ + FM_SECONDMATE_SCOPE='iOS implementation and Xcode validation' \ + remote_env "$ROOT/bin/fm-remote-home-seed.sh" ios remote-mac "$REMOTE_ROOT" "$REMOTE_HOME" --no-projects >/dev/null \ + || fail "remote seed did not provision the route under test" + +run_remote_launch() { #