amsd-pipeline-shared 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/amsd-pipeline-shared.js +3 -0
- package/docs/CONSUMER-REVIEW-B56.md +2 -0
- package/docs/gateway-baseline-2.0.80.md5 +7 -2
- package/docs/gateway-files.txt +9 -3
- package/orchestrations/prompts/templates/codeline-discovery.json +40 -0
- package/orchestrations/scripts/lib/preflight.sh +65 -0
- package/orchestrations/scripts/lib/prelaunch.js +294 -0
- package/orchestrations/scripts/preflight-check.sh +696 -0
- package/orchestrations/scripts/preflight-prd-integrity.sh +36 -0
- package/orchestrations/scripts/preflight-static.sh +251 -0
- package/package.json +2 -2
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Service endpoints come from orchestrations/config/services.json — one place to change
|
|
3
|
+
# a port, instead of the 20+ copies these URLs used to have across the pipeline.
|
|
4
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/service-urls.sh"
|
|
5
|
+
_DASH="$(service_url dashboard)"
|
|
6
|
+
|
|
7
|
+
# preflight-check.sh — validates everything before a paid pipeline run.
|
|
8
|
+
# Catches missing exports, bad PRD config, missing API keys, and wrong paths.
|
|
9
|
+
# Called automatically by pre-run-reset.sh, or run standalone.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# bash orchestrations/scripts/preflight-check.sh --runner tier3-travel-app-run.sh \
|
|
13
|
+
# --prd orchestrations/travel-app-prd.json
|
|
14
|
+
#
|
|
15
|
+
# Exit 0 = all checks pass. Exit 1 = one or more checks failed (do NOT run).
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
# shellcheck source=lib/resume-semantics.sh
|
|
20
|
+
. "$SCRIPT_DIR/lib/resume-semantics.sh"
|
|
21
|
+
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
22
|
+
# Config files are DATA: load them without executing them. See lib/env-file.sh.
|
|
23
|
+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-file.sh"
|
|
24
|
+
|
|
25
|
+
# ── Args ─────────────────────────────────────────────────────────────────────
|
|
26
|
+
# Inherit from the launching environment when present: a launcher that already resolved
|
|
27
|
+
# PRD_FILE should not have to pass it again, and blanking it here made the pre-flight
|
|
28
|
+
# report "PRD_FILE is unset" about an environment where it was plainly set.
|
|
29
|
+
RUNNER_SCRIPT="${RUNNER_SCRIPT:-}"
|
|
30
|
+
PRD_FILE="${PRD_FILE:-}"
|
|
31
|
+
PROJECT_CONFIG_DIR="${EPAM_PROJECT_CONFIG_DIR:-}"
|
|
32
|
+
while [[ $# -gt 0 ]]; do
|
|
33
|
+
case $1 in
|
|
34
|
+
--runner) RUNNER_SCRIPT="$SCRIPT_DIR/$2"; shift 2 ;;
|
|
35
|
+
--prd) PRD_FILE="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"; shift 2 ;;
|
|
36
|
+
--project-config) PROJECT_CONFIG_DIR="$2"; shift 2 ;;
|
|
37
|
+
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
PASS=0; FAIL=0
|
|
42
|
+
ok() { echo " ✓ $*"; PASS=$((PASS+1)); }
|
|
43
|
+
fail() { echo " ✗ $*" >&2; FAIL=$((FAIL+1)); }
|
|
44
|
+
|
|
45
|
+
echo ""
|
|
46
|
+
echo "━━━ Pre-flight checks ━━━"
|
|
47
|
+
|
|
48
|
+
# ── 1. Runner script exists and is shellcheck-clean ──────────────────────────
|
|
49
|
+
echo "[ Runner script ]"
|
|
50
|
+
if [[ -z "$RUNNER_SCRIPT" ]]; then
|
|
51
|
+
fail "No --runner specified"
|
|
52
|
+
elif [[ ! -f "$RUNNER_SCRIPT" ]]; then
|
|
53
|
+
fail "Runner not found: $RUNNER_SCRIPT"
|
|
54
|
+
else
|
|
55
|
+
ok "Runner exists: $(basename "$RUNNER_SCRIPT")"
|
|
56
|
+
if command -v shellcheck &>/dev/null; then
|
|
57
|
+
# THE VERDICT IS A FUNCTION OF THE BYTES, SO IT IS KEPT PER DIGEST. shellcheck holds ~3.3 GB
|
|
58
|
+
# for the 11,000-line orchestrator and takes ten seconds; running it on every launch cost that
|
|
59
|
+
# every launch, and under a memory cap sized to the host it was the one step that could not
|
|
60
|
+
# fit (2026-09-13: the £0 greenfield rehearsal was killed here twice). The same file yields the
|
|
61
|
+
# same verdict, so a clean verdict is recorded beside the script under its sha256 and a launch
|
|
62
|
+
# of unchanged bytes reads the record. A failing verdict is never recorded: it is re-run and
|
|
63
|
+
# re-reported until the script changes.
|
|
64
|
+
_sc_cache_dir="${EPAM_PREFLIGHT_CACHE_DIR:-$SCRIPT_DIR/.preflight-cache}"
|
|
65
|
+
_sc_digest="$(sha256sum "$RUNNER_SCRIPT" 2>/dev/null | cut -d' ' -f1)"
|
|
66
|
+
_sc_mark="$_sc_cache_dir/shellcheck-${_sc_digest:-nodigest}.ok"
|
|
67
|
+
if [[ -n "$_sc_digest" && -f "$_sc_mark" ]]; then
|
|
68
|
+
ok "shellcheck clean (verdict recorded for these exact bytes, ${_sc_digest:0:12}; not re-run)"
|
|
69
|
+
elif shellcheck --severity=error "$RUNNER_SCRIPT" 2>/dev/null; then
|
|
70
|
+
ok "shellcheck clean"
|
|
71
|
+
if [[ -n "$_sc_digest" ]]; then
|
|
72
|
+
mkdir -p "$_sc_cache_dir" 2>/dev/null && : > "$_sc_mark" 2>/dev/null || true
|
|
73
|
+
fi
|
|
74
|
+
else
|
|
75
|
+
fail "shellcheck errors in $(basename "$RUNNER_SCRIPT")"
|
|
76
|
+
fi
|
|
77
|
+
else
|
|
78
|
+
ok "shellcheck not installed — skipping"
|
|
79
|
+
fi
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# ── 2. Critical variables RESOLVE in the launching environment ───────────────
|
|
83
|
+
# This used to grep the launcher's source for "export VAR". A string in a file is not an
|
|
84
|
+
# environment: it missed tier3-metrolinx-run.sh's shared `export A B C` line and demanded
|
|
85
|
+
# OUTPUT_DIR/PROJECT_ROOT from a launcher that correctly sets them per lane downstream.
|
|
86
|
+
# Which variables matter is declared in config, not here.
|
|
87
|
+
echo "[ Critical variables ]"
|
|
88
|
+
_req_cfg="$REPO_ROOT/orchestrations/config/preflight-required-env.json"
|
|
89
|
+
if [ -f "$_req_cfg" ]; then
|
|
90
|
+
while IFS=$'\t' read -r _var _why; do
|
|
91
|
+
[ -n "$_var" ] || continue
|
|
92
|
+
if [ -n "${!_var:-}" ]; then
|
|
93
|
+
ok "$_var is resolved"
|
|
94
|
+
else
|
|
95
|
+
fail "$_var is unset — $_why"
|
|
96
|
+
fi
|
|
97
|
+
done < <(jq -r '.required | to_entries[] | "\(.key)\t\(.value)"' "$_req_cfg" 2>/dev/null)
|
|
98
|
+
while IFS=$'\t' read -r _var _why; do
|
|
99
|
+
[ -n "$_var" ] || continue
|
|
100
|
+
[ -n "${!_var:-}" ] && ok "$_var is resolved" || echo " – $_var unset — $_why"
|
|
101
|
+
done < <(jq -r '.advisory | to_entries[] | "\(.key)\t\(.value)"' "$_req_cfg" 2>/dev/null)
|
|
102
|
+
else
|
|
103
|
+
fail "preflight-required-env.json missing — cannot tell which variables this run needs"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# Is PRD_FILE about to be OVERWRITTEN by this run's own Jira ingest, before anything
|
|
107
|
+
# downstream ever reads today's content? ingest-jira-tickets.sh writes to
|
|
108
|
+
# JIRA_SYNTH_PRD_PATH if set, else falls through to PRD_FILE (run-agent-orchestration.sh
|
|
109
|
+
# `"${JIRA_SYNTH_PRD_PATH:-${PRD_FILE:-...}}"`). Whenever that target IS PRD_FILE, whatever
|
|
110
|
+
# is on disk right now is LAST run's leftover, not this run's input — checking its content
|
|
111
|
+
# is checking the wrong PRD.
|
|
112
|
+
#
|
|
113
|
+
# Caught live 2026-08-05: a metrolinx pre-flight failed on "pre-baked specification blocks"
|
|
114
|
+
# on AMSD-2041 in orchestrations/projects/metrolinx/prd.json — real contamination, but from
|
|
115
|
+
# the PREVIOUS run, about to be discarded by ingest before the writer ever runs. The earlier
|
|
116
|
+
# version of this guard only recognised a genuinely EMPTY placeholder (mock1's shape) and
|
|
117
|
+
# missed this — a non-empty stale file about to be overwritten is the same situation.
|
|
118
|
+
# The project's declared codeline root, read the same way the launcher reads it. Its presence is
|
|
119
|
+
# what tells this gate that scope — and therefore outputDir — is resolved during the run.
|
|
120
|
+
# A KEY FROM THE ENVIRONMENT, ELSE FROM THE PROJECT'S OWN ENV FILES — read the way the launcher
|
|
121
|
+
# reads it. One lookup for every key this gate needs from a project (JIRA_CODELINE_ROOT,
|
|
122
|
+
# EPAM_ESTIMATION_MANIFEST): a second copy of the rule would be free to drift.
|
|
123
|
+
_project_env_value() {
|
|
124
|
+
local _key="$1" _val="${!1:-}"
|
|
125
|
+
if [[ -z "$_val" ]] && [[ -n "${PROJECT_CONFIG_DIR:-}" ]]; then
|
|
126
|
+
# THE FILENAME IS NOT SPELLED HERE. The registry names a project's env files
|
|
127
|
+
# (config/provider-sets.json); a literal would be a second home for that name, free to drift
|
|
128
|
+
# with nothing failing. Both halves are scanned because the key may live in either, and the
|
|
129
|
+
# two are disjoint so at most one can answer.
|
|
130
|
+
local _pe_lib _pe_f
|
|
131
|
+
_pe_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/llm-settings-resolve.js"
|
|
132
|
+
if [[ -f "$_pe_lib" ]] && { [[ -n "${NODE_BIN:-}" ]] || command -v node >/dev/null 2>&1; }; then
|
|
133
|
+
while IFS= read -r _pe_f; do
|
|
134
|
+
[[ -n "$_pe_f" ]] && [[ -f "$_pe_f" ]] || continue
|
|
135
|
+
_val=$(sed -n "s/^[[:space:]]*${_key}=//p" "$_pe_f" \
|
|
136
|
+
| tail -1 | tr -d '"'"'"'"' )
|
|
137
|
+
[[ -n "$_val" ]] && break
|
|
138
|
+
done < <("${NODE_BIN:-node}" -e '
|
|
139
|
+
const { projectEnvFiles } = require(process.argv[1]);
|
|
140
|
+
const f = projectEnvFiles(process.argv[2]);
|
|
141
|
+
if (f) process.stdout.write(f.base + "\n" + f.overlay + "\n");
|
|
142
|
+
' "$_pe_lib" "$PROJECT_CONFIG_DIR" 2>/dev/null)
|
|
143
|
+
fi
|
|
144
|
+
fi
|
|
145
|
+
printf '%s' "$_val"
|
|
146
|
+
}
|
|
147
|
+
_codeline_root="$(_project_env_value JIRA_CODELINE_ROOT)"
|
|
148
|
+
|
|
149
|
+
_prd_pending_ingest=0
|
|
150
|
+
if [[ -n "${JIRA_URL:-}" ]] && [[ -n "$PRD_FILE" ]]; then
|
|
151
|
+
_synth_target="${JIRA_SYNTH_PRD_PATH:-$PRD_FILE}"
|
|
152
|
+
if [[ -n "$_synth_target" ]] && [[ "$(cd "$(dirname "$_synth_target")" 2>/dev/null && pwd)/$(basename "$_synth_target")" == "$PRD_FILE" ]]; then
|
|
153
|
+
_prd_pending_ingest=1
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# ── 3. PRD integrity gate ────────────────────────────────────────────────────
|
|
158
|
+
echo "[ PRD integrity ]"
|
|
159
|
+
if [[ -z "$PRD_FILE" ]]; then
|
|
160
|
+
fail "No --prd specified (required for integrity gate)"
|
|
161
|
+
elif [[ ! -f "$PRD_FILE" ]] && { [[ "$_prd_pending_ingest" == "1" ]] || [[ -n "$_codeline_root" ]]; }; then
|
|
162
|
+
# A PRD THAT DOES NOT EXIST YET IS NOT A FAILURE WHEN THE RUN CREATES IT.
|
|
163
|
+
#
|
|
164
|
+
# prd.json is REGENERATED every run (Jira ingest, or resolve-codeline-scope.sh for an authored
|
|
165
|
+
# PRD) and is no longer committed, so a fresh install legitimately has none at this point. This
|
|
166
|
+
# gate demanded the file anyway and refused every first launch on a clean install.
|
|
167
|
+
ok "no PRD on disk yet — this run creates it (ingest / scope resolution); integrity check deferred"
|
|
168
|
+
elif [[ ! -f "$PRD_FILE" ]]; then
|
|
169
|
+
fail "PRD not found: $PRD_FILE"
|
|
170
|
+
else
|
|
171
|
+
# Detect canonical (pre-spec-pass) PRD: no story has specification.createdFrom set.
|
|
172
|
+
# Strict phase/field checks only apply to elaborated PRDs — skip them for canonical.
|
|
173
|
+
_prd_is_canonical=$(python3 "$SCRIPT_DIR/lib/handlers/prd-is-canonical.py" "$PRD_FILE" 2>/dev/null || echo "false")
|
|
174
|
+
|
|
175
|
+
if [[ "$_prd_is_canonical" == "true" ]]; then
|
|
176
|
+
# Even on a canonical (pre-spec-pass) PRD, no base story should carry a
|
|
177
|
+
# pre-baked 'specification' block (status, coordinatorReview, etc.) from
|
|
178
|
+
# a prior run — the spec-mode coordinator reads this field to decide
|
|
179
|
+
# whether to (re)assign agents, and stale "completed" data silently makes
|
|
180
|
+
# it skip re-elaboration (and the split-mandate check inside that loop)
|
|
181
|
+
# for that story. This has happened once already (2026-07-06); the
|
|
182
|
+
# `has_splits` bypass above wouldn't catch it because these stories have
|
|
183
|
+
# no `createdFrom`, only stale status data. A story already completed=true
|
|
184
|
+
# in THIS run (e.g. SKY-001 after the scaffold phase) legitimately carries
|
|
185
|
+
# its own real specification data from this run's own spec pass — only
|
|
186
|
+
# PENDING stories carrying specification data indicate prior-run
|
|
187
|
+
# contamination baked into canonical.
|
|
188
|
+
_stale_spec=$(python3 "$SCRIPT_DIR/lib/handlers/prd-stale-specification-stories.py" "$PRD_FILE" 2>/dev/null || echo "")
|
|
189
|
+
# A RESUME LEGITIMATELY CARRIES ITS OWN SPEC PASS.
|
|
190
|
+
#
|
|
191
|
+
# This gate reads a pending story's specification block as a PRIOR run baked into the
|
|
192
|
+
# canonical, which is right for a fresh run and has fired for real (2026-07-06). A resume is
|
|
193
|
+
# the second case it cannot distinguish, alongside the pending-ingest one below: the blocks
|
|
194
|
+
# belong to the run being resumed, and that run IS this run. On 2026-08-18 it refused the
|
|
195
|
+
# resume of 20260818T101809Z — whose canonical was correctly lean while the runtime PRD held
|
|
196
|
+
# 6 and 3 verification criteria, 2 fix sites each and both roles — for carrying exactly the
|
|
197
|
+
# output the resume exists to reuse.
|
|
198
|
+
if [[ -n "$_stale_spec" ]] && resume_preserves spec-blocks; then
|
|
199
|
+
ok "PRD carries specification data for the run being resumed (EPAM_RESUME_RUN=${EPAM_RESUME_RUN:-}) — this is the resumed run's own output, not a prior run's"
|
|
200
|
+
PASS=$((PASS+1))
|
|
201
|
+
elif [[ -n "$_stale_spec" ]] && [[ "$_prd_pending_ingest" != "1" ]]; then
|
|
202
|
+
fail "Canonical PRD has pre-baked 'specification' blocks on base stories (must be lean/unelaborated): $_stale_spec"
|
|
203
|
+
elif [[ -n "$_stale_spec" ]]; then
|
|
204
|
+
ok "PRD carries stale specification data from a prior run, but Jira ingest overwrites this exact file before anything reads it — deferred"
|
|
205
|
+
PASS=$((PASS+1))
|
|
206
|
+
else
|
|
207
|
+
_base_count=$(python3 "$SCRIPT_DIR/lib/handlers/prd-story-count.py" "$PRD_FILE" 2>/dev/null || echo "?")
|
|
208
|
+
ok "PRD integrity OK — $_base_count base user stories (canonical/pre-spec-pass — strict phase checks deferred until after spec pass elaboration)"
|
|
209
|
+
PASS=$((PASS+1))
|
|
210
|
+
fi
|
|
211
|
+
else
|
|
212
|
+
integrity_out=$(bash "$SCRIPT_DIR/preflight-prd-integrity.sh" --prd "$PRD_FILE" 2>&1) || integrity_exit=$?
|
|
213
|
+
echo "$integrity_out"
|
|
214
|
+
if [[ "${integrity_exit:-0}" -ne 0 ]]; then
|
|
215
|
+
FAIL=$((FAIL+1))
|
|
216
|
+
else
|
|
217
|
+
PASS=$((PASS+1))
|
|
218
|
+
fi
|
|
219
|
+
fi
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
# ── 4. PRD file valid JSON with required fields ───────────────────────────────
|
|
223
|
+
echo "[ PRD file ]"
|
|
224
|
+
if [[ -z "$PRD_FILE" ]]; then
|
|
225
|
+
fail "No --prd specified"
|
|
226
|
+
elif [[ ! -f "$PRD_FILE" ]] && { [[ "$_prd_pending_ingest" == "1" ]] || [[ -n "$_codeline_root" ]]; }; then
|
|
227
|
+
# Same reason as the integrity gate above: the run writes this file, so its absence here is the
|
|
228
|
+
# expected state of a fresh install, not a fault to refuse the launch over.
|
|
229
|
+
ok "PRD is written by this run (ingest / scope resolution) — file checks deferred"
|
|
230
|
+
elif [[ ! -f "$PRD_FILE" ]]; then
|
|
231
|
+
fail "PRD not found: $PRD_FILE"
|
|
232
|
+
else
|
|
233
|
+
ok "PRD exists: $(basename "$PRD_FILE")"
|
|
234
|
+
if python3 -c "import json; json.load(open('$PRD_FILE'))" 2>/dev/null; then
|
|
235
|
+
ok "PRD valid JSON"
|
|
236
|
+
else
|
|
237
|
+
fail "PRD is not valid JSON"
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
# outputDir must be set
|
|
241
|
+
#
|
|
242
|
+
# THE CONDITION IS TESTED BEFORE THE VALUE, and that ordering is the whole point.
|
|
243
|
+
#
|
|
244
|
+
# This read the field FIRST and reported whatever it found, so both deferral branches below were
|
|
245
|
+
# unreachable whenever the file on disk happened to carry a value. That is every fresh install:
|
|
246
|
+
# pre-flight runs BEFORE ingest, so prd.json at this moment is not this run's PRD at all — it is
|
|
247
|
+
# the file the install shipped, or the PREVIOUS run's output, and the run overwrites it minutes
|
|
248
|
+
# later (resolve-codeline-scope.sh -> apply-codeline-scope.js).
|
|
249
|
+
#
|
|
250
|
+
# Live 2026-09-04, pipeline-tests-10: a run correctly targeting the test codelines reported
|
|
251
|
+
# "✓ PRD project.outputDir = /home/…/metrolinx/next.gotransit.com" — a REAL CLIENT PATH, out of a
|
|
252
|
+
# dead file — and the comparison below it turns the same stale value into a HARD FAILURE
|
|
253
|
+
# ("does NOT match PRD outputDir") for a run whose real target is simply different.
|
|
254
|
+
#
|
|
255
|
+
# When the run resolves scope, the field is not read, not reported, and not compared.
|
|
256
|
+
OUTPUT_DIR_VAL=""
|
|
257
|
+
if [[ "$_prd_pending_ingest" == "1" ]]; then
|
|
258
|
+
ok "PRD content is pending Jira ingest for this run — outputDir check deferred"
|
|
259
|
+
elif [[ -n "$_codeline_root" ]]; then
|
|
260
|
+
# DEFERRED FOR THE SAME REASON, ARRIVED AT DIFFERENTLY.
|
|
261
|
+
#
|
|
262
|
+
# project.outputDir is WRITTEN BY THE RUN: resolve-codeline-scope.sh calls
|
|
263
|
+
# apply-codeline-scope.js, which sets outputDirs and outputDir from the codelines it
|
|
264
|
+
# discovers. So this gate demanded a field the pipeline had not created yet, and no
|
|
265
|
+
# PRD-authored project could ever pass it.
|
|
266
|
+
#
|
|
267
|
+
# The deferral above already exists for the Jira path — the same reasoning, keyed to
|
|
268
|
+
# ingest. That is the Step 0.3 shape again: a mechanism written only for the branch it was
|
|
269
|
+
# first needed on. A project that declares a codeline root resolves its scope the same way,
|
|
270
|
+
# whether its PRD arrived from Jira or was authored by hand.
|
|
271
|
+
ok "codeline scope is resolved during the run (root: $_codeline_root) — outputDir check deferred"
|
|
272
|
+
else
|
|
273
|
+
# NOTHING WILL RESOLVE THIS LATER, so the PRD's own declared value is the answer and IS checked.
|
|
274
|
+
# Read here rather than above, so a deferred run never touches the field at all.
|
|
275
|
+
OUTPUT_DIR_VAL=$(python3 -c "import json; d=json.load(open('$PRD_FILE')); print(d.get('project',{}).get('outputDir',''))" 2>/dev/null || true)
|
|
276
|
+
if [[ -n "$OUTPUT_DIR_VAL" ]]; then
|
|
277
|
+
ok "PRD project.outputDir = $OUTPUT_DIR_VAL"
|
|
278
|
+
else
|
|
279
|
+
fail "PRD project.outputDir is NOT set, and no codeline root is declared to resolve it from"
|
|
280
|
+
fi
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
# The RESOLVED OUTPUT_DIR must agree with the PRD's. This used to scrape `OUTPUT_DIR=`
|
|
284
|
+
# out of the launcher's SOURCE with grep+sed, which is the same anti-pattern as the export
|
|
285
|
+
# check above and failed the same way: tier3-mock-run.sh takes its output directory from
|
|
286
|
+
# --project-root and never spells OUTPUT_DIR= at all, so the scrape produced '' and the
|
|
287
|
+
# pre-flight declared a mismatch against a perfectly correct launcher. A launcher that
|
|
288
|
+
# resolves the directory per lane has nothing to compare here, and saying so is honest.
|
|
289
|
+
if [[ -n "$OUTPUT_DIR_VAL" ]]; then
|
|
290
|
+
if [[ -z "${OUTPUT_DIR:-}" ]]; then
|
|
291
|
+
echo " – OUTPUT_DIR not resolved in this environment (set per lane downstream) — nothing to compare"
|
|
292
|
+
elif [[ "${OUTPUT_DIR}" == "$OUTPUT_DIR_VAL" ]]; then
|
|
293
|
+
ok "OUTPUT_DIR agrees with PRD outputDir ($OUTPUT_DIR_VAL)"
|
|
294
|
+
else
|
|
295
|
+
fail "OUTPUT_DIR='${OUTPUT_DIR}' does NOT match PRD outputDir='$OUTPUT_DIR_VAL' — deliverables would be checked in the wrong place"
|
|
296
|
+
fi
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
# Story field checks — a canonical PRD is judged on what it must NOT carry; an elaborated one on
|
|
300
|
+
# what it must.
|
|
301
|
+
#
|
|
302
|
+
# A RESUME IS JUDGED BY NEITHER. Its PRD is the run's own working copy, restored from the
|
|
303
|
+
# checkpoint, and carries the run's own assignments; a story that was never split carries no
|
|
304
|
+
# specification.createdFrom, so prd-is-canonical calls that copy canonical and the pin refusal
|
|
305
|
+
# aborted every resume of the brownfield rehearsal on the assigner's own agentRole, the ladder's
|
|
306
|
+
# own model and the set's own aiProvider (£0 brownfield harness run 5, 2026-09-14).
|
|
307
|
+
if resume_preserves prd-state; then
|
|
308
|
+
ok "resuming run ${EPAM_RESUME_RUN}: the PRD is the run's own working copy — the authored-PRD checks do not apply"
|
|
309
|
+
elif [[ "$_prd_is_canonical" == "true" ]]; then
|
|
310
|
+
# A CANONICAL PRD PINS NOTHING THE RUN DECIDES. A story's agent is the assigner's decision, its
|
|
311
|
+
# model the ladder's, its provider the set's. Authored with any of them, the PRD bypasses that
|
|
312
|
+
# stage and the mint refuses MID-RUN — "assigned python-engineer, which is not in the roster" —
|
|
313
|
+
# after the roster was minted, the prompts generated and the spend made (2026-09-13, a
|
|
314
|
+
# generated greenfield PRD). This used to say "deferred" and pass. Refused here by story and
|
|
315
|
+
# field, before any model is called, so the correction costs nothing.
|
|
316
|
+
_pinned=$(jq -r '
|
|
317
|
+
[ .stories[]? | select(.id) as $s
|
|
318
|
+
| ["agentRole","model","aiProvider"][] as $k
|
|
319
|
+
| select(($s[$k] // "") != "") | "\($s.id): \($k)=\($s[$k])" ] | .[]' "$PRD_FILE" 2>/dev/null || true)
|
|
320
|
+
if [[ -n "$_pinned" ]] && [[ "$_prd_pending_ingest" == "1" ]]; then
|
|
321
|
+
# THE FILE ON DISK IS THE PREVIOUS RUN'S, AND THIS RUN OVERWRITES IT. A Jira project's PRD is
|
|
322
|
+
# synthesised by ingest every run; what sits here now carries the previous run's own
|
|
323
|
+
# assignments, exactly as the stale-specification check above already understands. Refusing on
|
|
324
|
+
# it refused the second launch of every Jira project (£0 replay of a Sept 9 brownfield
|
|
325
|
+
# cassette, 2026-09-17). Reported, not refused — ingest replaces it before anything reads it.
|
|
326
|
+
ok "PRD on disk carries a previous run's assignments (agentRole/model/aiProvider), but this run's Jira ingest overwrites this exact file before anything reads it — deferred"
|
|
327
|
+
elif [[ -n "$_pinned" ]]; then
|
|
328
|
+
fail "the canonical PRD pins what the run decides — the assigner owns agentRole, the ladder owns model, the set owns aiProvider. Remove these from the authored PRD:"
|
|
329
|
+
while IFS= read -r _line; do [[ -n "$_line" ]] && echo " $_line" >&2; done <<< "$_pinned"
|
|
330
|
+
else
|
|
331
|
+
ok "canonical PRD pins no agent, model or provider — the run will decide each"
|
|
332
|
+
fi
|
|
333
|
+
else
|
|
334
|
+
python3 "$SCRIPT_DIR/lib/handlers/prd-story-assignment-check.py" "$PRD_FILE" "$SCRIPT_DIR/../config/providers.json"
|
|
335
|
+
story_exit=$?
|
|
336
|
+
[[ $story_exit -eq 0 ]] && ((PASS++)) || ((FAIL++))
|
|
337
|
+
fi
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
# ── 4. Required API keys ──────────────────────────────────────────────────────
|
|
341
|
+
echo "[ API keys ]"
|
|
342
|
+
# THE ACTIVE SET SAYS WHICH KEYS A RUN NEEDS; THE PROJECT SAYS WHICH IT NEEDS BESIDES.
|
|
343
|
+
# This named OPENROUTER_API_KEY and OPENAI_API_KEY as required for every run — two vendors, in the
|
|
344
|
+
# engine, whatever set was active — and RAPIDAPI_KEY, one project's contract-discovery key, as a
|
|
345
|
+
# warning for all of them. On the claude, codemie and mockserver sets the first two are absent by
|
|
346
|
+
# design (the mockserver set REMOVES vendor credentials so a rehearsal cannot spend), so pre-flight
|
|
347
|
+
# refused every £0 rehearsal and every subscription-billed run over keys nothing would use.
|
|
348
|
+
# Found 2026-09-13 opening the greenfield rehearsal. Derived: the set's own credential declarations
|
|
349
|
+
# (config/provider-sets.json, via lib/set-credentials.sh) and the project's REQUIRED_KEYS.
|
|
350
|
+
load_env_file_safe "$REPO_ROOT/.env" keep-existing
|
|
351
|
+
. "$SCRIPT_DIR/lib/set-credentials.sh"
|
|
352
|
+
_pf_set_keys="$(set_required_keys 2>/dev/null || printf '')"
|
|
353
|
+
if [[ -z "$_pf_set_keys" ]]; then
|
|
354
|
+
ok "the active provider set (${EPAM_PROVIDER_SET:-default}) declares no required credentials"
|
|
355
|
+
fi
|
|
356
|
+
IFS=',' read -ra _pf_keys <<< "${_pf_set_keys}"
|
|
357
|
+
for key in "${_pf_keys[@]}"; do
|
|
358
|
+
key="${key// /}"; [[ -z "$key" ]] && continue
|
|
359
|
+
if [[ -n "${!key:-}" ]]; then
|
|
360
|
+
ok "$key is set (required by the active provider set)"
|
|
361
|
+
else
|
|
362
|
+
fail "$key is NOT set — the active provider set requires it; the run will fail"
|
|
363
|
+
fi
|
|
364
|
+
done
|
|
365
|
+
|
|
366
|
+
IFS=',' read -ra _pf_project_keys <<< "${REQUIRED_KEYS:-}"
|
|
367
|
+
for key in "${_pf_project_keys[@]}"; do
|
|
368
|
+
key="${key// /}"; [[ -z "$key" ]] && continue
|
|
369
|
+
if [[ -n "${!key:-}" ]]; then
|
|
370
|
+
ok "$key is set (required by this project)"
|
|
371
|
+
else
|
|
372
|
+
echo " ⚠ $key not set — this project declares it in REQUIRED_KEYS; the story that needs it may fail"
|
|
373
|
+
fi
|
|
374
|
+
done
|
|
375
|
+
|
|
376
|
+
# ── Balance: what the account can still spend, against what this launch expects to ─────────
|
|
377
|
+
# regintel 20260919T224649Z resume 6 burned attempts into 402s on an empty account; a launch on
|
|
378
|
+
# 2026-09-20 was about to start on $13.50 for a ~$20 run. The set declares how to read the
|
|
379
|
+
# balance (balanceProbe); the project may declare the launch's budget (costControls.runBudgetUsd).
|
|
380
|
+
. "$SCRIPT_DIR/lib/spend-probe.sh" 2>/dev/null || true
|
|
381
|
+
_pf_balance="$(balance_probe_read 2>/dev/null || true)"
|
|
382
|
+
_pf_budget="${EPAM_RUN_BUDGET_USD:-}"
|
|
383
|
+
if [[ -z "$_pf_balance" ]]; then
|
|
384
|
+
ok "balance: the active provider set (${EPAM_PROVIDER_SET:-default}) declares no balance probe, or it answered no figure — not judged"
|
|
385
|
+
elif [[ -n "$_pf_budget" ]] && "${NODE_BIN:-node}" -e 'process.exit(Number(process.argv[1]) < Number(process.argv[2]) ? 0 : 1)' "$_pf_balance" "$_pf_budget" 2>/dev/null; then
|
|
386
|
+
fail "balance \$${_pf_balance} is below this project's declared run budget \$${_pf_budget} (costControls.runBudgetUsd) — top up before launching"
|
|
387
|
+
else
|
|
388
|
+
ok "balance \$${_pf_balance}${_pf_budget:+ covers the declared run budget \$${_pf_budget}}"
|
|
389
|
+
fi
|
|
390
|
+
|
|
391
|
+
# ── Sign-in: the set's own check that this machine can call its stack ─────────────────────
|
|
392
|
+
# A set whose calls ride a session (CodeMie web: the CLI's SSO) declares signInProbe — a command,
|
|
393
|
+
# relative to orchestrations/, that exits 0 when a call can be made and names what is missing when
|
|
394
|
+
# not. Without it a signed-out session is found by the first story, on every rung of its ladder.
|
|
395
|
+
# A set declaring none is not judged.
|
|
396
|
+
preflight_sign_in() {
|
|
397
|
+
local _pf_probe_argv=() _a _l _pf_probe_out _pf_probe_err _n=0
|
|
398
|
+
# One argument per line, each newline-terminated — `read` drops an unterminated last line, and a
|
|
399
|
+
# probe command without its script is `node` alone, which reads an empty stdin and "passes".
|
|
400
|
+
while IFS= read -r _a; do [[ -n "$_a" ]] && _pf_probe_argv+=("$_a"); done < <("${NODE_BIN:-node}" -e '
|
|
401
|
+
const fs=require("fs"),path=require("path");
|
|
402
|
+
const reg=JSON.parse(fs.readFileSync(path.join(process.argv[1],"..","config","provider-sets.json"),"utf8"));
|
|
403
|
+
const p=((reg.sets||{})[process.env.EPAM_PROVIDER_SET||reg.defaultSet||""]||{}).signInProbe;
|
|
404
|
+
if(p&&Array.isArray(p.command)) for(const a of p.command) process.stdout.write(a+"\n");
|
|
405
|
+
' "$SCRIPT_DIR" 2>/dev/null || true)
|
|
406
|
+
if [[ "${#_pf_probe_argv[@]}" -eq 0 ]]; then
|
|
407
|
+
ok "sign-in: the active provider set (${EPAM_PROVIDER_SET:-default}) declares no sign-in probe — not judged"
|
|
408
|
+
return 0
|
|
409
|
+
fi
|
|
410
|
+
[[ "${_pf_probe_argv[0]}" = "node" ]] && _pf_probe_argv[0]="${NODE_BIN:-node}"
|
|
411
|
+
_pf_probe_err="$(mktemp)"
|
|
412
|
+
# stdout: one line per check that held; stderr: the sign-in chatter and, on refusal, the reason last.
|
|
413
|
+
if _pf_probe_out="$(cd "$SCRIPT_DIR/.." && "${_pf_probe_argv[@]}" 2>"$_pf_probe_err" </dev/null)"; then
|
|
414
|
+
while IFS= read -r _l; do [[ -n "$_l" ]] && { ok "sign-in: $_l"; _n=$((_n + 1)); }; done <<< "$_pf_probe_out"
|
|
415
|
+
[[ "$_n" -gt 0 ]] || ok "sign-in: the ${EPAM_PROVIDER_SET:-default} set's probe passed"
|
|
416
|
+
else
|
|
417
|
+
fail "sign-in: the ${EPAM_PROVIDER_SET:-default} set's probe refused — $(grep -v '^[[:space:]]*$' "$_pf_probe_err" | tail -1)"
|
|
418
|
+
fi
|
|
419
|
+
rm -f "$_pf_probe_err"
|
|
420
|
+
}
|
|
421
|
+
preflight_sign_in
|
|
422
|
+
|
|
423
|
+
# ── 5. Every assigned model is a rung of a declared ladder ───────────────────
|
|
424
|
+
#
|
|
425
|
+
# A model absent from the chain cannot escalate: the successor lookup returns EMPTY,
|
|
426
|
+
# which every call site reads as "already at the top rung". The story then burns all of
|
|
427
|
+
# its attempts on one model and the log is indistinguishable from a legitimate ceiling.
|
|
428
|
+
#
|
|
429
|
+
# Live, run 20260814T224748Z: the prd-model-coordinator assigned `gpt-5-codex`, a model
|
|
430
|
+
# on no ladder this project declares. Its own deterministic reviewer checks STRUCTURE —
|
|
431
|
+
# which fields changed, whether stories were added — and never membership. The run only
|
|
432
|
+
# escaped because ladder position had persisted from an earlier run and it resumed
|
|
433
|
+
# mid-chain, ignoring the PRD's assignment entirely.
|
|
434
|
+
#
|
|
435
|
+
# Here rather than in one launcher: this file is what all eight launchers pre-flight
|
|
436
|
+
# through, and the same coordinator writes the PRD for every one of them.
|
|
437
|
+
echo "[ Model ladders ]"
|
|
438
|
+
_mlm_lib="$SCRIPT_DIR/lib/model-ladder-membership.sh"
|
|
439
|
+
_mlm_settings="${EPAM_LLM_SETTINGS_FILE:-${PROJECT_CONFIG_DIR:-}/llm-settings.json}"
|
|
440
|
+
if [[ ! -f "$_mlm_lib" ]]; then
|
|
441
|
+
fail "lib/model-ladder-membership.sh missing — cannot verify assigned models can escalate"
|
|
442
|
+
elif [[ -z "${PRD_FILE:-}" || ! -f "${PRD_FILE:-}" ]]; then
|
|
443
|
+
echo " ⚠ no PRD to check model assignments against"
|
|
444
|
+
elif [[ ! -f "$_mlm_settings" ]]; then
|
|
445
|
+
# UNKNOWN, not fine: a project whose ladders cannot be read must not look verified.
|
|
446
|
+
fail "no llm-settings.json at '$_mlm_settings' — cannot tell whether assigned models can escalate"
|
|
447
|
+
elif [[ "$_prd_pending_ingest" == "1" ]]; then
|
|
448
|
+
# THE SAME DEFERRAL THE PRD CHECKS ABOVE ALREADY MAKE. Jira ingest rewrites this file before
|
|
449
|
+
# anything reads it, so whatever model the LAST run assigned is not this run's assignment.
|
|
450
|
+
# Live 2026-09-11: the previous run was openrouter and left `AMSD-1919 -> MiniMax-M3`; the next
|
|
451
|
+
# launch was the claude set, on whose ladders that model does not exist, and pre-flight refused
|
|
452
|
+
# the run over a value it was about to discard. Provider sets are hot-swappable on one install;
|
|
453
|
+
# a pre-flight that fails the swap on the last run's leftovers breaks that.
|
|
454
|
+
ok "model assignments belong to the last run; Jira ingest rewrites the PRD before anything reads it — deferred"
|
|
455
|
+
PASS=$((PASS+1))
|
|
456
|
+
else
|
|
457
|
+
# shellcheck source=lib/model-ladder-membership.sh
|
|
458
|
+
. "$_mlm_lib"
|
|
459
|
+
if stories_with_unladdered_models "$PRD_FILE" "$_mlm_settings"; then
|
|
460
|
+
ok "every assigned model is a rung of a declared ladder"
|
|
461
|
+
elif resume_preserves prd-state; then
|
|
462
|
+
# GREENFIELD RESUME WITH A PROVIDER-SET SWAP. The PRD carries model assignments from the
|
|
463
|
+
# prior set; _mc_enforce_ladder (story-watchdog.sh) corrects every unladdered model to the
|
|
464
|
+
# current set's start rung at the top of run-agent-orchestration.sh, before any coordinator
|
|
465
|
+
# call or story run. Refusing here blocks a legitimate swap — the same class of problem the
|
|
466
|
+
# Jira ingest deferral above already solves for brownfield projects.
|
|
467
|
+
# Live 2026-09-17, regintel 20260916T200108Z: resumed with EPAM_PROVIDER_SET=claude after
|
|
468
|
+
# an openrouter run; stories carried MiniMax-M2.7-highspeed, which is on no claude ladder.
|
|
469
|
+
ok "resume — stale model assignments from prior provider set; _mc_enforce_ladder corrects them at orchestration start"
|
|
470
|
+
PASS=$((PASS+1))
|
|
471
|
+
else
|
|
472
|
+
fail "a story is assigned a model that is on no declared ladder (see above) — it could never escalate"
|
|
473
|
+
fi
|
|
474
|
+
fi
|
|
475
|
+
|
|
476
|
+
# ── Machine-environment checks: on by default ────────────────────────────────
|
|
477
|
+
# Sections 5 and 6 and the service probes in 7 assess THIS MACHINE — is the dashboard up,
|
|
478
|
+
# is the snapshot watcher alive, is Langfuse answering. A real launch must pass them.
|
|
479
|
+
#
|
|
480
|
+
# EPAM_PREFLIGHT_ENVIRONMENT=0 assesses the PROJECT only. It exists for callers that invoke
|
|
481
|
+
# a launcher without launching on the machine's behalf — mock-launcher-parity.test.ts drives
|
|
482
|
+
# the real launcher into a forced-failure phase to prove a failed run still archives its
|
|
483
|
+
# evidence, and would otherwise pass or fail with whether a watcher happened to be running.
|
|
484
|
+
# It cannot hide a project defect: every project-readiness check still runs and still blocks.
|
|
485
|
+
_assess_environment="${EPAM_PREFLIGHT_ENVIRONMENT:-1}"
|
|
486
|
+
if [ "$_assess_environment" = "0" ]; then
|
|
487
|
+
echo "[ Machine environment ]"
|
|
488
|
+
echo " – SKIPPED by EPAM_PREFLIGHT_ENVIRONMENT=0 — project checks below still apply"
|
|
489
|
+
echo ""
|
|
490
|
+
fi
|
|
491
|
+
|
|
492
|
+
# ── 5. Dashboard is up ───────────────────────────────────────────────────────
|
|
493
|
+
if [ "$_assess_environment" != "0" ]; then
|
|
494
|
+
echo "[ Dashboard ]"
|
|
495
|
+
# The dashboards are how a run is watched, so pre-flight BRINGS THEM UP rather than reporting that
|
|
496
|
+
# they are down. See lib/dashboard-ensure.sh — it restarts the container and re-checks before it
|
|
497
|
+
# fails. The old probe curled prd.json, a file the dashboards no longer read.
|
|
498
|
+
# shellcheck source=lib/dashboard-ensure.sh
|
|
499
|
+
. "$SCRIPT_DIR/lib/dashboard-ensure.sh"
|
|
500
|
+
if ensure_dashboards_up "$_DASH"; then
|
|
501
|
+
ok "Dashboard is up at ${_DASH}"
|
|
502
|
+
else
|
|
503
|
+
fail "Dashboard not serving at ${_DASH} — a run started now cannot be watched"
|
|
504
|
+
fi
|
|
505
|
+
|
|
506
|
+
# ── 6. Self-heal observability routing ───────────────────────────────────────
|
|
507
|
+
# Verifies the full chain: claude.sh writes healing-events.jsonl to LOG_DIR
|
|
508
|
+
# (orchestrations/logs), docker mounts orchestrations/logs at /logs-dir, and
|
|
509
|
+
# nginx serves /logs/healing-events.jsonl so agent-activity.html and health.html
|
|
510
|
+
# both read real data — not stale events from a previous run.
|
|
511
|
+
echo "[ Self-heal observability ]"
|
|
512
|
+
|
|
513
|
+
# 6a. LOG_DIR write path is writable
|
|
514
|
+
# Honour an inherited LOG_DIR. This resolved ../logs unconditionally, so a run told to keep
|
|
515
|
+
# its artefacts elsewhere — a test run inside its own perimeter — still had the SHARED client
|
|
516
|
+
# log directory probed and reported here, and the operator was told artefacts would land in a
|
|
517
|
+
# directory the run never writes to.
|
|
518
|
+
LOG_DIR_DEFAULT="${LOG_DIR:-$(cd "$(dirname "$0")/../logs" 2>/dev/null && pwd || echo '')}"
|
|
519
|
+
if [[ -d "$LOG_DIR_DEFAULT" ]] && touch "$LOG_DIR_DEFAULT/.preflight-write-test" 2>/dev/null; then
|
|
520
|
+
rm -f "$LOG_DIR_DEFAULT/.preflight-write-test"
|
|
521
|
+
ok "LOG_DIR ($LOG_DIR_DEFAULT) is writable — healing-events.jsonl will land here"
|
|
522
|
+
else
|
|
523
|
+
fail "LOG_DIR ($LOG_DIR_DEFAULT) is not writable — self-heal events will be lost"
|
|
524
|
+
fi
|
|
525
|
+
|
|
526
|
+
# 6b. healing-events.jsonl at LOG_DIR is NOT stale from a prior run
|
|
527
|
+
# (pre-run-reset.sh should have cleared it; non-empty means it was not reset)
|
|
528
|
+
HEAL_LOG="$LOG_DIR_DEFAULT/healing-events.jsonl"
|
|
529
|
+
# A RESUME CARRIES ITS OWN LEDGERS. pre-run-reset keeps them on a resume — they are this run's
|
|
530
|
+
# record, and clearing them re-ran a finished phase (regintel 20260918T132928Z). Non-empty here
|
|
531
|
+
# is the resumed run's own history, not a prior run's leftovers.
|
|
532
|
+
if resume_preserves ledgers; then
|
|
533
|
+
ok "resuming run ${EPAM_RESUME_RUN}: healing-events.jsonl is the run's own record — kept, not stale"
|
|
534
|
+
elif [[ -s "$HEAL_LOG" ]]; then
|
|
535
|
+
fail "healing-events.jsonl is non-empty from a prior run — run pre-run-reset.sh to clear it (stale data pollutes health.html)"
|
|
536
|
+
else
|
|
537
|
+
ok "healing-events.jsonl is empty/absent — clean slate for this run"
|
|
538
|
+
fi
|
|
539
|
+
|
|
540
|
+
# 6c. Dashboard /logs/healing-events.jsonl is served by nginx (routing sanity)
|
|
541
|
+
#
|
|
542
|
+
# WAITED FOR, NOT SAMPLED ONCE. pre-run-reset.sh restarts agent-monitor with --force-recreate one
|
|
543
|
+
# step before this runs, so the container being probed was deliberately destroyed and rebuilt
|
|
544
|
+
# seconds earlier and is not yet accepting connections. A single curl therefore read a healthy
|
|
545
|
+
# stack as a broken mount and refused the launch -- live 2026-09-08, pipeline-tests-40, where the
|
|
546
|
+
# same URL answered 200 moments later with the file present and empty exactly as intended.
|
|
547
|
+
#
|
|
548
|
+
# That message has now been misread twice, once as a container-runtime fault and once as a missing
|
|
549
|
+
# file. It was neither: the probe raced a restart the pipeline itself had just forced. Bounded, so
|
|
550
|
+
# a mount that genuinely never comes up still refuses the launch rather than hanging.
|
|
551
|
+
_logs_ok=0
|
|
552
|
+
for _try in $(seq 1 15); do
|
|
553
|
+
if curl -sf ${_DASH}/logs/healing-events.jsonl >/dev/null 2>&1; then _logs_ok=1; break; fi
|
|
554
|
+
sleep 1
|
|
555
|
+
done
|
|
556
|
+
if [ "$_logs_ok" = "1" ]; then
|
|
557
|
+
ok "nginx serves /logs/healing-events.jsonl — agent-activity.html and health.html will read it"
|
|
558
|
+
else
|
|
559
|
+
fail "nginx /logs/healing-events.jsonl not reachable after 15s — docker /logs-dir mount may be wrong; run pre-run-reset.sh"
|
|
560
|
+
fi
|
|
561
|
+
|
|
562
|
+
# 6d. build-info.json is fresh and contains selfHealing (health.html depends on it)
|
|
563
|
+
if curl -sf "${_DASH}/build-info.json" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'selfHealing' in d.get('metrics',{}), 'selfHealing missing'" 2>/dev/null; then
|
|
564
|
+
ok "build-info.json has metrics.selfHealing — health.html will render correctly"
|
|
565
|
+
else
|
|
566
|
+
fail "build-info.json missing or lacks metrics.selfHealing — health.html will show nothing; check snapshot-watch.js process"
|
|
567
|
+
fi
|
|
568
|
+
|
|
569
|
+
# 6e. Snapshot watcher (snapshot-watch.js) is running — health.html OOMs when
|
|
570
|
+
# Eleventy's full watcher runs; snapshot-watch.js is the lightweight replacement.
|
|
571
|
+
# If the PID file is missing or the process is dead, build-info.json will never
|
|
572
|
+
# update during the run, leaving health.html permanently stale.
|
|
573
|
+
# The watcher is a MACHINE-level daemon feeding the dashboards, not a per-run artefact, so its
|
|
574
|
+
# PID file lives with those dashboards rather than in whichever log directory this run happens
|
|
575
|
+
# to own. Reading it from LOG_DIR meant a run keeping its artefacts elsewhere looked for the
|
|
576
|
+
# file in its own empty directory and concluded the watcher was dead.
|
|
577
|
+
SNAP_PID_FILE="$(cd "$(dirname "$0")/../logs" 2>/dev/null && pwd || echo '')/dashboards-watch.pid"
|
|
578
|
+
_snap_ok=false
|
|
579
|
+
if [[ -f "$SNAP_PID_FILE" ]]; then
|
|
580
|
+
_snap_pid="$(cat "$SNAP_PID_FILE" 2>/dev/null || echo '')"
|
|
581
|
+
if [[ -n "$_snap_pid" ]] && ps -p "$_snap_pid" > /dev/null 2>&1; then
|
|
582
|
+
_snap_ok=true
|
|
583
|
+
fi
|
|
584
|
+
fi
|
|
585
|
+
# A RUNNING WATCHER WITH NO PID FILE IS STILL A RUNNING WATCHER.
|
|
586
|
+
#
|
|
587
|
+
# This fallback used to sit INSIDE the `-f "$SNAP_PID_FILE"` branch above, so it was reachable
|
|
588
|
+
# only when a PID file already existed. Started by hand — which is exactly what the check's own
|
|
589
|
+
# error message tells you to do — the watcher leaves no PID file, and the check then reported it
|
|
590
|
+
# dead while it was demonstrably polling every ten seconds. The next check (6f) proves it is
|
|
591
|
+
# actually refreshing, so this one only has to establish that it exists.
|
|
592
|
+
if [[ "$_snap_ok" != "true" ]] && pgrep -f 'snapshot-watch.js' > /dev/null 2>&1; then
|
|
593
|
+
_snap_ok=true
|
|
594
|
+
fi
|
|
595
|
+
if [[ "$_snap_ok" == "true" ]]; then
|
|
596
|
+
ok "snapshot-watch.js is running — build-info.json will refresh every 10s"
|
|
597
|
+
else
|
|
598
|
+
fail "snapshot-watch.js is NOT running — health.html will stay permanently stale during the run; start it with: node orchestrations/scripts/snapshot-watch.js 10 &"
|
|
599
|
+
fi
|
|
600
|
+
|
|
601
|
+
# 6f. build-info.json was generated recently (within 120s) — proves the watcher
|
|
602
|
+
# is actively refreshing, not just present-but-stalled.
|
|
603
|
+
_generated_at=$(curl -sf "${_DASH}/build-info.json" 2>/dev/null \
|
|
604
|
+
| python3 "$SCRIPT_DIR/lib/handlers/json-field.py" generatedAt 2>/dev/null || echo '')
|
|
605
|
+
if [[ -n "$_generated_at" ]]; then
|
|
606
|
+
_age_s=$(python3 "$SCRIPT_DIR/lib/handlers/iso-timestamp-age-seconds.py" "$_generated_at" 2>/dev/null || echo 9999)
|
|
607
|
+
if [[ "$_age_s" -lt 120 ]]; then
|
|
608
|
+
ok "build-info.json is ${_age_s}s old — snapshot watcher is actively refreshing"
|
|
609
|
+
else
|
|
610
|
+
fail "build-info.json is ${_age_s}s old (> 120s) — snapshot watcher may be stalled; kill and restart snapshot-watch.js"
|
|
611
|
+
fi
|
|
612
|
+
else
|
|
613
|
+
fail "build-info.json missing generatedAt field — snapshot watcher may be writing corrupted output"
|
|
614
|
+
fi
|
|
615
|
+
|
|
616
|
+
fi
|
|
617
|
+
|
|
618
|
+
# ── 7. PROJECT READINESS ─────────────────────────────────────────────────────
|
|
619
|
+
# Added 2026-08-05. Four launches that day died on conditions nothing checked before
|
|
620
|
+
# spending: a stale dist (twice, caught by a phase gate AFTER the run started), a dead
|
|
621
|
+
# observability stack, and a project with no synthesis template that silently ran under
|
|
622
|
+
# ANOTHER project's identity. A pre-flight that does not assess the PROJECT is not
|
|
623
|
+
# assessing what actually breaks.
|
|
624
|
+
echo "[ Project readiness ]"
|
|
625
|
+
|
|
626
|
+
# dist/ must be newer than src/ — the pipeline executes dist/epam.js, so a stale bundle
|
|
627
|
+
# means the code under test is not the code that runs.
|
|
628
|
+
_newest_src=$(find "$REPO_ROOT/src" -name '*.ts' -newer "$REPO_ROOT/dist/epam.js" -print -quit 2>/dev/null)
|
|
629
|
+
if [ ! -f "$REPO_ROOT/dist/epam.js" ]; then
|
|
630
|
+
fail "dist/epam.js missing — the pipeline runs dist, not src. Build with: node ./node_modules/.bin/tsup"
|
|
631
|
+
elif [ -n "$_newest_src" ]; then
|
|
632
|
+
fail "dist/ is STALE (e.g. $(basename "$_newest_src")) — source changes would NOT execute. Rebuild with tsup."
|
|
633
|
+
else
|
|
634
|
+
ok "dist/ is newer than src/"
|
|
635
|
+
fi
|
|
636
|
+
|
|
637
|
+
# AN ESTIMATION MANIFEST THE RUN WAS TOLD TO USE MUST BE THERE (2026-09-28). Unset is nothing to
|
|
638
|
+
# check — the run proceeds exactly as it did before the variable existed. Set, it is read right
|
|
639
|
+
# after ingest and every ticket's assessment reaches six agents; a path that is missing or
|
|
640
|
+
# unreadable would stop the run there, after the spend on ingest, so it is refused here instead.
|
|
641
|
+
_estimation_manifest="$(_project_env_value EPAM_ESTIMATION_MANIFEST)"
|
|
642
|
+
if [[ -n "$_estimation_manifest" ]]; then
|
|
643
|
+
if [[ -r "$_estimation_manifest" ]]; then
|
|
644
|
+
ok "estimation manifest readable: $_estimation_manifest"
|
|
645
|
+
else
|
|
646
|
+
fail "EPAM_ESTIMATION_MANIFEST=$_estimation_manifest is missing or unreadable — fix the path or unset it"
|
|
647
|
+
fi
|
|
648
|
+
fi
|
|
649
|
+
|
|
650
|
+
# The project must own its synthesis template. Without one, synthesize-prd-from-jira.js
|
|
651
|
+
# falls back to another project's canonical and inherits ITS project block — observed
|
|
652
|
+
# 2026-08-05: hello-dolly runs were labelled project.name: skyscanner-app.
|
|
653
|
+
# THE CHECK IS GONE BECAUSE THE TEMPLATE IS. It required every project to ship its own stored PRD
|
|
654
|
+
# template, because synthesis filled one and would otherwise borrow another project's identity.
|
|
655
|
+
# Synthesis now builds the PRD from the tracker and the project's own config, so there is no
|
|
656
|
+
# template to own and no identity to borrow.
|
|
657
|
+
if [ -n "${PROJECT_CONFIG_DIR:-}" ]; then
|
|
658
|
+
if [ -n "${PROJECT_NAME:-}" ]; then
|
|
659
|
+
ok "project declares its own name (${PROJECT_NAME}) — synthesis has an identity of its own"
|
|
660
|
+
else
|
|
661
|
+
fail "PROJECT_NAME is not set — the synthesised PRD would carry no project identity at all"
|
|
662
|
+
fi
|
|
663
|
+
else
|
|
664
|
+
echo " – project config dir not given (--project-config); skipping identity check"
|
|
665
|
+
fi
|
|
666
|
+
|
|
667
|
+
# Observability: a run aborts at the tier launcher's own preflight when these are down, so
|
|
668
|
+
# finding out here costs nothing and saves a launch cycle.
|
|
669
|
+
if [ "${EPAM_PREFLIGHT_SKIP_NETWORK:-0}" != "1" ] && [ "$_assess_environment" != "0" ]; then
|
|
670
|
+
for _svc in langfuse grafana; do
|
|
671
|
+
_url=""
|
|
672
|
+
if [ -f "$SCRIPT_DIR/lib/service-urls.sh" ]; then
|
|
673
|
+
# shellcheck source=lib/service-urls.sh
|
|
674
|
+
. "$SCRIPT_DIR/lib/service-urls.sh" 2>/dev/null || true
|
|
675
|
+
_url="$(service_url "$_svc" 2>/dev/null || true)"
|
|
676
|
+
fi
|
|
677
|
+
[ -n "$_url" ] || continue
|
|
678
|
+
_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$_url" 2>/dev/null || echo "000")
|
|
679
|
+
if [ "$_code" = "000" ]; then
|
|
680
|
+
fail "${_svc} NOT serving at ${_url} — the run will abort at the observability preflight"
|
|
681
|
+
else
|
|
682
|
+
ok "${_svc} serving (HTTP ${_code})"
|
|
683
|
+
fi
|
|
684
|
+
done
|
|
685
|
+
fi
|
|
686
|
+
echo ""
|
|
687
|
+
|
|
688
|
+
# ── Summary ──────────────────────────────────────────────────────────────────
|
|
689
|
+
echo ""
|
|
690
|
+
if [[ $FAIL -eq 0 ]]; then
|
|
691
|
+
echo "━━━ ✓ All $PASS checks passed — safe to run ━━━"
|
|
692
|
+
exit 0
|
|
693
|
+
else
|
|
694
|
+
echo "━━━ ✗ $FAIL check(s) FAILED — DO NOT run pipeline ━━━" >&2
|
|
695
|
+
exit 1
|
|
696
|
+
fi
|