426 lines
24 KiB
Bash
Executable File
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"
|