BSL pipeline orphaned-worker incident (2026-08-06)
Symptom
Users on bsl.hashpass.tech saw the in-app "update available" modal correctly
report a newer version, clicked accept, watched the app clear caches and hard
reload — and the app still reported the old version afterward. Repeated
reloads made no difference.
This looked like a client-side caching bug (the obvious suspects: service
worker, CloudFront, the performHardReload() cache-busting query param) but
wasn't one. It was a stuck deployment pipeline: the live static site bundle
was genuinely still v1.8.329, three releases (v1.8.330, v1.8.331, v1.8.332)
behind.
How to tell the difference next time
Before assuming a stale-reload bug is client-side, check what's actually being served:
# 1. Confirm the reload is genuinely hitting origin, not an edge cache
curl -sI "https://bsl.hashpass.tech/?_hpv=$(date +%s)" | grep -iE "x-cache|cache-control"
# Expect: x-cache: Miss from cloudfront, cache-control: no-cache,no-store,must-revalidate
# (index.html is deployed with these headers already — if you see a Hit or a
# long max-age here, the bug really is CDN caching, not this incident.)
# 2. Extract the JS bundle path index.html actually points at, and grep it
# for the version string baked in at build time
curl -s "https://bsl.hashpass.tech/?_hpv=$(date +%s)" | grep -oE '"/_expo/static/js/web/index-[a-f0-9]+\.js"'
curl -s "https://bsl.hashpass.tech/_expo/static/js/web/index-<hash>.js" | grep -oE '"1\.8\.[0-9]+"' | sort -u
# 3. Compare against what the API layer (a *separate* deployment, the Lambda)
# thinks is current -- this can legitimately be ahead of the static site,
# since they deploy independently
curl -s "https://api.hashpass.tech/api/config/versions" | grep currentVersion
If the bundle's baked-in version is behind what the release tags say should be live, the static site deploy pipeline itself hasn't shipped. Go to the pipeline, not the browser.
Root cause
bsl-hashpass-prod's CodePipeline had two executions stuck InProgress for
3+ hours (one from the v1.8.330 release, one from v1.8.331, queued behind
it — aws_pipeline_ec2_worker's single EC2 worker processes one job at a
time, see the "EC2 pipeline worker: operational gotchas" section referenced
from .agents/done/task-aws-account-migration.md). The last execution that
actually succeeded was v1.8.329's — matching exactly what was still live.
Checking the worker found zero running EC2 instances for this pipeline at all. The worker process/instance had died or stopped without ever calling back to CodePipeline with a success or failure result, leaving the execution permanently orphaned. Confirmed via CPU/instance-state checks, not guessed.
Why the existing self-timeout guard didn't help
aws_pipeline_ec2_worker's build_timeout_seconds variable (default 2700s /
45min) makes worker-loop.sh kill a hung build process and report failure —
this was added specifically after a July 2026 incident where a cancelled
CodePipeline execution's build process kept running forever inside a live
worker. That guard assumes the worker process is alive to run it. It does
nothing if the instance itself dies, crashes, or is stopped — there's
nothing left inside the instance to run the timeout logic or call
put-job-failure-result. That's exactly what happened here.
Fix
- Immediate: manually stopped/abandoned the orphaned pipeline execution
(
aws codepipeline stop-pipeline-execution ... --abandon) to unblock the queue, then let the next execution run. - Structural: added
timeout_in_minutes = 60to both BSL pipeline actions (bsl-hashpass-prodandbsl-hashpass-dev'sDeployInfraaction,packages/infra/terraform/stacks/bsl-target/main.tf). This is a CodePipeline-enforced timeout — the service itself marks the action failed after 60 minutes regardless of whether any worker is alive to report back. It works precisely in the failure mode the worker's own internal guard can't cover, and requirespipeline_type = "V2"(both BSL pipelines already are).
hashpass-web's pipelines (hashpass-dev-site / hashpass-production-site,
aws_static_site_pipeline module) have the identical worker architecture and
identical exposure to this failure mode, but are still pipeline_type = "V1"
(the enable_path_filtered_trigger variable that upgrades them to V2 is
off by default, with an explicit "opt in deliberately once ready" note in
its description — it wasn't flipped as a side effect of this fix). This is
a known follow-up: upgrading those two pipelines to V2 and adding the same
timeout_in_minutes is the same fix, just not yet applied there.
If this happens again
# 1. Confirm no worker is actually running (vs. a slow-but-real build)
aws ec2 describe-instances --region us-east-2 --profile hashpass \
--filters "Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].{Id:InstanceId,Name:Tags[?Key==`Name`]|[0].Value}'
# 2. List recent executions, find the stuck one(s)
aws codepipeline list-pipeline-executions --pipeline-name bsl-hashpass-prod \
--region us-east-2 --profile hashpass --max-items 5
# 3. Abandon it (use the FULL execution id from step 2, not the truncated
# one gh/aws sometimes prints)
aws codepipeline stop-pipeline-execution --pipeline-name bsl-hashpass-prod \
--pipeline-execution-id <full-id> --abandon \
--reason "orphaned, no worker running" --region us-east-2 --profile hashpass
With the timeout_in_minutes = 60 guard now in place, this specific
"stuck forever" shape shouldn't recur — a dead worker now just means the
action fails cleanly after an hour instead of hanging indefinitely. It's
still worth periodically spot-checking a live domain's deployed bundle
version against its release tag (the three-command check at the top of this
doc) since a failed deploy still needs a human to notice and re-trigger it.