ci/woodpecker/pr/ci Pipeline failed
On a host with no system Node, tools/install.sh bootstraps one into ~/.mosaic/node/current/bin and records it in ~/.profile. The fleet unit runs `env -i ... bash --noprofile --norc`, so ~/.profile is never read — that is deliberate — and _build_runtime_bin_prefix did not name the directory itself. Runtime binaries are `#!/usr/bin/env node`, so the pane resolved `mosaic` and then died on `env: 'node': No such file or directory` after an install that reported success. Measured on a greenfield VM. The existing `npm config get prefix` branch cannot cover it: that reports a package prefix (~/.npm-global), never a Node runtime directory. The test case asserts the property rather than the string — it runs the pane for real with a Node-shebang `mosaic` and requires the pane to have executed. A PATH substring check would pass on a fix that put the directory in the wrong position. Red without the launcher change, green with it, rest of the suite unaffected.
442 lines
17 KiB
Bash
Executable File
442 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
|
|
# separately parsed data-only .env.local can influence launch. Never source an
|
|
# environment file and never accept a command string from either file.
|
|
|
|
MODE=launch
|
|
case "${1:-}" in
|
|
--stop)
|
|
MODE=stop
|
|
AGENT_NAME=${2:-}
|
|
;;
|
|
--interaction)
|
|
MODE=interaction
|
|
AGENT_NAME=${2:-}
|
|
;;
|
|
*) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
|
|
esac
|
|
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
|
|
|
fail() {
|
|
echo "ERROR: $*" >&2
|
|
exit 64
|
|
}
|
|
|
|
hash_value() {
|
|
printf '%s' "$1" | sha256sum | awk '{print $1}'
|
|
}
|
|
|
|
fail_env() {
|
|
local code="$1"
|
|
local key="$2"
|
|
local value="$3"
|
|
echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
|
|
exit 64
|
|
}
|
|
|
|
safe_agent_name() {
|
|
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
|
|
}
|
|
|
|
safe_policy_name() {
|
|
[[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
|
|
}
|
|
|
|
safe_path() {
|
|
[[ "$1" == /* ]] || return 1
|
|
[[ "$1" != *".."* ]] || return 1
|
|
[[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
|
|
}
|
|
|
|
assert_private_regular_file() {
|
|
local file="$1"
|
|
[ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
|
|
local mode
|
|
mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
|
|
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
|
|
}
|
|
|
|
assert_managed_directory() {
|
|
local directory="$1"
|
|
[ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
|
|
local mode
|
|
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
|
(( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
|
}
|
|
|
|
assert_private_directory() {
|
|
local directory="$1"
|
|
assert_managed_directory "$directory"
|
|
local mode
|
|
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
|
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
|
}
|
|
|
|
[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
|
|
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
|
|
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
|
|
|
FLEET_DIR="$MOSAIC_HOME/fleet"
|
|
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
|
assert_managed_directory "$MOSAIC_HOME"
|
|
assert_managed_directory "$FLEET_DIR"
|
|
assert_private_directory "$AGENT_ENV_DIR"
|
|
|
|
GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
|
|
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"
|
|
|
|
declare -A GENERATED_VALUES=()
|
|
declare -A LOCAL_VALUES=()
|
|
declare -A SEEN_KEYS=()
|
|
|
|
is_sensitive_key() {
|
|
[[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
|
|
}
|
|
|
|
is_generated_key() {
|
|
case "$1" in
|
|
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
is_local_key() {
|
|
case "$1" in
|
|
MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
validate_generated_value() {
|
|
local key="$1"
|
|
local value="$2"
|
|
case "$key" in
|
|
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
|
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
|
MOSAIC_AGENT_RUNTIME)
|
|
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
|
;;
|
|
MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
|
|
MOSAIC_AGENT_REASONING)
|
|
case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
|
|
;;
|
|
MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
|
|
MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
|
|
MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
|
|
esac
|
|
}
|
|
|
|
validate_local_value() {
|
|
local key="$1"
|
|
local value="$2"
|
|
if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
|
|
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
|
|
else
|
|
safe_path "$value" || fail_env unsafe-path "$key" "$value"
|
|
fi
|
|
}
|
|
|
|
load_environment_file() {
|
|
local file="$1"
|
|
local kind="$2"
|
|
[ -e "$file" ] || {
|
|
[ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
|
|
return 0
|
|
}
|
|
assert_private_regular_file "$file"
|
|
SEEN_KEYS=()
|
|
|
|
local line key value
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
[ -z "$line" ] && continue
|
|
if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
|
|
fail_env malformed-line '(malformed)' "$line"
|
|
fi
|
|
key=${BASH_REMATCH[1]}
|
|
value=${BASH_REMATCH[2]}
|
|
[ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
|
|
SEEN_KEYS[$key]=1
|
|
is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"
|
|
|
|
if [ "$kind" = generated ]; then
|
|
is_generated_key "$key" || fail_env unknown-key "$key" "$value"
|
|
validate_generated_value "$key" "$value"
|
|
GENERATED_VALUES[$key]=$value
|
|
else
|
|
is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
|
|
is_local_key "$key" || fail_env unknown-key "$key" "$value"
|
|
validate_local_value "$key" "$value"
|
|
LOCAL_VALUES[$key]=$value
|
|
fi
|
|
done < "$file"
|
|
}
|
|
|
|
load_environment_file "$GENERATED_ENV" generated
|
|
for required_key in \
|
|
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
|
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
|
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
|
done
|
|
load_environment_file "$LOCAL_ENV" local
|
|
|
|
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
|
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
|
|
|
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
|
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
|
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
|
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
|
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
|
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
|
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
|
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
|
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
|
|
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
|
|
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
|
|
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}
|
|
|
|
if ! command -v tmux >/dev/null 2>&1; then
|
|
echo "ERROR: tmux is required" >&2
|
|
exit 69
|
|
fi
|
|
|
|
_tmux() {
|
|
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
|
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
|
else
|
|
tmux "$@"
|
|
fi
|
|
}
|
|
|
|
assert_owned_tmux_server() {
|
|
local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
|
|
[ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
|
|
local owner_mode
|
|
owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
|
|
(( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
|
|
local owner
|
|
owner=$(tr -d '\n' < "$owner_file")
|
|
[[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
|
|
_tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
|
|
local environment expected
|
|
environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
|
|
expected=$(printf '%s\n' \
|
|
"HOME=$HOME" \
|
|
'PATH=/usr/bin:/bin' \
|
|
"PWD=$HOME" \
|
|
"MOSAIC_FLEET_OWNER=$owner" \
|
|
'MOSAIC_TMUX_HOLDER=_holder' \
|
|
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
|
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
|
fail "tmux server ownership or environment validation failed"
|
|
}
|
|
|
|
# Validate exact server ownership before querying, cleaning, or creating any
|
|
# managed session. An unmanaged or contaminated named socket is never repaired.
|
|
assert_owned_tmux_server
|
|
|
|
if [ "$MODE" = interaction ]; then
|
|
[ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
|
|
[ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
|
|
fail "operator interaction service requires the pinned model"
|
|
[ "$MOSAIC_AGENT_REASONING" = high ] || \
|
|
fail "operator interaction service requires high reasoning"
|
|
[ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
|
|
fail "operator interaction service requires the operator-interaction tool policy"
|
|
fi
|
|
|
|
if [ "$MODE" = stop ]; then
|
|
_tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
|
|
exit 0
|
|
fi
|
|
|
|
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
|
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
|
exit 0
|
|
fi
|
|
|
|
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
|
# that home. Derive the pane home from the canonical path when available so an
|
|
# inherited pane/session HOME cannot become runtime authority.
|
|
PANE_HOME=$HOME
|
|
case "$MOSAIC_HOME" in
|
|
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
|
esac
|
|
|
|
_build_runtime_bin_prefix() {
|
|
local candidates=()
|
|
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
|
# A host with no system Node gets one bootstrapped here by tools/install.sh, which
|
|
# records it in ~/.profile. The fleet unit runs `env -i ... bash --noprofile --norc`
|
|
# by design, so ~/.profile is never read and the directory has to be named here.
|
|
# The npm probe below cannot cover this: it reports a package prefix
|
|
# (~/.npm-global), never a Node runtime directory. It is first so the bootstrapped
|
|
# runtime wins on a host that has both — that is the one the installer verified.
|
|
# Runtime binaries are `#!/usr/bin/env node`, so without this the pane resolves the
|
|
# binary and then dies on `env: 'node': No such file or directory`.
|
|
candidates+=("$PANE_HOME/.mosaic/node/current/bin")
|
|
if command -v npm >/dev/null 2>&1; then
|
|
local npm_prefix
|
|
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
|
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
|
fi
|
|
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
|
|
|
local prefix="" dir
|
|
for dir in "${candidates[@]}"; do
|
|
[ -d "$dir" ] || continue
|
|
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
|
done
|
|
printf '%s' "$prefix"
|
|
}
|
|
|
|
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
|
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
|
|
|
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
|
|
# environment. A binary missing from *that* path is a pane that dies in under a
|
|
# second, inside a session nobody is attached to, with its diagnostic scrolled
|
|
# into a pane tmux then destroys. Resolve both here, before any effect, where
|
|
# the failure is still attributable to the thing that caused it.
|
|
#
|
|
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
|
|
# is named exactly like the runtime, so resolving the runtime name is the same
|
|
# question the pane will ask a moment later — asked while an operator can still
|
|
# see the answer.
|
|
_resolve_in_pane_path() {
|
|
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
|
}
|
|
|
|
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
|
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
|
|
# here the data is fine and the host is not ready. Callers tell the individual
|
|
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
|
|
fail_launch() {
|
|
local code="$1"
|
|
shift
|
|
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
|
|
exit 69
|
|
}
|
|
|
|
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
|
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
|
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
|
done
|
|
|
|
_ensure_claude_workdir_trusted() {
|
|
local workdir="$1"
|
|
local resolved
|
|
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
|
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
|
claude_json="${claude_json:-$HOME/.claude.json}"
|
|
command -v python3 >/dev/null 2>&1 || return 1
|
|
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
|
import json, os, sys, tempfile
|
|
cj = os.environ["MOSAIC_CJ"]
|
|
d = os.environ["MOSAIC_TRUST_DIR"]
|
|
try:
|
|
data = json.load(open(cj)) if os.path.exists(cj) else {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except Exception:
|
|
sys.exit(2)
|
|
projects = data.setdefault("projects", {})
|
|
entry = projects.get(d)
|
|
if not isinstance(entry, dict):
|
|
entry = {}
|
|
projects[d] = entry
|
|
entry["hasTrustDialogAccepted"] = True
|
|
tmp_dir = os.path.dirname(cj) or "."
|
|
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
|
|
try:
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
os.replace(tmp, cj)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
sys.exit(3)
|
|
PY
|
|
}
|
|
|
|
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
|
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
|
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
|
fi
|
|
|
|
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
|
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
|
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
|
|
|
# The tmux holder owns a named server. Explicitly clear the pane environment
|
|
# so server/session variables cannot cross the launch boundary; retain only
|
|
# trusted bootstrap, generated, and approved local data as argv assignments.
|
|
LAUNCH_ENV=(
|
|
/usr/bin/env
|
|
-i
|
|
"HOME=$PANE_HOME"
|
|
"PATH=$PANE_PATH"
|
|
"MOSAIC_HOME=$MOSAIC_HOME"
|
|
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
|
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
|
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
|
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
|
"MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
|
|
"MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
|
|
"MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
|
|
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
|
|
"MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
|
|
)
|
|
|
|
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
|
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
|
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
|
|
|
|
PANE_PID=""
|
|
for _retry in 1 2 3 4 5; do
|
|
PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
|
[ -n "$PANE_PID" ] && break
|
|
sleep 0.2
|
|
done
|
|
|
|
_start_heartbeat_sidecar() {
|
|
local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
|
|
local hb_file="${run_dir}/${agent}.hb"
|
|
mkdir -p "$run_dir"
|
|
local sidecar_script
|
|
sidecar_script=$(printf \
|
|
'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
|
|
"$hb_file" "$pane_pid" "$interval")
|
|
if command -v setsid >/dev/null 2>&1; then
|
|
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
|
else
|
|
bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
|
fi
|
|
disown $! 2>/dev/null || true
|
|
}
|
|
|
|
if [ -n "$PANE_PID" ]; then
|
|
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
|
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
|
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
|
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
|
# #1241. Session present, no pane PID after a second of retries. Whatever this
|
|
# is, it is not a seat an operator can use, so it is not a success either.
|
|
fail_launch pane-pid-unresolved \
|
|
"tmux reports the session but no pane PID after 5 attempts"
|
|
else
|
|
# #1241. This branch used to print a WARNING about the heartbeat sidecar and
|
|
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
|
|
# command exits, so an absent session one second after new-session means the
|
|
# runtime died on startup. Reporting it as success is what let `fleet start`
|
|
# return 0 over three dead panes — the launcher knew, and said the wrong thing
|
|
# at the wrong severity to the wrong layer.
|
|
fail_launch pane-did-not-survive \
|
|
"the pane exited immediately and tmux destroyed the session;" \
|
|
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
|
|
fi
|