Files
2026-07-13 12:58:18 +08:00

426 lines
24 KiB
Bash
Executable File

#!/bin/bash
set -e
# Initialize PIDs up front so the cleanup trap below does not emit bare
# ``kill`` usage errors when the script aborts before either child starts
# (e.g. FATAL in ``_check_key``).
AGENT_PID=""
NEXT_PID=""
WATCHDOG_PID=""
# Disable Python stdout buffering so the FastAPI/uvicorn agent flushes
# tracebacks and log lines immediately. Without this a silent crash during
# module import can sit in Python's userspace buffer until the process
# exits, by which point the container is already gone. Paired with `python
# -u` on the uvicorn invocation below and `awk ... fflush()` on the log
# prefixer — all three are belt-and-suspenders measures against pipe-
# buffered log loss observed across Railway deploys.
export PYTHONUNBUFFERED=1
cleanup() {
# Trap may fire from a FATAL ``exit 1`` path where ``set -e`` is still
# active. Any non-zero return from ``kill`` (e.g. process already gone)
# in a ``&&`` chain whose final command is ``kill`` is subject to
# errexit and would abort cleanup before the grace loop runs. Disable
# errexit for the duration of the trap — every kill/wait below
# explicitly expects and tolerates non-zero returns.
set +e
# Guard each pid: empty var -> skip (no operand), set var -> best-effort
# SIGTERM. ``2>/dev/null`` swallows normal "no such process" races after
# wait has already reaped the child.
#
# After SIGTERM, give each child up to 5s to exit cleanly before
# escalating to SIGKILL. Matches the survivor-termination grace window
# further down and the starter entrypoint's cleanup pattern — a
# runaway uvicorn / next.js process should not get wedged on trap-exit
# waiting for the container runtime to SIGKILL it.
[ -n "$AGENT_PID" ] && kill "$AGENT_PID" 2>/dev/null
[ -n "$NEXT_PID" ] && kill "$NEXT_PID" 2>/dev/null
[ -n "$WATCHDOG_PID" ] && kill "$WATCHDOG_PID" 2>/dev/null
for _ in 1 2 3 4 5; do
local any_alive=0
[ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null && any_alive=1
[ -n "$NEXT_PID" ] && kill -0 "$NEXT_PID" 2>/dev/null && any_alive=1
[ "$any_alive" = "0" ] && break
sleep 1
done
[ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null && kill -9 "$AGENT_PID" 2>/dev/null
[ -n "$NEXT_PID" ] && kill -0 "$NEXT_PID" 2>/dev/null && kill -9 "$NEXT_PID" 2>/dev/null
[ -n "$WATCHDOG_PID" ] && kill -0 "$WATCHDOG_PID" 2>/dev/null && kill -9 "$WATCHDOG_PID" 2>/dev/null
return 0
}
trap cleanup EXIT
# Provider-agnostic startup diagnostic. langroid is multi-provider — the chat
# model is selected via ``LANGROID_MODEL`` (e.g. ``gpt-4.1``,
# ``litellm/anthropic/claude-opus-4``, ``gemini/gemini-2.5-flash``). Whichever
# provider is picked, only THAT provider's API key is required.
#
# This block inspects ``LANGROID_MODEL`` (and the planner-only override
# ``A2UI_MODEL`` if distinct) and warns when the expected credential env
# var is missing. Default behavior is warn-and-continue so operators can
# bring the container up for local dev; set ``REQUIRE_LANGROID_API_KEY=1``
# in production to fail-fast.
# Map a langroid model string like ``gpt-4.1`` (bare OpenAI name) or
# ``gemini/gemini-2.5-flash`` to the env var that langroid's ``OpenAIGPT``
# client actually reads at request time. Mappings verified against
# langroid's installed ``language_models/openai_gpt.py`` — in particular:
# * Bare OpenAI names (``gpt-*``, ``o1*``, ``o3*``, ``o4*``, anything with
# NO ``/`` separator)
# -> ``OPENAI_API_KEY``. langroid strips no prefix from
# ``openai/<model>`` — it passes the model string
# LITERALLY to the OpenAI SDK, which then rejects
# ``openai/gpt-4.1`` as "model not found". Use bare
# OpenAI names.
# * ``openai/*`` -> WARN (fatal under REQUIRE_LANGROID_API_KEY=1):
# ``openai/`` is NOT a langroid-native prefix;
# langroid passes it literally to the OpenAI SDK
# which will reject the model id.
# * ``gemini/*`` -> ``GEMINI_API_KEY`` (NOT ``GOOGLE_API_KEY``; that is
# google-genai / google-adk's convention, not langroid's).
# * ``openrouter/*`` -> ``OPENROUTER_API_KEY``.
# * ``groq/*`` -> ``GROQ_API_KEY`` (native langroid prefix).
# * ``cerebras/*`` -> ``CEREBRAS_API_KEY`` (native langroid prefix).
# * ``glhf/*`` -> ``GLHF_API_KEY`` (native langroid prefix).
# * ``minimax/*`` -> ``MINIMAX_API_KEY`` (native langroid prefix).
# * ``portkey/*`` -> ``PORTKEY_API_KEY`` (native langroid prefix; note
# langroid ALSO reads portkey provider-specific keys
# at request time — a plain ``PORTKEY_API_KEY`` probe
# is the best we can do at boot).
# * ``deepseek/*`` -> ``DEEPSEEK_API_KEY`` (native langroid prefix).
# * ``litellm/anthropic/*`` -> ``ANTHROPIC_API_KEY`` (langroid strips the
# ``litellm/`` prefix and delegates to litellm, which
# reads ``ANTHROPIC_API_KEY`` for the Anthropic provider).
# * Bare ``anthropic/*`` is NOT a langroid-native prefix — langroid has no
# handling for it and falls through to the default
# OpenAI client, which rejects the request. We still
# map it to ``ANTHROPIC_API_KEY`` so the env-guard
# doesn't falsely succeed in warn-mode, but _check_key
# FATALs under ``REQUIRE_LANGROID_API_KEY=1`` so fail-
# fast operators see this misconfig at boot rather than
# at first request.
# * ``ollama/*``, ``local/*``, ``vllm/*``, ``llamacpp/*`` -> no API key
# required (local-inference); ``_check_key`` returns
# the ``NO_KEY_REQUIRED`` sentinel and logs INFO.
_expected_key_for_model() {
local model="${1:-gpt-4.1}"
# ORDER MATTERS: ``litellm/anthropic/*`` must precede the bare
# ``anthropic/*`` arm below. Otherwise ``litellm/anthropic/...`` would
# never match — bash ``case`` uses first-match-wins, and an earlier bare
# ``anthropic/*`` arm would never fire for a ``litellm/`` prefix anyway,
# but keeping litellm first makes the routing intent explicit and is
# robust to future reorderings.
case "$model" in
# Local-inference prefixes: no API key required. Sentinel distinct
# from the empty string so _check_key can log an INFO and return 0
# even under REQUIRE_LANGROID_API_KEY=1 (fail-fast), rather than
# FATALing with "Cannot infer required credential".
ollama/*|local/*|vllm/*|llamacpp/*) echo "NO_KEY_REQUIRED" ;;
litellm/anthropic/*) echo "ANTHROPIC_API_KEY" ;;
anthropic/*) echo "ANTHROPIC_API_KEY" ;;
openai/*) echo "OPENAI_API_KEY" ;;
openrouter/*) echo "OPENROUTER_API_KEY" ;;
gemini/*) echo "GEMINI_API_KEY" ;;
# ``google/`` is intentionally NOT mapped here. langroid has no
# native ``google/`` prefix handling — treating it as a gemini
# alias would let fail-fast mode "succeed" at boot (because
# GEMINI_API_KEY is set) only to blow up at request time when
# langroid falls through to the default OpenAI client. The
# dedicated ``google/*`` arm inside ``_check_key`` FATALs under
# REQUIRE_LANGROID_API_KEY=1 and WARNs otherwise, which is the
# correct signal.
groq/*) echo "GROQ_API_KEY" ;;
cerebras/*) echo "CEREBRAS_API_KEY" ;;
glhf/*) echo "GLHF_API_KEY" ;;
minimax/*) echo "MINIMAX_API_KEY" ;;
portkey/*) echo "PORTKEY_API_KEY" ;;
deepseek/*) echo "DEEPSEEK_API_KEY" ;;
# langdb/*: langroid's ``OpenAIGPT`` natively handles this prefix
# (sets ``is_langdb``) and resolves credentials via ``langdb_params``
# (a config object) rather than a single env var. There is no env var
# for us to probe at startup — emit a distinct NO_KEY_REQUIRED_*
# sentinel so ``_check_key`` logs INFO and returns 0 even under
# REQUIRE_LANGROID_API_KEY=1.
langdb/*) echo "NO_KEY_REQUIRED_LANGDB" ;;
# litellm-proxy/*: langroid's ``OpenAIGPT`` natively handles this
# prefix (sets ``is_litellm_proxy``) and resolves credentials via
# ``LiteLLMProxyConfig`` (a config object) rather than a single env
# var. Same NO_KEY_REQUIRED_* treatment as langdb/.
litellm-proxy/*) echo "NO_KEY_REQUIRED_LITELLM_PROXY" ;;
# Non-anthropic litellm variants (``litellm/openai/*``,
# ``litellm/azure/*``, ``litellm/bedrock/*``, etc.) — litellm resolves
# per-provider env vars internally (AZURE_API_KEY, AZURE_API_BASE,
# AWS_ACCESS_KEY_ID, ...) and we don't know which to probe at boot.
# Note: ``litellm/anthropic/*`` is handled by the SPECIFIC earlier
# arm (returns ANTHROPIC_API_KEY) and matches first by bash
# first-match-wins ordering — this catch-all only sees the non-
# anthropic variants.
litellm/*) echo "NO_KEY_REQUIRED_LITELLM" ;;
# Bare model names with no ``/`` separator are treated as OpenAI
# (gpt-*, o1*, o3*, o4*, chatgpt-*, etc.). This matches langroid's
# canonical convention (``OpenAIChatModel.GPT4_1.value == "gpt-4.1"``)
# — and the OpenAI SDK accepts them directly.
*/*) echo "" ;;
*) echo "OPENAI_API_KEY" ;;
esac
}
# Log when we're falling back to the default so operators understand why
# the OpenAI-shaped env guard fires even though they "didn't pick OpenAI".
if [ -z "${LANGROID_MODEL:-}" ]; then
echo "[entrypoint] INFO: LANGROID_MODEL not set — defaulting to 'gpt-4.1' (OPENAI_API_KEY will be required)" >&2
fi
LANGROID_MODEL_EFFECTIVE="${LANGROID_MODEL:-gpt-4.1}"
A2UI_MODEL_EFFECTIVE="${A2UI_MODEL:-$LANGROID_MODEL_EFFECTIVE}"
_check_key() {
local model="$1"; local role="$2"
# ``google/`` is a common typo for ``gemini/`` — handle it BEFORE we
# call ``_expected_key_for_model`` so a GEMINI_API_KEY that happens to
# be set can't silently pass the fail-fast guard for a prefix that has
# no langroid-native routing.
case "$model" in
google/*)
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $role model '$model' uses 'google/' prefix which is not a langroid-native prefix. Use 'gemini/<model>' instead (with GEMINI_API_KEY set); refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2
exit 1
fi
echo "[entrypoint] WARN: $role model '$model' uses 'google/' prefix — langroid has no native google/ routing; use 'gemini/<model>' instead. Request-time calls will fail." >&2
return 0
;;
esac
# ``openai/*`` is NOT langroid-native either: langroid passes the full
# string LITERALLY to the OpenAI SDK (verified empirically — the
# ``openai/`` prefix is not stripped inside ``lm.OpenAIGPT``), and the
# SDK rejects ``openai/gpt-4.1`` as "model not found". Emit a warning so
# operators see the boot-time remediation rather than a cryptic
# request-time failure.
case "$model" in
openai/*)
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $role model '$model' uses 'openai/' prefix which is not a langroid-native prefix — langroid passes it literally to the OpenAI SDK which will reject it. Use the bare model name (e.g. 'gpt-4.1') instead; refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2
exit 1
fi
echo "[entrypoint] WARN: $role model '$model' uses 'openai/' prefix — langroid passes it LITERALLY to the OpenAI SDK (the prefix is NOT stripped) and the SDK will reject it as 'model not found'. Use the bare model name (e.g. 'gpt-4.1') instead. Falling through to OPENAI_API_KEY check so the operator sees both issues at boot." >&2
;;
esac
local var
var=$(_expected_key_for_model "$model")
# NO_KEY_REQUIRED sentinels — two families:
# * Plain ``NO_KEY_REQUIRED``: local-inference models (ollama/, local/,
# vllm/, llamacpp/) — no credential at all.
# * ``NO_KEY_REQUIRED_*`` variants: langroid-native prefixes where
# credentials ARE required but resolved via a config object
# (langdb_params, LiteLLMProxyConfig) or via per-provider env vars
# internal to litellm (AZURE_*, AWS_*, etc.). We cannot name a
# single env var to probe at startup — skip the env-key check and
# let request-time surface any missing config.
# Both skip the env check and return 0 even under REQUIRE_LANGROID_API_KEY=1
# so the fail-fast contract doesn't reject a legitimately-configured
# langroid-native prefix.
case "$var" in
NO_KEY_REQUIRED)
echo "[entrypoint] INFO: local-inference model '$model' — no API key required for $role" >&2
return 0
;;
NO_KEY_REQUIRED_*)
echo "[entrypoint] INFO: $role model '$model' uses a langroid-native prefix that resolves credentials via config (no single env var to probe) — skipping env-key check" >&2
return 0
;;
esac
if [ -z "$var" ]; then
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: Cannot infer required credential for $role model '$model'. Set a langroid-native prefix (bare OpenAI name e.g. 'gpt-4.1', litellm/anthropic/, gemini/, openrouter/, groq/, cerebras/, glhf/, minimax/, portkey/, deepseek/, ollama/, local/, vllm/, llamacpp/) or set REQUIRE_LANGROID_API_KEY=0 to downgrade to warn-mode." >&2
exit 1
fi
echo "[entrypoint] INFO: $role model '$model' does not match a known provider prefix — skipping env-key check (request-time calls will surface credentials)" >&2
return 0
fi
# Bash indirect expansion with default: ``${!var:-}`` resolves to the
# value of the env var NAMED by ``$var``, or "" if unset. The ``:-``
# default guarantees we evaluate to the empty string when the caller has
# not exported the credential, which is what the empty-check below
# expects. Note: this script runs under ``set -e`` but NOT ``set -u`` —
# every ``${FOO:-default}`` site in the file is load-bearing as-written
# because several env vars (REQUIRE_LANGROID_API_KEY, LANGROID_MODEL,
# A2UI_MODEL, PORT) are commonly unset in dev.
local val="${!var:-}"
if [ -z "$val" ]; then
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $var not set (required by $role model '$model') and REQUIRE_LANGROID_API_KEY=1 — refusing to start" >&2
exit 1
fi
echo "[entrypoint] WARN: $var not set — $role ('$model') calls will fail at request time (structured error returned to client)" >&2
fi
# Bare ``anthropic/<model>`` is not a langroid-native prefix; langroid
# only routes Anthropic via ``litellm/anthropic/...`` or
# ``openrouter/anthropic/...``. If an operator sets
# ``LANGROID_MODEL=anthropic/claude-opus-4`` the env-key check passes
# but the request will fail downstream because langroid falls back to
# the default OpenAI client and the OpenAI SDK rejects the model id.
#
# Under ``REQUIRE_LANGROID_API_KEY=1`` we FATAL (fail-fast contract) —
# silently booting and failing at first request contradicts the whole
# point of the guard. Under warn-mode we surface a WARN so local-dev
# operators can still bring the container up. The outer ``case``
# pattern already matched ``anthropic/*`` — no inner guard is needed
# (a string cannot simultaneously start with ``anthropic/`` and
# ``litellm/anthropic/``; the latter is handled by the earlier
# ``litellm/anthropic/*`` arm in ``_expected_key_for_model``).
# NOTE: this case intentionally tests only the bare ``anthropic/*``
# pattern. ``litellm/anthropic/...`` strings already matched the earlier
# ``litellm/anthropic/*`` arm in ``_expected_key_for_model`` (which runs
# first by design — see the ORDER MATTERS comment there) and are routed
# correctly via litellm; we must NOT warn on them here.
case "$model" in
anthropic/*)
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $role model '$model' uses bare 'anthropic/' prefix which is not routable through langroid (native langroid Anthropic support goes via 'litellm/anthropic/<model>' with ANTHROPIC_API_KEY set); refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2
exit 1
fi
echo "[entrypoint] WARN: $role model '$model' uses bare 'anthropic/' prefix — langroid has no native Anthropic routing; requests will fail. Use 'litellm/anthropic/<model>' instead (drop-in replacement that reads ANTHROPIC_API_KEY)." >&2
;;
esac
}
_check_key "$LANGROID_MODEL_EFFECTIVE" "primary agent"
if [ "$A2UI_MODEL_EFFECTIVE" != "$LANGROID_MODEL_EFFECTIVE" ]; then
_check_key "$A2UI_MODEL_EFFECTIVE" "A2UI planner"
fi
# Start agent backend.
# NOTE: `set -e` does not fire on backgrounded processes — if uvicorn crashes
# immediately, the shell still proceeds to start Next.js. We capture PIDs and
# probe them explicitly after `wait -n` so operators can tell which process
# died with which exit code.
#
# `python -u` + `awk ... fflush()` below: unbuffered stdout at the interpreter
# level + line-flushed awk prefixer so uvicorn request lines and tracebacks
# reach Railway's log stream immediately rather than block-buffered in pipe
# buffers.
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &> >(awk '{print "[agent] " $0; fflush()}') &
AGENT_PID=$!
# Start Next.js frontend (PORT defaults to 10000 — Railway / local compose
# override as needed).
npx next start --port ${PORT:-10000} &> >(awk '{print "[nextjs] " $0; fflush()}') &
NEXT_PID=$!
# Watchdog: Railway deploys of showcase packages have been observed to hit a
# silent agent hang — the Python process stays alive (so `wait -n` never
# fires and the container never restarts) but stops responding on :8000.
# Poll the agent's /health endpoint every 30s; after 3 consecutive failures
# (~90s of unreachable agent), kill the agent process so `wait -n` returns
# and Railway restarts the container. Generalized from
# showcase/integrations/crewai-crews/entrypoint.sh (PRs #4114 + #4115).
(
FAILS=0
while sleep 30; do
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
break
fi
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
FAILS=0
else
FAILS=$((FAILS + 1))
echo "[watchdog] Agent health probe failed (count=$FAILS)" >&2
if [ $FAILS -ge 3 ]; then
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart" >&2
kill -9 "$AGENT_PID" 2>/dev/null || true
break
fi
fi
done
) &
WATCHDOG_PID=$!
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)" >&2
# Wait for either process to exit; then figure out which one.
# set +e for wait -n; exit code captured explicitly into EXIT_CODE. The
# subsequent `kill -0` / `echo` calls run without errexit — that is fine
# because the final `exit "$EXIT_CODE"` uses the captured value, so the
# container exits with the dying child's status regardless.
#
# errexit (set -e) is INTENTIONALLY left off for the remainder of the
# script: the diagnostic and cleanup blocks below use `kill`, `kill -0`,
# and `wait` calls whose non-zero returns are expected (dead process,
# already-reaped child, EPERM). Re-enabling errexit would cause the shell
# to abort before the survivor-termination grace window runs.
set +e
# ``wait -n "$AGENT_PID" "$NEXT_PID"`` (positional pid list) narrows the wait
# to just the two children we explicitly spawned, so an unrelated reaped
# subshell (e.g. process-substitution helper) cannot spuriously satisfy
# ``wait -n`` with its exit code. Requires bash 5.1+ — the base image ships
# bash 5.2. For symmetry with the starter entrypoint.
wait -n "$AGENT_PID" "$NEXT_PID"
EXIT_CODE=$?
# Interpret common POSIX / shell exit codes for operators reading the log
# stream. These are the codes likely to show up from uvicorn/next.js/Node
# under typical container-orchestration conditions (OOM kill, SIGTERM,
# missing binary, uncaught-fatal, Ctrl-C during `docker run -it`, etc.).
case "$EXIT_CODE" in
0) EXIT_MEANING="clean exit (unexpected for a long-running server)" ;;
1) EXIT_MEANING="generic error (uncaught exception / non-zero program exit)" ;;
2) EXIT_MEANING="misuse of shell builtin / bad CLI args" ;;
126) EXIT_MEANING="command invoked but not executable (permission denied)" ;;
127) EXIT_MEANING="command not found (missing binary / bad PATH)" ;;
130) EXIT_MEANING="SIGINT (Ctrl-C / interactive interrupt)" ;;
137) EXIT_MEANING="SIGKILL (likely OOM-killed or force-stopped)" ;;
139) EXIT_MEANING="SIGSEGV (segmentation fault — native crash)" ;;
143) EXIT_MEANING="SIGTERM (orderly shutdown from platform)" ;;
255) EXIT_MEANING="exit -1 / catastrophic program failure" ;;
*) EXIT_MEANING="(no common interpretation)" ;;
esac
SURVIVOR_PID=""
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
echo "[entrypoint] agent backend (uvicorn, pid=$AGENT_PID) exited with code $EXIT_CODE$EXIT_MEANING" >&2
if kill -0 "$NEXT_PID" 2>/dev/null; then
SURVIVOR_PID="$NEXT_PID"
fi
elif ! kill -0 "$NEXT_PID" 2>/dev/null; then
echo "[entrypoint] next.js frontend (pid=$NEXT_PID) exited with code $EXIT_CODE$EXIT_MEANING" >&2
if kill -0 "$AGENT_PID" 2>/dev/null; then
SURVIVOR_PID="$AGENT_PID"
fi
else
# `wait -n` returned but both pids still resolve. This most commonly
# happens when a child was reaped before we ran `kill -0` (race), which
# means one IS actually dead — we just can't tell which. Escalate to
# ERROR + exit 1 so this path does not silently mask the real death.
# Under no-children-dead the shell would never reach this block.
echo "[entrypoint] ERROR: wait -n returned exit=$EXIT_CODE ($EXIT_MEANING) but both agent ($AGENT_PID) and next.js ($NEXT_PID) appear alive — treating as fatal race; the actual dying child's status has already been reaped" >&2
exit 1
fi
# Terminate the surviving sibling with a bounded grace window so it shuts
# down cleanly rather than getting SIGKILL'd by the container runtime at
# teardown.
if [ -n "$SURVIVOR_PID" ]; then
echo "[entrypoint] Terminating surviving sibling (pid=${SURVIVOR_PID}) to avoid orphan-reparent" >&2
# Capture kill failure: if `kill` returns non-zero AND the process is
# still alive, that's a real signal-delivery failure (e.g. EPERM) —
# surface it rather than letting `2>/dev/null` swallow the diagnosis.
if ! kill "$SURVIVOR_PID" 2>/dev/null; then
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
echo "[entrypoint] WARN: kill(SIGTERM) failed for survivor pid=${SURVIVOR_PID} but process is still alive — signal delivery refused (EPERM?)" >&2
fi
fi
for _ in 1 2 3 4 5; do
kill -0 "$SURVIVOR_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
echo "[entrypoint] Survivor (pid=${SURVIVOR_PID}) did not exit within 5s — sending SIGKILL" >&2
if ! kill -9 "$SURVIVOR_PID" 2>/dev/null; then
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
echo "[entrypoint] WARN: kill(SIGKILL) failed for survivor pid=${SURVIVOR_PID} but process is still alive — cannot force-terminate (EPERM?)" >&2
fi
fi
fi
wait "$SURVIVOR_PID" 2>/dev/null || true
fi
exit "$EXIT_CODE"