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.
@@ -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