forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathentrypoint.sh
More file actions
executable file
·425 lines (410 loc) · 23.9 KB
/
Copy pathentrypoint.sh
File metadata and controls
executable file
·425 lines (410 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
#!/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"