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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,24 @@ fm_backend_kill() { # <backend> <target>
esac
}

# fm_backend_current_path: the live occupied working directory of a running
# target, or nonzero when the backend cannot prove it without interacting with
# the agent. tmux and Herdr expose the foreground process cwd passively. Zellij
# and cmux only support an active shell probe, which is safe during pre-agent
# spawn discovery but would submit `pwd` into a running agent during relaunch;
# Orca exposes no corresponding proof.
fm_backend_current_path() { # <backend> <target>
local backend=$1
shift
fm_backend_source "$backend" || return 1
case "$backend" in
tmux) fm_backend_tmux_current_path "$@" ;;
herdr) fm_backend_herdr_current_path "$@" ;;
zellij|cmux|orca) return 1 ;;
*) echo "error: no current-path implementation for backend '$backend'" >&2; return 1 ;;
esac
}

fm_backend_remove_worktree() { # <backend> <worktree-id>
local backend=$1
shift
Expand Down
31 changes: 26 additions & 5 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@
# to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the
# recorded task metadata cannot drift apart.
# Ship briefs begin with a worktree-isolation assertion before the branch step.
# No path is baked in here: bin/fm-spawn.sh appends the exact copy and the
# repository primary to the launch brief as `# Worktree isolation`, and owns
# both, so a scaffold made in one place can never contradict the project the
# spawn actually launched against. The assertion in this scaffold points at
# that section and compares physical paths, because a repo LABEL is not
# comparable with `pwd -P`, equality of pwd and git-toplevel does not prove
# isolation, and git-dir vs git-common-dir equality only proves the copy is not
# a linked worktree, which an ordinary clone and an Orca-managed copy also
# satisfy.
# A project given as exactly `.` - and nothing else, since every other spelling
# is a repo LABEL rather than a path - resolves to its repository's primary
# working tree (fm-tangle-lib.sh) for the worktree LABEL alone, so `.` from a
# linked firstmate worktree reads as the repository's name rather than a dot;
# any other label is rendered verbatim.
# --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a
# report rather than a merge, and a charter is not a delivery contract.
# There is no --yolo flag here. The worker never owns merge decisions, so yolo is
Expand Down Expand Up @@ -90,6 +104,8 @@ esac
. "$SCRIPT_DIR/fm-classify-lib.sh"
# shellcheck source=bin/fm-dod-lib.sh
. "$SCRIPT_DIR/fm-dod-lib.sh"
# shellcheck source=bin/fm-tangle-lib.sh
. "$SCRIPT_DIR/fm-tangle-lib.sh"
PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}

resolve_directory_input() {
Expand Down Expand Up @@ -310,6 +326,11 @@ exit 0
fi

REPO=${POS[1]}
REPO_LABEL=$REPO
if [ "$REPO" = . ] && REPO_ABS=$(pwd -P 2>/dev/null); then
REPO_PRIMARY=$(fm_git_primary_workdir "$REPO_ABS" 2>/dev/null) || REPO_PRIMARY=$REPO_ABS
REPO_LABEL=$(basename "$REPO_PRIMARY")
fi

if [ "$HERDR_LAB" -eq 1 ]; then
HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh")
Expand Down Expand Up @@ -362,7 +383,7 @@ $TASK_SECTION
$HERDR_SECTION

# Setup
You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch.
You are in a disposable git worktree of $REPO_LABEL, at a detached HEAD on a clean default branch.
This is a SCOUT task: the deliverable is a written report, not a PR.
The worktree is your laboratory - install, run, edit, and make scratch commits freely; all of it is discarded at teardown.
The report is the only thing that survives, so anything worth keeping must be in it.
Expand Down Expand Up @@ -446,11 +467,11 @@ $TASK_SECTION
$HERDR_SECTION

# Setup
You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch.
You are in a disposable git worktree of $REPO_LABEL, at a detached HEAD on a clean default branch.

**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from.
The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout.
If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop.
**Verify isolation before anything else.** Run \`pwd -P\`. It must be exactly the path named in this brief's \`# Worktree isolation\` section, which firstmate renders at launch: your own disposable copy (a treehouse pool path, an Orca-managed worktree, or another isolated worktree), never the project's primary checkout.
Equality of \`pwd\` and \`git rev-parse --show-toplevel\` does not prove isolation: both name the current worktree root in the primary checkout and in a linked worktree alike. Compare the physical paths instead.
If \`pwd -P\` is not that exact worktree path, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop.

1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2

Expand Down
45 changes: 39 additions & 6 deletions bin/fm-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,15 @@
# inherits the local copy but none of the conversation; a
# secondmate reconciles its own home's records at startup, so its
# standing charter is never rewritten.
# Records a durable checkpoint and that note, exits the old agent,
# then delegates the launch to its single owner,
# bin/fm-spawn.sh --relaunch. A failure before publication keeps
# the prior durable record in place and reports the concrete
# state; it never leaves a half-transitioned task claiming to be
# running.
# Records a durable checkpoint and that note, verifies the live
# occupied directory is the recorded worktree (Herdr's frozen
# launch cwd is not that proof), exits the old agent, then
# delegates the launch to its single owner,
# bin/fm-spawn.sh --relaunch. An unverifiable or mismatched live
# cwd refuses before the agent is stopped. A failure before
# publication keeps the prior durable record in place and reports
# the concrete state; it never leaves a half-transitioned task
# claiming to be running.
#
# Teardown and discard are NOT verbs here and never will be. `exit` stops an
# agent and preserves everything else; removing a worktree, killing an
Expand Down Expand Up @@ -684,6 +687,35 @@ resolve_relaunch_profile() {
fi
}

# require_relaunch_occupied_worktree: prove, before the running agent is
# stopped, that the live shell occupies the recorded worktree. Herdr's
# pane.cwd is the launch directory and does not update; the backend current-
# path read is the foreground process. A read that comes back empty is
# retried a bounded number of times, as the launch owner does, so one hiccup
# in the backend CLI does not abort a relaunch; a cwd that is still
# unreadable after those attempts preserves the agent. Path identity uses the
# physical directory.
require_relaunch_occupied_worktree() {
local seen seen_real wt_real attempt
wt_real=$(CDPATH='' cd -- "$WT" 2>/dev/null && pwd -P) \
|| die "task $ID's recorded worktree $WT cannot be resolved"
seen=
for attempt in $(seq 1 10); do
seen=$(fm_backend_current_path "$BACKEND" "$T" 2>/dev/null) || seen=
seen=$(printf '%s' "$seen" | tr -d '\r')
[ -z "$seen" ] || break
if [ "$attempt" -lt 10 ]; then sleep 0.5; fi
done
if [ -z "$seen" ]; then
die "task $ID's live working directory cannot be verified; refusing to stop the agent without proof it occupies $WT"
fi
seen_real=$(CDPATH='' cd -- "$seen" 2>/dev/null && pwd -P) || seen_real=
if [ -n "$seen_real" ] && [ "$seen_real" = "$wt_real" ]; then
return 0
fi
die "task $ID's live shell is in ${seen_real:-$seen}, not its recorded worktree $WT; refusing to stop the agent"
}

# safe_checkpoint: prove, before anything is stopped, that the work a relaunch
# must preserve is actually there and recoverable afterwards. Fills
# CHECKPOINT_LINES with the journal lines describing what it proved, and
Expand Down Expand Up @@ -814,6 +846,7 @@ do_relaunch() {
note_line="note=none"
fi
safe_checkpoint
require_relaunch_occupied_worktree
cp -p "$META" "$META_PRIOR" || die "could not preserve task $ID's durable record before relaunching"
RELAUNCH_ACTIVE=1
journal_write checkpoint "${CHECKPOINT_LINES[@]}" "$note_line"
Expand Down
52 changes: 47 additions & 5 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@
# model, and effort may change, which is what makes a harness switch one
# ordinary relaunch. It refuses unless the recorded endpoint is positively
# agent-free on a backend with a recovery-grade agent-state classifier (tmux
# or herdr), refuses unless the endpoint's shell is sitting in the recorded
# worktree, and clears the previous harness's per-task wiring before arming
# the new incarnation.
# or herdr). The control plane verifies the live occupied directory before
# stopping the previous agent; this script independently refuses unless the
# endpoint's shell is still sitting in the recorded worktree, because a
# ship/scout copy nothing occupies is a copy the pool may already have handed
# to someone else. It clears the previous harness's per-task wiring before
# arming the new incarnation.
# --harness <name> is the explicit per-spawn harness/profile adapter. The old
# positional harness arg still works for back-compat.
# --model <name> and --effort <low|medium|high|xhigh|max> are concrete profile
Expand Down Expand Up @@ -175,6 +178,14 @@
# Ship/scout spawns refuse to launch unless the resolved task path is a real
# git worktree root distinct from both the spawning project and its repository's
# primary checkout, including when the spawning project is a linked worktree.
# A project path of `.` resolves to the caller's own PHYSICAL directory, so
# the isolation comparisons cannot be defeated by a symlinked spelling and a
# genuine treehouse copy is not refused. It is never rebound to the
# repository primary: a secondmate spawning from its own leased home stays
# bound to that home.
# The same two paths the guard proved - the resolved copy and the repository
# primary - are appended to every ship/scout launch brief, because the brief's
# repo positional is a label the worker cannot compare with its own `pwd -P`.
# On the backends that discover that path by reading the task pane's own cwd,
# the same isolation test screens every read: a pane still showing the project
# or the repository primary while `treehouse get` prepares the slot is waited
Expand Down Expand Up @@ -423,6 +434,8 @@ fm_backlog_directory_present "$STATE" "state directory" || {
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-dod-lib.sh
. "$SCRIPT_DIR/fm-dod-lib.sh"
# shellcheck source=bin/fm-tangle-lib.sh
. "$SCRIPT_DIR/fm-tangle-lib.sh"
# shellcheck source=bin/fm-trace-context-lib.sh
. "$SCRIPT_DIR/fm-trace-context-lib.sh"
# shellcheck source=bin/fm-remote-readiness-lib.sh
Expand Down Expand Up @@ -2142,7 +2155,7 @@ if [ "$KIND" = secondmate ]; then
BRIEF="$DATA/$ID/brief.md"
fi
else
PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)"
PROJ_ABS="$(CDPATH='' cd -- "$(resolve_project_dir_arg "$PROJ")" && pwd -P)"
WT=""
BRIEF="$DATA/$ID/brief.md"
fi
Expand Down Expand Up @@ -2996,7 +3009,9 @@ if [ "$RELAUNCH" -eq 1 ]; then
# No worktree is acquired: the recorded one is reused as-is. What must be
# proven instead is that the adopted endpoint's shell is actually sitting in
# that worktree, so the replacement agent starts where the work is rather
# than wherever the pane happened to drift.
# than wherever the pane happened to drift. A shell that is no longer in the
# copy is not corrected into it: for a pooled slot, nothing holding the copy
# is exactly the state in which the pool may hand it to another task.
relaunch_wt_real=$(real_path_or_raw "$WT")
relaunch_seen=
for _ in $(seq 1 10); do
Expand Down Expand Up @@ -3075,6 +3090,33 @@ if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then
freshen_spawn_worktree_base "$WT" || exit 1
fi

# The worker's isolation check needs paths, not names: the brief's repo
# positional is a label, and no label can be compared with `pwd -P`. Both paths
# exist only here, once the copy is resolved and validated, so they are rendered
# into the launch brief the agent is about to read - whatever the project
# spelling was, and whether the copy is a treehouse slot, an Orca worktree, or
# any other accepted isolated copy.
append_worktree_isolation_contract() {
local wt_real primary
wt_real=$(cd "$WT" && pwd -P) || return 1
primary=$(fm_git_primary_workdir "$PROJ_ABS" 2>/dev/null) || primary=
{
printf '\n# Worktree isolation\n'
# shellcheck disable=SC2016 # Backticks are literal Markdown, not command substitutions.
printf 'Your task worktree is `%s`.\n' "$wt_real"
[ -z "$primary" ] \
|| printf "The project's primary checkout is \`%s\`; it is never yours to work in.\n" "$primary"
# shellcheck disable=SC2016 # Backticks are literal worker instructions.
printf 'Before anything else run `pwd -P`. If it is not exactly `%s`, STOP - do not branch or commit here - append `blocked: launched in primary checkout, not an isolated worktree` to the status file and stop.\n' "$wt_real"
} >> "$BRIEF"
}
if [ "$KIND" = ship ] || [ "$KIND" = scout ]; then
append_worktree_isolation_contract || {
echo "error: could not render task $ID's worktree-isolation contract into $BRIEF" >&2
exit 1
}
fi

# Pre-register Claude's workspace trust for the worktree, at the first point the
# worktree is known and before any per-task state is created below. The dialog
# gates the pane before the brief is ever read, and it also gates loading the
Expand Down
17 changes: 17 additions & 0 deletions bin/fm-tangle-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ fm_default_branch() {
return 1
}

# The primary working tree of the repository that <dir> belongs to.
# For a linked worktree this is the directory whose git dir is the common dir,
# not <dir> itself. Equality of pwd and git-toplevel cannot find that primary:
# both names are the current worktree root in the primary and in a linked copy.
# Echoes an absolute physical path, or returns 1 if it cannot be resolved.
fm_git_primary_workdir() {
local abs common parent parent_git
abs=$(CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || return 1
common=$(git -C "$abs" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
common=$(CDPATH='' cd -- "$common" 2>/dev/null && pwd -P) || return 1
parent=$(dirname "$common")
parent_git=$(git -C "$parent" rev-parse --path-format=absolute --absolute-git-dir 2>/dev/null) || return 1
parent_git=$(CDPATH='' cd -- "$parent_git" 2>/dev/null && pwd -P) || return 1
[ "$parent_git" = "$common" ] || return 1
printf '%s\n' "$parent"
}

# If the git checkout at <root> is tangled - on a NAMED branch that is not its
# default branch - echo the offending branch name and return 0. For every healthy
# state (not a git work tree, detached HEAD, or already on the default branch)
Expand Down
Loading
Loading