CI/CD factory-floor map

How a change actually travels from agent to production. A map, not a redesign.

Pinned to propflowai origin/main @ 864d9a57570. Live GitHub and AWS reads were made read-only between 21:50 and 22:10 UTC on 2026-10-02. Every claim is tagged V (verified: file:line or command output), I (inferred) or U (unknown).

Read this first: four things the brief assumed that the system contradicts.

1 Topology2–3 PR lifecycle & merge queue4 & 11 Execution & economics5 Tests6–7 Dependency graph & hotspots8–9 Deploy & staleness10 Failures12 The constraint13 Unknowns14 System map

12 · The constraint (Theory of Constraints read)

This section is synthesis. It combines the verified numbers in §2–§11, and every inference is marked as one. It does not recommend anything.

The constraint

Upstream inventory: where work piles up

Downstream starvation

Scrap and rework

Variability: what makes throughput collapse

Utilization

Yes: agent utilization is high while system throughput is set elsewhere (I). Agents produce PRs faster than the chain can absorb them at the current per-entry failure rate. Each extra queued PR deepens the chain, and a deeper chain raises the odds that something ahead fails and forces a rebuild. Beyond some depth, more PRs in flight means more scrap, not more merges. Where that depth sits is not measured (see §13).

Which category: A–H

CandidateRole todayEvidence
A. Compute capacitySecondary: a source of variability, not the binding limit. EC2 quotas (4,096 spot vCPU in us-east-1) cap it, but they bind only in bursts.§4.1, §4.2 latency burst, §10 VcpuLimitExceeded
B. CI durationAn amplifier. A 17-min median queue run times a 24-deep chain sets the price of each failure.§3, §11
C. Merge serializationHalf of the constraint. The single ALLGREEN chain means one red entry invalidates everything behind it.§3 ruleset, §11.1
D. Branch stalenessReal but indirect. PRs are tested against main as of their last push, about 28 commits behind by merge time, yet text conflicts are only 9% of exits. Staleness matters mostly because the PR run is not the integrated run.§9.3, §9.5, §10.4
E. Integration failure probabilityThe other half of the constraint. It is driven mainly by the PR lane not running what the queue runs, and by shared-count and list ratchets.§5.6, §10.2, §10.5
F. Dependency couplingThe root of E's shape. One TypeScript program, about 2,000 cross-tree alias imports, and 221 count/list ratchet drift tests that turn independent changes into dependent ones.§1, §6, §7
G. Deployment architectureNot the merge constraint. It adds about 53 min median of main-to-live latency and couples all units into one gated release.§8
H. CombinationThis is the answer: C × E, with F producing E and B pricing it. A and G sit downstream or off to the side.
13 · What we still don't know
QuestionWhy it mattersHow to verify
What enqueues PRs now that auto-merge-all.yml is disabled (since 10-01 20:48Z)? Every queued PR is enqueued as gera-propflow or fede-propflow.The arming policy decides queue depth and ordering. CLAUDE.md mentions a "local merge-loop" (I), and this map found none in the propflowai repo.Check the mini's LaunchAgents and the agentflow repo for the merge loop; read the actor on recent added_to_merge_queue timeline events.
Who did the bulk dequeue of 94 PRs on 10-02 between 19:00 and 21:00?Manual removals are 168 of 310 non-merge exits, a large source of churn.GraphQL RemovedFromMergeQueueEvent.actor; GitHub audit log.
Ruleset edit history. It was edited at 13:44 CDT on 10-02.The queue parameters (24/16/ALLGREEN/30 min) may be hours old; any before/after comparison depends on them.GitHub org audit log (needs admin); ruleset history API.
Does GitHub cancel CI runs for groups it invalidates, or let them finish?It decides how much of the 86k exec-min/day of scrap is avoidable.Correlate merge_group run cancel actor or queue-fail-fast log lines against queue rebuild timestamps.
Of the ~1,290 queue failures hidden by fail-fast on 10-02, what is the full class breakdown?§10 classified only a skewed 171-run sample.Batch-pull failed-job annotations for every cancelled merge_group run.
For each culprit PR, did the failing test run in its PR checks?It separates "PR lane coverage gap" from "true overlap between two PRs", which is the core of the graph-vs-train question.Join each red test file to the culprit PR's affected-selection list (in the CI log) and the guard-pin selection.
True per-entry failure rate in the queue, independent of position.The chain arithmetic in §12 rests on it.Group merge_group runs by PR and find each PR's first run where it is the head entry.
Real dollar cost per queue run (RunsOn instance-seconds × price).The brief's "~$20 per failure" is unverified.AWS Cost Explorer filtered by RunsOn stack tags; RunsOn cost reports.
Why the flex pool, defined on-demand, also runs 1,911 spot t4g.medium.Capacity behaviour under bursts..github-private/.github/runs-on.yml and RunsOn logs (AWS console).
Spot circuit-breaker drift: live CFN 100/15/5 vs Terraform 20/15/10.It changes how runners behave when spot fails.RunsOn stack parameters in the AWS console.
Whether reused pool instances keep disk state between jobs.Cache correctness and isolation.RunsOn pool documentation; inspect a pooled instance.
GitHub cache is 10.68 GB against a 10 GB cap. What is the hit rate?Evictions turn every run into a cold install.Cache-restore step outputs (cache-hit) across a day of runs.
Whether a skipped Nested Gate on a PR can mask a red one of the same name.The required-check integrity of the queue.GitHub support or a controlled test PR.
Vercel dashboard Ignored Build Step, and the project's git settings.It decides whether anything but the API promote can deploy main.Vercel project settings (needs the Vercel connector re-authorised or dashboard access).
How infra/runs-on-flex, infra/temporal-worker and lambda/vercel-log-drain get deployed.Units with no deploy workflow sit outside the map.Ask the owners; check Terraform state and the AWS console.
Shared env/secrets across Vercel, Lambdas and ECS.A runtime coupling the code can't show.Vercel env, Lambda configuration, ECS task-def secrets (AWS console).
Real eval run times, and how often test-full runs.Completes the test-cost picture.Job durations from gh api …/jobs over a week.
The number of commits that land after a PR's last PR-level CI event.This, not base age, is the true staleness at queue entry.Each PR's last pull_request run timestamp against the main log.
14 · The whole system, as it actually runs
  AGENT SWARM  (~200–470 PRs/day landed; worktree off fresh origin/main, enforced by hook)
      │  pre-commit: regenerate + stage route manifest   pre-push: symbol/docs-index/... checks, push-pacer
      ▼
  PR opened ─────────────────────────────────────────────────────────────────────────────
      │  pull_request → ci.yml on refs/pull/N/merge  (= PR + main AS OF THIS PUSH, never refreshed)
      │    Type Check (tsgo, whole repo) · Lint (whole repo) · Unit Tests (AFFECTED, cap 200)
      │    Guard pins (SUBSET, 3 shards) · always-run drift/decide-* · Leasing gate (path-gated)
      │    Build: NOT RUN (left to Vercel) · evals: path-gated, PR head only
      │  claude-code-review (LLM, PR commits only; clean verdict does NOT survive a new SHA)
      │  branch protection strict=false → being behind main is allowed
      ▼
  ENQUEUE  (auto-merge-all.yml DISABLED since 10-01; enqueued as gera-/fede-propflow — actor U)
      ▼
  GITHUB MERGE QUEUE   ruleset: ALLGREEN · build ≤24 · merge ≤16 · wait 2 min · timeout 30 min · SQUASH
      │
      │   base M ─▶ [M+A] ─▶ [M+A+B] ─▶ [M+A+B+C] ─▶ ... up to 24 deep, all running at once
      │               │          │            │
      │   each group runs ci.yml on merge_group (17 min median):
      │     Checks (lint + scripts typecheck + npm run build) · Unit Tests (affected vs base_sha)
      │     Guard pins (FULL, 6 shards) + Full set (64-core: full guards + full suite) ← suite runs 2×
      │     review = PASS-BY-DESIGN · Leasing gate = PASS-BY-DESIGN · Queue fail-fast watcher
      │
      │   B red ─▶ fail-fast CANCELS B's run (reads "cancelled", not "failure")
      │          ─▶ every group built on B is red/void ─▶ GitHub ejects B, REBUILDS C, D, ... on M+A
      │   ~4.8 queue runs per merged PR · 68% of runs end cancelled · text conflict = 9% of exits
      ▼
  MAIN  (squash; ~19 commits/hr; up to 16 PRs per push)
      │  push → ci.yml: skips tests whose tree the queue already validated; checks only
      │  post-merge-heavy (cron :07/:37): full suite on tip → culprit-find, auto-revert, hold deploys
      │  sync-specialists / sync-phone / turnover-intake / DLQ alarms ── fire on raw push:main,
      │      NOT gated on CI ─▶ ElevenLabs + CloudWatch live within minutes
      ▼
  ONE GATED RELEASE  (needs push CI = success)
      ├─▶ vercel-promote: waits a 30-min batch window, promotes the newest green tip ─▶ propflowai.co
      │       (one runs, one waits, a newer arrival replaces the waiter → ~80% of push runs "cancelled")
      ├─▶ deploy-lambdas: esbuild import-graph gate per lane (fail-open) ─▶ 13–15 of 15 lanes usually
      │       ─▶ zip ─▶ aws lambda update + alias live          (6–8 min after CI)
      └─▶ deploy-renewal-worker: source hash over src/agents/... ─▶ arm64 image ─▶ ECR ─▶ ECS Fargate
              (almost every merge rebuilds it; 13–34 min)
      ▼
  PRODUCTION   web lags main by median 53 min (p90 90) · one DynamoDB table shared by every unit

  RUNNERS under all of it: RunsOn on EC2 (3 stacks: us-east-1 2-AZ "production", us-west-2, us-east-1 6-AZ flex),
  one instance per job, spot + on-demand, GitHub Actions cache (10.68 GB of 10 GB cap), no Docker/K8s
  except the worker image build.

The section reports were each gathered by an independent read-only pass. Where two passes measured the same thing over different windows, the numbers differ slightly. Examples: merge_group cancellations are 1,539 in the trailing 24 h vs 1,377 on the calendar day 10-02; required checks are 6 from classic branch protection plus Nested Gate from the ruleset.

1 · Repository topology · 6 · Dependency graph · 7 · Hotspots

PropFlow CI/CD forensic map: §1 Topology, §6 Dependency graph, §7 Hotspots

Pinned to origin/main @ 864d9a57570. Tags: V = VERIFIED (cited), I = INFERRED, U = UNKNOWN.

§1 Repository topology

Shape: a single monorepo, a single TypeScript program, a single root lockfile, and no monorepo build tool. (V) There are 22,015 tracked files (git ls-tree -r). No turbo.json, nx.json, Bazel, cdk.json, SAM template.yml or serverless.yml appears in the tree. None of those tools appears in root dependencies either; the positive control is that next 16.3.0, esbuild, vitest and @typescript/native-preview (tsgo) all do appear. Package manager: npm ≥11, node ≥24 (package.json engines). Root package.json has 482 npm scripts, 76 dependencies and 27 devDependencies.

src/ (12,748)        Next.js app on Vercel: app/ (528 route.ts, 198 page.tsx), lib/, components/, __tests__/
  lib/data/types.ts  22,852 lines, the canonical entity schema (no codegen; hand-written + JSDoc @canonical/@derived)
agents/clara/ (655)  Clara agent runtime; lib/ mirrors src/lib (parallel data/types.ts, 3,561 lines)
lambda/ (200)        17 function dirs, each with build.ts (esbuild) + deploy.sh; lib/esbuild-clara-fallback.ts
services/renewal-worker/  the ONLY Dockerfile; Temporal worker on ECS Fargate
infra/               runs-on-flex (Terraform: CI runners) · temporal-worker (shell + IAM/task-def JSON, no IaC framework)
packages/ui (36)     the one workspace
scripts/ (4,153)     ops tools, DDB migrations/GSI backfills, CI helpers, harnesses (separate tsconfig.scripts.json)
evals/ (1,732) · e2e/ (59) · tests/ (11) · evidence/ (262) · quality-gate-poc/ (210)
config/ (13)         phone-registry.json, elevenlabs-agents.json, automations.toml …
contracts/           1 file (change-term/scenarios.json), NOT an API-contract layer
elevenlabs/          1 file (.gitkeep)
docs/ (1,343)        INDEX.md generated by scripts/generate-docs-index.ts; 169 entries in docs/adr/
.github/ (84)        64 workflows; CODEOWNERS
.husky/              pre-commit, pre-push, post-checkout
root clutter         .pr-body-*.md, PROGRESS-*.md, ITERATION-2-PLAN.md, chunk-complete-draft.md, e2e-rent-roll-test.mjs

Schemas and migrations (V): the schema is code, src/lib/data/types.ts plus a parallel agents/clara/lib/data/types.ts that describes itself as a "PARALLEL TREE" (header lines 1–12). There is no migrations directory. DynamoDB GSI changes are one-off npm scripts (gsi::add, gsi::backfill, gsi::revert, parity: in package.json).

Generated, committed files (V): src/lib/traffic/route-manifest.generated.ts and coverage.generated.ts (regenerated and staged by .husky/pre-commit), plus docs/INDEX.md (npm run docs:index).

Coupling (V): the boundary between agents and src exists only on paper.

Can each area be built or deployed on its own?

§6 Dependency graph: "if X changes, what rebuilds or retests?"

What tooling exists

InstrumentState
graphify graph.jsonGitignored (.gitignore:408-411). Pulled from s3://propflow-photos/graphify/ by scripts/pull-graph.ts, rebuilt nightly by Agent Smith (docs/runbooks/graphify.md:33). Not available in CI (.github/actions/nested-gate/action.yml:123). Advisory only. (V)
npm run test:affected → scripts/test-affected.shExists; covered by another agent. (V)
Lambda gate scripts/lambda-deploy-gate.tsReal esbuild import-graph check per lane. The diff starts from the head of the previous green push:main CI run. It fails open to deploy=true (deploy-lambdas.yml:193-247). The gate's LAMBDAS= list has 16 lanes; vercel-log-drain is absent, although it is one of the 17 directories. lambda/README.md lists tour-reminder, which does not exist, so the README is stale. (V)
Renewal-worker gateA content hash over HEAD:src, HEAD:agents, packages, package-lock.json, config, tsconfig.json, … (deploy-renewal-worker.yml:193-215). Any change under src/ or agents/ redeploys the worker. (V)
CODEOWNERSCovers only .github/reviewer-core.md, reviewer-lenses/, workflows/ and itself. Nothing in code is owned. (V)
DockerfilesOne: services/renewal-worker/Dockerfile. It COPYs all of src, agents, config, packages and a quality-gate-poc/src slice. (V)
tsconfig project referencesNone. (V)
ESLint import rulesno-restricted-imports cross-runtime blocks (lines 407–569) catch literal paths only. (V)
Package manifestsOnly one workspace, so no per-package dependency graph. (V)

Four kinds of dependency

Concrete examples

§7 Change and conflict hotspots (last 30 days)

Throughput (V): 6,512 commits on main, 6,467 distinct (#N) PR refs, only 20 true merge commits (squash merging), and 45 commits with no PR ref. That is about 209 PRs/day; median 175/day, max 542/day. The brief's "~100/day" is I and looks low. Authors: "Gera" 3,845, "fede-propflow" 2,619.

Top serialization points

#FileChangesWhy independent agents must edit it
1src/lib/data/types.ts419Single hand-written schema for every entity. Commits that touch it touch a median of 12 files (p90 34). (V)
2package.json203330 of the 334 added lines are npm-script registrations; 1 is a dependency. Every new script or harness registers a runner here. scripts-unreachable-baseline.json and check-scripts-runner-ratchet.ts push in the same direction (I on the exact enforcement). package-lock.json changed only 3 times. (V)
3agents/clara/lib/agent/tools-leasing.ts173Leasing tool registry and schemas. (I)
4.github/workflows/ci.yml169CI is reshaped constantly (lanes, heap, quarantine, merge queue). (V, from subjects)
5src/lib/traffic/route-manifest.generated.ts168Regenerated and auto-staged by .husky/pre-commit on any route.ts or page.tsx change. (V)
6agents/clara/lib/agent/conversation-manager.ts154The agent-loop hub. (I)
7scripts/lib/user-route-inventory.ts152326 hand-classified { route: … } rows; each scope change to a session-gated API route edits its row. (V)
8–10src/lib/data/index.ts, json-repository.ts, dynamo/index.ts (+ store.ts 106)150 / 133 / 122The repository fan-in from example B. (V)
11src/app/api/voice/personalization/route.ts139Single voice dynamic-variable webhook. (I)
12src/__tests__/entity-classification.drift.test.ts124A registry of non-entity interface names with reasons. New exported interfaces in types.ts add {name, reason} rows here, so it moves in lockstep with #1. (V, from hunks)
13src/lib/domain/portfolio/registry/keys.ts111Settings-key registry (2,058 lines). (V)
14scripts/ci/scripts-unreachable-baseline.json96A ratchet list of unreachable scripts; adding or retiring a script edits it. (V)
15src/__tests__/admin-scope-inventory.drift.test.ts77"A new route anywhere in the tree fails until classified" (header); covers all 150 admin routes. (V)

Also notable (V): scripts/operator-rename/ratchet.json 45, config/phone-registry.json 30, eslint-suppressions.json 23, customer-identifier-fixture-baseline.ts 20, agents/clara/lib/data/types.ts 72 (the parallel schema), and claude-code-review.yml 60 and auto-merge-all.yml 52 (the merge machinery is itself a hotspot).

docs/INDEX.md is enforced but cool: 53 changes (V). It is enforced by the pre-push check-docs-index.ts, by the docs-index step in ci.yml and by a full rebuild in ci-docs.yml. It is lower churn than the brief implied.

Hooks that force edits to central files (V)

Pattern (I): most hotspots are registries and inventories that a drift test keeps closed: the types classification, route inventories, scripts baseline, key registry and the generated manifest. Each turns "add one thing" into "edit one shared list". Lists merge cleanly in Git, but count lines and ratchet ceilings collide semantically, as in example D.

2 · PR lifecycle · 3 · Merge queue semantics

PropFlow CI/CD forensic map: §2 PR lifecycle, §3 merge queue

Pinned to origin/main @ 864d9a5757 (git rev-parse origin/main). Live API snapshot taken 2026-10-02 at about 21:58–22:00Z. Tags: V = verified (file:line or command output), I = inferred, I-gh = inferred from GitHub's documented semantics, U = unknown.


§3 How the GitHub merge queue is set up and behaves (most important)

3.1 Configuration (V)

gh api repos/.../rulesets returns one ruleset, 14925316 "Require CI". It is active, targets ~DEFAULT_BRANCH, has bypass_actors: [], and its updated_at is 2026-10-02T13:44:15-05:00, earlier today. The parameters below are current as of the snapshot and may have changed recently.

merge_queue: merge_method SQUASH, max_entries_to_build 24, min_entries_to_merge 1,
  max_entries_to_merge 16, min_entries_to_merge_wait_minutes 2,
  grouping_strategy ALLGREEN, check_response_timeout_minutes 30
required_status_checks: [{context:"Nested Gate", integration_id:15368}],
  strict_required_status_checks_policy:false
pull_request: required_approving_review_count 0, dismiss_stale_reviews_on_push false,
  (other review params off); also deletion, non_fast_forward

GraphQL mergeQueue.configuration returns the same values: checkResponseTimeout 1800s, mergingStrategy ALLGREEN, minimumEntriesToMergeWaitTime 120s. (V)

branches/main/protection does not return 404. Classic protection is still active alongside the ruleset (V): required_status_checks.strict:false, contexts = Build, Type Check, Unit Tests, "Regression Gate — Leasing (required)", Lint, review, all app_id 15368 (GitHub Actions). enforce_admins:false, required_conversation_resolution:false. No required human review.

The effective required set is the union of seven contexts: the six classic ones plus Nested Gate. Neither layer requires the branch to be up to date (I-gh: GitHub layers rulesets and classic protection, and both apply).

3.2 Which workflows fire on merge_group (V)

git grep merge_group -- .github/workflows: only ci.yml has a merge_group: types:[checks_requested] trigger (ci.yml:196). claude-code-review.yml:110 says its merge_group trigger was deleted. auto-merge-all.yml and ci-followups.yml use branches-ignore: ['gh-readonly-queue/**'] on their workflow_run legs.

Per ci.yml:189-195, only the "CORE lane" has a merge_group leg. The jobs that actually run are (V, from the if: conditions):

Jobmerge_group behaviour
preflight, planrun (plan diffs vs merge_group.base_sha, ci.yml:6015)
checks (type/lint/DDB/build)runs, on-demand rather than spot (ci.yml:765), with queue-only cache restores (ci.yml:1950-1960)
testaffected tests against merge_group.base_sha (merge-group-affected.sh, ci.yml:2892)
guard-manifest + guard-pins (6 shards)run (ci.yml:3134). They do not run on push:main
test-fulldoes not run on merge_group (ci.yml:3235: "NOT on merge_group either… full suite runs AFTER merge in post-merge-heavy.yml")
full-set-one-machinequeue only: full guard pins plus full unit tests on one 64-vCPU spot box, one retry per failed file. Required by the verdict (ci.yml ~6565)
queue-fail-fastqueue only. Cancels its own run when a verdict job turns red
evals, semgrep, pr-gates, docs-only-fence, regression-gate, override, pr-verdictdo not run (their if: conditions exclude merge_group)

Which SHA the queue tests: github.sha is the head of the temporary branch gh-readonly-queue/main/pr-<N>-<base_sha> (I-gh). GraphQL confirms it is a chain (V): each entry's baseCommit is the previous entry's headCommit, and entry 1's base is the main tip 864d9a57. Each head is the squash of the PR on top of the commit before it. The <sha> in the branch name is that base commit, so the branch name does not show how many PRs the group contains. Every observed run name is a single pr-<N>.

3.3 Required checks to enter the queue vs. to leave it

To enter (PR head): all seven contexts must be green on the PR head SHA (I-gh: the queue only accepts a PR whose required checks pass). Because strict:false, those results come from refs/pull/N/merge as it stood at the last push, and they can be stale relative to main.

To leave (merge-group SHA): the same seven names must report success on the merge-group commit. In ci.yml they come from three different sources (V):

Context on the queue SHAProducerReal or by-design pass?
Nested Gatemerge-group-verdict (ci.yml ~6614, name: Nested Gate, scripts/ci/merge-group-verdict.sh)Real, but it checks something different under the same name. On the queue it means "the whole core lane passed: checks, test, guard-manifest, test-aggregate, guard-pins, full-set-one-machine". On a PR, the nested-gate job runs .github/actions/nested-gate, which type-checks and builds the nested packages (mock-pms-service, testing-harness, graphify).
Type Check / Build / Lint / Unit Testsmerge-group-contexts matrix (scripts/ci/merge-group-context.sh)Real. Recomputed from this run's own needs results. The script says "Nothing here is a stub".
review, Regression Gate — Leasing (required)merge-group-by-design matrix (ci.yml ~6670)Pass by design. The step only runs echo "By design, not a verdict on the merge-group tree. A PR enters the queue only with ${{ matrix.name }} green on its own head."

This is the stub pattern you asked about, and the repo names it openly. The bot review and the leasing regression gate are never evaluated against the combined tree.

merge-group-contexts and merge-group-by-design are named by expression (… && matrix.name || '<name> (not applicable)') so that a skipped job never posts under a required name (#9119). merge-group-verdict breaks that rule: its name is fixed as name: Nested Gate. So every PR-event ci.yml run leaves a skipped Nested Gate check-run on the PR head (V: PR run 37069793874's job list shows Nested Gate skipped). Whether that shadows the real PR-side result is the aggregation question the repo itself marks unsettled (U).

3.4 Live queue activity (V, read-only GETs)

3.5 What happens when an entry fails (I-gh unless marked)

  1. A required context goes red on entry k's merge-group SHA, or nothing reports within 30 min. GitHub removes k from the queue.
  2. Every entry after k was built on top of k, so GitHub rebuilds those entries on a new chain without k and fires a new merge_group checks_requested for each one.
  3. GitHub does not cancel the old runs for groups it invalidated. In this repo they are stopped only by queue-fail-fast when they turn red themselves (V). Old runs that would have passed simply finish and are ignored. Concurrency cannot cancel them because each one has a unique ref (V).
  4. ALLGREEN: an entry merges only when it and everything ahead of it are green. Up to 16 ready entries can merge together after the 2-min wait (I-gh). Two same-timestamp commit pairs on main weakly support batching. The gap between 390 commits and 245 push:main runs is confounded by docs paths-ignore, so it is not evidence.
  5. A removed PR leaves the queue. It re-enters only if something re-enqueues it. Here that is presumably the external loop, since the enqueuer identities are human (I).

History (V, ci.yml:229-245): the queue ran 07-14 to 07-21, was retired ("NEVER batched (66 trains for 63 PRs)"), and came back 2026-10-01 10:49Z (auto-merge-all.yml:420).

3.6 State diagram

PR head (refs/pull/N/merge at push time, strict:false)
  │  7 contexts green: Build, Type Check, Lint, Unit Tests, Regression Gate,
  │                    review, Nested Gate(nested pkgs)
  ▼
[enqueue] ── by fede-/gera-propflow credential (loop location U)
  ▼
QUEUED ──► build group k = squash(PR) on head(k-1)   [≤24 groups built at once]
  ▼  merge_group:checks_requested → ci.yml core lane (one run per entry, own ref)
AWAITING_CHECKS ──(any verdict job red)──► queue-fail-fast cancels own run
  │                                         → Nested Gate/contexts post failure
  │                                         → UNMERGEABLE → REMOVED
  │                                         → every entry behind k REBUILT on new chain
  │──(no report in 30 min)──► REMOVED (I-gh)
  │──(conflict)──► UNMERGEABLE (headCommit null)
  ▼ all 7 green on group SHA (4 real, Nested Gate=core verdict, 2 by-design)
MERGEABLE ──(all ahead green; ≤2 min wait; ≤16 per merge)──► squash to main
  ▼
push:main ci.yml (mg_validated skips tests) → promote window → deploys

§2 The PR lifecycle, stage by stage

2.0 Workflow enable/disable state (V, a finding that cuts across stages)

15 workflows are disabled_manually (actions/workflows), including auto-merge-all.yml (since 2026-10-01 20:48Z; its last run was 20:48:16Z), claude-review-recheck.yml, claude.yml (@claude review), and the standalone semgrep, docs-only-fence, pr-size-tier and pr-body-gate. The callees still run through ci.yml's uses: (sampled PR runs: Semgrep SAST / Scan=success, PR gates / PR gates=success). auto-merge-all no longer runs at all, so its arm call (auto-merge-all.yml:1082) is moot. Enqueue happens through human credentials (§3.4).

2.1 Per-stage matrix

StageTriggerWhat runsRevision it checksConcurrencyFailure behaviour / goes stale?
Commitlocal.husky/pre-commit: regenerates the route manifest only when route, page or surface files are staged (V)index–blocks the commit if generation fails
Pushlocal.husky/pre-push: symbol sweep, deleted-file importers, phone deploy lane, docs index, husky runtime (blocking); mutation controls warn-only; behind-count advisory. Then push-pacer.sh waits if >80 queued runs or >10 pushes/10 min (fails open) (V)local origin/mainper machineblocks the push; bypassed by --no-verify
PR CIpull_request [opened, synchronize, reopened, labeled, unlabeled, ready_for_review, edited] + pull_request_review (ci.yml:169-222)preflight ‖ plan → checks, affected test (capped), diff-scoped guard shard, evals, folded callees (docs-only-fence, pr-gates, semgrep, require-eval-diff…), regression gate, nested-gate, pr-verdictdefault checkout = refs/pull/N/merge at event time (I-gh; no ref: override except ref: main at ci.yml:7044)per event/label class + ref, cancel-in-progress except push (ci.yml:331)goes stale when main moves, never re-run (strict:false); no full suite per PR
Bot reviewclaude-code-review.yml: opened, ready_for_review, reopened, synchronize, dispatch; not merge_group (V)relevance (about 90 s settle wait + supersede check) → LLM review → encoder posts reviewPR head (claude-code-review.yml:1011)per PR, no cancel (1 running + 1 pending)re-runs every push, incremental from the last full read; 🔴 → red
review writersseveral (V)encoder check_run; ci.yml override (APPROVED, only if commit_id == head); recheck (blocking carry-forward, disabled); dangerous-diff-fence merge-hold commit status (pull_request_target, main's copy)head SHAper PRhow GitHub combines same-name writers is contested in-repo (U)
Fencesdangerous-diff-fence.yml (pull_request_target); stacked-pr-guard.yml (PRs with a non-main base, including auto_merge_enabled)dangerous-globs label + review hold; disarms stacked PRsmain's copy of the workflow; PR read via APIper-PR groupshold is cleared only by label/state
Queuesee §3ci.yml core lane plus full set on one machinemerge-group SHAper queue refeject plus rebuild of everything behind
push:main CIpush main (docs paths-ignore)merge-group-validated.sh: if a successful merge_group run had the same tree, test/guard-pins/test-full skip (ci.yml:437, fails open); checks still runs; vercel-promote; alert-main-red sets AUTO_MERGE_FREEZEmain SHAper run; preflight cancels superseded runsred → freeze + alert
Web deployvercel-promote in push CIwaits for the 30-min batch window (promote-window.env), then deploys the newest green tipnewest green mainsingle, no cancelLambdas wait on the whole CI run
Lambdas / worker / previewworkflow_run on a successful push CIper-lambda promotes; the worker deploys the newest green main, not the commit that triggered itmainper-lambda, no cancelonly after the whole CI run succeeds
Heavy backstoppost-merge-heavy.yml cron 7,37full unit plus full guard pins on main HEAD. On red: bisect, open an issue, auto-revert, hold deploysmain HEADsingle, no cancelreleased on green

2.2 Key points from §2

4 · CI execution architecture · 11 · Queue economics

PropFlow CI/CD forensic map: §4 Execution Architecture and §11 Queue Economics

Pinned to origin/main @ 864d9a57570 (64 workflows). Live data captured 2026-10-02 ~22:05Z. Tags: V = VERIFIED (file:line or command output), I = INFERRED, U = UNKNOWN.


§4 CI execution architecture

4.1 Runner fabric: RunsOn (self-hosted EC2 autoscaler) in AWS account 194925971150, three stacks

StackHow it was provisionedRegion / AZsEnvironmentWhich labels reach it
runs-onCloudFormation, RunsOn app v3.3.2us-east-1, 2 AZsproductionevery label with no env=
runs-on-usw2CloudFormationus-west-2usw2/env=usw2/region=us-west-2
runs-on-flexTerraform (infra/runs-on-flex/, module runs-on/runs-on/aws//modules/flex v3.3.2, state in s3://propflow-terraform-state)us-east-1, 6 AZs, VPC 10.3.0.0/16flex/env=flex/region=us-east-1, including pool=ci-2cpu-arm64

4.2 Labels and routing

V: no .github/runs-on.yml in the repo; pools live in .github-private:.github/runs-on.yml (last change 2026-10-02T19:06Z, "keep 6 hot"). Nearly every job uses: ${{ vars.CI_RUNS_ON == '1' && format('runs-on={0}/...', github.run_id) || '<GitHub-hosted fallback>' }} V repo variables: CI_RUNS_ON=1 (set 2026-09-28) and CI_CAPACITY=west-only (set 2026-10-02T19:57Z). scripts/lib/no-hosted-runners.ts fails any hard-coded hosted label.

CountLabel shapeSpotFallbackWhere it lands today
115runner=2cpu-linux-x64/cpu=2/ram=8, 15 Intel/AMD familiesprice-capacity-optimizedubuntu-latestruns-on (no env)
33+1pool=ci-2cpu-arm64/env=flex (ci.yml glue jobs)pool = on-demand t4g/m7g/c7g/m8g/c8g, image ubuntu24-full-arm64, 30 GB gp3ubuntu-latestflex
102cpu x64 spot=falseon-demandubuntu-latestruns-on
~10various 8cpu-arm64 (m7g/m8g/r7g/r8g ± d)mixedpropflow-arm64-8core (a hosted larger runner)runs-on
21cpu-linux-arm64 t4g/c7g/m7g (queue-fail-fast, context-claims)spotubuntu-24.04-armruns-on
1checks: 4cpu/32-64 GB on PR, 8cpu on others, volume=80gb, spot=false on merge_group; west when CI_CAPACITY=west-only or run_id ends in 7/8/9mixed8coreusw2
1test: same switch as checks, 4cpu/16-32 GB on PRmixed8coreusw2
1test-full: 8cpu, spot=false on pushmixed8coreusw2
1guard-pins (6 shards): 4cpu/16 GB, spot=false on merge_groupmixedubuntu-24.04-armflex
1full-set-one-machine: cpu=64/ram=128+256, m7g/m8g/c7g/c8g/r7g/r8gspot8coreruns-on (2-AZ), because this label has no CI_CAPACITY clause
1one-machine-shadow: 8cpu spot, west switchspot8coreusw2

V EC2 snapshot (~1 h of churn, not a day): runs-on-flex: 2,536 t4g.medium (1,911 spot, 625 on-demand), 331+29 m7g.xlarge, 198 m8g.xlarge. runs-on: 522 t4g.small, 447 m7i-flex.large, 212 m6a.large, 48 c7g.16xlarge + 24 c8g.16xlarge (the 64-vCPU full-set). runs-on-usw2: 133 on-demand m7g.2xlarge plus r8gd/m8g/m8gd spot. Anomaly (V): 625 flex t4g.medium instances are on-demand while 1,911 are spot. U: why the pool, defined as on-demand, also runs spot.

4.3 Caching and artifacts

4.4 What GitHub-hosted runners provided implicitly that now has to be supplied explicitly


§11 Queue economics: where work is thrown away

11.1 Volume, last 24 h (from 2026-10-01T22:00Z)

total_count figures are uncapped (V). The per-conclusion splits for PR, merge_group and review come from the API's newest-1000 cap, so they cover only the last ~10-18 h (V, caveat). Push is complete.

ci.yml eventRuns/24hCancelledSampled jobs runExec-min/run (median)Wall-min (median)What "cancelled" means
merge_group (passing)2,260 total—2167.5 (heavy 4/8/64-cpu: 47.7)17.8—
merge_group (cancelled)1,539 (68%)37 (20 cancelled)55.8 (heavy 44.2)26.4Every sampled run (15/15) already had a red Full set (one machine) or Guard pins (k/6) before Queue fail-fast (scripts/ci/queue-fail-fast.sh watch) cancelled the rest (V).
pull_request1,54910915.512.65.7top-level concurrency, cancel-in-progress on non-push events
pull_request_review1,48218140.91.4same
push (main)293235 (80%)88.98.410/10 sampled: only Vercel Production Promote was cancelled

11.2 Duplicated test work

11.3 Installs, shards and global checks

11.4 Idle waiter jobs occupying instances (V, sampled durations)

Vercel Production Promote ~30 min (pool), Queue fail-fast ~15 min (1cpu), Preflight (debounce) ~3.3 min on push. ci.yml has 56-65 jobs per run, of which 44-52 are skipped. I: review runs (1,482/day × ~4 glue jobs) produce several thousand pool boots a day to deliver debounce and verdict results.

11.5 Concurrency inventory (V)


Caveats

5 · Test topology

§5 Test topology — propflowai @ origin/main 864d9a57570

Tags: V verified, I inferred, U unknown. :N is a ci.yml line at the pinned SHA. Timings come from gh api …/jobs, 2026-10-02.

5.1 Inventory (V, git ls-tree origin/main)

CountWhat
7,931*.test.ts(x) files. By directory: src/__tests__ 3,118 · src/lib 2,328 · src/app 934 · scripts 663 · src/components 379 · agents 298 · evals 130 · lambda 67
913.test.tsx files. 166 import @testing-library/react; 791 files carry @vitest-environment jsdom. The default environment is node (vitest.config.ts:282)
670*.drift.test.ts(x) files
28Playwright specs in e2e/
2*.contract.test.ts, which read contracts/change-term/scenarios.json
12Extra vitest configs (harnesses)
192 files / 765 violationseslint-suppressions.json

5.2 What "affected" means

The selector (V). The selector is npx vitest list --filesOnly --changed $BASE (ci.yml ~2475, scripts/test-affected.sh, scripts/ci/merge-group-affected.sh). It picks two kinds of file:

Locally it also includes staged, unstaged and untracked files.

The base per event (V):

The cap (V). AFFECTED_CAP=200. What happens over the cap depends on the event:

EventOver-cap outcome
pushmode=full
pull_requestmode=capped. The required Unit Tests stays PENDING until a review verdict
verdict-reviewdecide-delta-escalation.sh runs --changed against the last fully green ancestor (mode=delta); otherwise full
merge_groupmode=full (the full-set job covers it)
localexit 1, runs nothing

Zeros (V). classify-zero-selection.mjs splits two cases:

assert-selection-covers-changed-tests.sh fails if a changed test file was dropped. Selector crash → exit 1.

Gating (V, decide-test-lane.sh).

Blind spots:

  1. Tests that read their subject with fs instead of importing it are invisible to --changed. PR #5471 caused a 14-hour deploy stall this way. CI compensates with:
    • (a) On PRs only, the "Always-run drift and decision tests" step runs every .drift.test. and decide-.test. file except 42 hard-coded slow ones (always-run-tests.mjs SLOW_EXCLUDED).
    • (b) Guard pins (5.3).
  2. The always-run step's condition is mode ∈ {affected, delta, no-tests} (V). A hub-sized PR (capped) gets neither the affected run nor the drift set on its push. Only the fence subset of guard pins runs until a verdict arrives.
  3. merge_group's affected lane has no always-run step (V, merge-group-affected.sh). It relies on the full-set job.
  4. Local runs have none of this compensation. test-affected.sh is plain --changed. A local green says nothing about tests that read .sh, YAML, JSON or workflows.
  5. Root config edits (package.json, vitest.config) probably match vitest's default forceRerunTriggers, which selects everything and escalates (I: no override in the config).
  6. Never selected (vitest.config.ts excludes):
    • 15 EVAL_SUITES (opt-in RUN_EVALS=1)
    • the timing-quarantined mutation-control suite (nightly)
    • 3 ADVISORY_TESTS (advisory-tests.yml)
    • e2e/
  7. Shallow history. The affected job aborts on a missing merge-base (:2465). The old --depth=1 empty-selection failure is closed on main (V, depth 0).

5.3 Guard pins (drift/runtime-config validation) — V

The list. guard-union-files.mjs builds one list, with floors checked per source, from:

It holds about 1,035 files, per a guard-select.mjs comment (I as of today).

What each event runs (guard-select.mjs):

A PR push never goes FULL, even past 300 changed files.

Shards and timing. 3 shards on PRs and 6 on merge_group (observed). There is no --passWithNoTests, and each shard checks the list's count and sha256. Each shard takes about 2.1–3.0 min on PRs and 2.6–3.6 min on merge_group.

The integration gap it documents. On 2026-10-01 there were 122 merge_group Guard-pin reds. Each was on a bookkeeping pin whose fact the batch's PR had moved, under a green PR run. The merge-queue full set on fresh main is the only lane that sees a pin moved by a sibling merge.

5.4 Category table

CategoryWhat runsTriggerScopeRuntimeDeps
Linteslint --cache plus the suppressions ratchetPR, merge_group (if: event != push, :1726). push only warms the cacheglobal, cachedin CI Checks–
Typechecktsgo --noEmit, plus typecheck:scriptstsgo on PR/push only (:1167). Full tsc on schedule/dispatch only (:1334). On merge_group the verdict is fed only by scripts-tsgo (V, verdict step); main-tree typing there comes from the build-tree tsc (:2033), which feeds build_result (I)globalCI Checks on PR 2.3–3.7 min (V, n=20)–
Static ratchetsabout 12 check:* scripts (ddb-escapes, settings-reads, symbol-sweep, docs-index, …; :1580-1910)CI Checksglobal or diff vs mainin CI Checks–
Buildnpm run buildmerge_group and Dependabot only. On PR/push it is an echo, deferred to Vercel (:1930-1975)globalmerge_group CI Checks 11.7–13.1 min (n=4)auth secret
Unit/componentUnit Tests (affected)PR (non-draft), verdict, merge_group, pushaffected, cap 200PR 2.8–8.3 min (median ~3); merge_group 3.2–6.0DATA_BACKEND=json; DDB calls go to a sinkhole endpoint (vitest.setup.ts)
Full unit + guardsFull set (one machine): 64 CPUs; guard union, then npm test; one retry per failed file; flaky quarantine and heavy-lane-red known-red exclusions (full-set-one-machine.sh:108-112)merge_group only (and dispatch)global8.8–11.0 min (n=4)gh token
test-full (2 shards)only schedule, dispatch, Dependabot (:3236-3250). ci.yml has no schedule: trigger (I: effectively dispatch and Dependabot)globalU
post-merge-heavy.yml: unit ×2 and guards ×3 on the main tip; bisects, files an issue, auto-reverts, holds deployscron every 30 minglobal15.8–16.5 min (n=3)AUTOMERGE_TOKEN
Advisorytest:advisory plus mutation controlspush:main, nightly3 files + quarantine~12.9 min (n=1)–
Regression Gate — Leasing (required)relevance check, then quality-gate-poc npm run gate on head vs base fixtures (:6220-6480)PR and review only. Stubbed "by design" in merge_group (:6670)path-relevant0.3 min when not relevant–
Contract2 tests over contracts/in the unit lane (fs readers; membership in the guard union is U)–––
DatabaseNo DynamoDB Local or LocalStack: repo-wide git grep found 0 files (positive control AWS_ENDPOINT_URL_DYNAMODB found). Prod-backed scripts are manualnot in CI––AWS
E2Eonly e2e/preview-probes runs, in ui-verify (:5875-5922). Other specs are manual (test:journeys, …)PR only when a preview exists and UI was touched; continue-on-error. Previews are opt-in ([preview])subsetUVercel preview
Harnessesharnesses.yml (7 jobs: lambda-bundles, portfolio, …)push:main (path-gated by a changes job), weeklypath-gated1–7 min; recent runs mixed failuresome secrets
Evals (LLM)intent, renewal{,-sms,-scoping}, renewal-replay, clara-behavior (daily budget), pipeline-lab (needs a preview), voice-comprehensiveworkflow_call from ci.yml only when event_class=='code' (a PR code push) and pr-plan.paths.json globs match (:6099-6188). Never in merge_group or push. Some also run on weekly cronspath-gated, PR headtimeouts 30–60 min; actual Usubscription OAuth, ElevenLabs, AWS, Vercel
Nightlynightly.yml: eval sweep, smoke, canaries, mutation registrycron 0 8 0 (weekly at this SHA), but runs were observed daily 09-27 to 09-29, so the cadence changed recently (U)global128–137 min, 3/3 failure (V)AWS, Anthropic, ElevenLabs, MS, SMOKE_*
Deploy validationvercel-promote (needs type_check, test-aggregate and build success, :4762), sns-forwarder canary, deploy-lambdas pre-deploy checks. npm run canary is manual (only a comment at :5970)push:main–promote 29.6 min (n=1)Vercel, AWS

5.5 Result reuse (V)

5.6 Which lanes catch integration failures

FailurePRmerge_groupafter merge
Type break from a stale basebase as of the push (I)tsc over the build on the queue tree (V)push tsgo
Test broken by a sibling mergenoaffected vs queue base, plus the full setheavy lane, with bisect and revert
Guard pin moved by a siblingsubset or fence onlyfull guard set (122 reds on 10-01 surfaced here)heavy lane
Build breakno (deferred to Vercel)yesVercel
LLM behaviourpath-gated evalsnoweekly/nightly crons

Queue health (V).

5.7 Unknowns

Percentiles (small samples), live guard-union size, real eval durations, test-full cadence, and whether the full set's quarantine or known-red exclusions mask real breaks.

8 · Build & deploy boundaries · 9 · Freshness

PropFlow CI/CD forensic map: §8 Deploy boundaries, §9 Freshness/staleness

Workflow and script files were read at 864d9a57570. The PR-lifecycle lookup ran after a mid-task fetch, against origin/main = 89ea1dc5e2. Live API and AWS reads (read-only) were taken around 2026-10-02 21:50Z. Tags: V = verified (file:line or command output) · I = inferred · U = unknown.

§8 Build & deployment boundaries

8.0 Headline

8.1 Next.js web app (Vercel)

src/ → timeout 1200 npm run build (vercel.json:2) → Vercel deployment → propflowai.co alias → Vercel functions + ~30 Vercel crons. V

8.2 Lambdas (deploy-lambdas.yml, 4,280 lines)

lambda/<name>/ + the src/lib/ it imports → npx tsx lambda/<name>/build.ts (esbuild) → dist/lambda.zip → aws lambda update-function-code → publish-version → update-alias live. This is raw AWS CLI: no SAM, CDK or CloudFormation. V**

8.3 Renewal / Temporal worker (deploy-renewal-worker.yml)

src, agents, services/renewal-worker, infra/temporal-worker, packages, config, lockfiles and a few others → source hash (git rev-parse HEAD:<paths> | sha256sum, 202-217) → arm64 Docker image → ECR propflow-renewal-worker:<sha12> → ECS Fargate service renewal-worker on cluster propflow-renewal-worker-prod (new task-def revision, ecs wait services-stable). After success the image gets the tag deployed-<env>-<srchash>. V

8.4 Third-party config written on push:main (not CI-gated, not batched)

WorkflowTriggerWrites
sync-specialists.ymlpush: main, paths src/lib/tools/, agents/clara/lib/{agent/specialists,voice-agents}/ (76-82)ElevenLabs agent / prompts / transfers / turn / language / voice-sound, shared fleet and --property appfolio-45 (336-454)
sync-phone-numbers.ymlpush (paths) + nightly cron + dispatchElevenLabs phone assignments (prod-sync, 233)
sync-turnover-intake.ymlpush (paths)turnover-intake agent + prompts (89)
dlq-alarms-refresh.ymlpush (paths scripts/ensure-dlq-alarms.sh, lambda/lib/observability-alarms.sh) + weekly cronCloudWatch DLQ alarms

8.5 Other units and coupling

8.6 Measured deploy cycle (24h to 21:50Z, 2026-10-02)

§9 Freshness / staleness

9.1 Branch creation

9.2 While the PR is open, nothing refreshes it

9.3 Which main a PR is tested against

9.4 Survival across a new SHA

9.5 Measured lifecycle (40 latest merged PRs, #15564–#15754)

MetricMedianp90
first commit → PR open2.9 min23 min
PR open → merged141 min230 min
branch base → merged229 min307 min
main commits landed between base and the PR's squash28.558 (max 63)
queue dwell (squash committer date → mergedAt, a proxy)35 min155 min
commits per PR13

V (gh pr list + pulls/N/commits + git rev-list --first-parent). "Base" is the parent after any rebase, so this undercounts the original branch age. I

9.6 Where staleness enters

  1. Branch creation: fresh, enforced by the hook plus the post-checkout fast-forward. V
  2. Open PR (~2.5h median): tested against main as of its last event and never refreshed. V
  3. Queue: current main, but affected tests only and no re-review. V
  4. After merge:
    • Web prod lags main by a median of 53 min. V
    • Lambdas and the worker trail the same green CI run by 6–34 min. V
    • Voice config goes live ahead of CI. V/I
  5. Superseded promotes: about 80% of push runs end "cancelled". Lambdas and the worker fire only on the ~46 green runs a day; diff-range folding covers the commits in between. V
10 · Failure forensics

§10 Failure forensics: PropFlow CI/CD

Collected 2026-10-02 ~22:00 UTC, read-only; main tip 429eca86187. Tags: [V] verified from logs, annotations or the API; [I] inferred; [U] unknown. Failures were classified from failing-job annotations and log greps, not from run conclusions. Excluded: claude-code-review verdicts, and cancellations from cancel-in-progress (push:main had 2,063 of these since 09-18 [V]).

0. Three facts that change how every number reads

  1. Since 10-01 20:03 UTC, queue failures are recorded as cancelled. [V] PR #14934 added a Queue fail-fast job that cancels the whole run once any verdict job is red. Merge-queue runs ending failure went from 174 on 10-01 to 0 on 10-02, while cancelled went to 1,377. In a sample of 180 cancelled runs from 10-02, 170 (94%) contain a real failed job [V].
  2. The queue was not on the whole time. [V/I, from PR timelines] It ran 10-01 from 11:00 to 20:00, was off from 20:00 to 23:00 (84 PRs merged directly in that hour), and has been on since 10-01 23:00. Below, "pre-queue" means before 10-01 11:00 UTC.
  3. Run counts are not incident counts. [V] One broken test produces many red runs, because:
    • every merge group queued behind a bad entry fails too;
    • every push to main re-fails until it is fixed;
    • "label-only" PR runs re-state a failure that already exists.

1. Merge queue (all post-queue)

Runs per day [V]:

DaySuccessFailureCancelled
10-01369174149
10-0267401,377 (about 94% of these hide a failure)

Reruns that came back green [V]:

1a. Before fail-fast (178 failed runs, all classified):

Root causeRuns%Evidence
A break already on main, tripped by later groups (DDB-escape gate: tests making real DynamoDB calls)9151%#14896 [V]: merged 17:05. 47 runs failed on 10 reply-release-* tests between 17:41 and 19:50. Fix 81783769491 says: "#14896 routed the staff-answer relay's line lookup through resolveStaffLineAndThread… tests now mock it." #14850 [I]: merged 15:41, and its own group passed. 44 runs failed on tour-reconfirm-staff-heads-up.test.ts between 15:38 and 16:34. I found no fix commit, so what ended that cluster is [U].
The same drift, ratchet or guard test failing across many unrelated PRs7743%[V] e.g. guard-pin-kinds (16 runs / 14 PRs), workflow-grep-marker-ratchet (16 / 16), shell-pipefail-ratchet (12 / 7), import-guards (9 / 6), user-route inventory (10), dangerous-diff glob mirror (7), workflow-concurrency (6). Main has matching fixes, e.g. "regenerate guard-pin kinds sidecar (red main)" #14632/#14649/#14678.
One PR's own bug, retried53%[V] atlas-record-only-settings, a single PR
Infra: AWS reclaimed the spot machine / exit 13742%[V] all on 09-30 between 23:17 and 23:20
Lint plus spot11%[V]
Textual conflict, compile, dependency, deploy00%[V] Textual conflicts are ejected before any run starts (§4)

Incidents: these 178 runs collapse to about 12 distinct incidents.

1b. After fail-fast: sample of 171 cancelled runs that contain a failed job. The sample is skewed: 100 come from 19:00–22:00.

Root causeRuns%Evidence
Same guard test across many PRs~7544%[V] sync-tour-mock-covers-both-views (33 runs / 19 PRs, 19:28–22:01), guard-receiver-dot-blindness (19 / 14), eval-catalog-coverage (11 / 11, within 7 minutes), property-owner-id-retired (5)
"Full set (one machine)" job (#15399)7242%[V] Log grep of 30 of them: the same clusters again (ws-lead-outcome + findtour-property-gate 11/30, voice-confirm-turnover-sync 8/30), plus quarantined files (#15203)
Infra: runner disk full ("No space left on device")116%[V]; related fix #15452 (80 GB disk)
Unclassified~106%

CI config churn is a cause in its own right [V]: about 20 merged changes to ci.yml between 09-30 and 10-02, including two reverts (#15220, #15372). Several of them change which tests the queue runs. [I] Some clusters are tests that never ran before merge until the queue started running them: a coverage gap showing up, not two PRs conflicting.

2. Red main (push:main ci.yml, 09-18 to 10-02)

703 failed runs [V]: 671 on attempt 1, 32 on attempts 2–3. Of about 4,100 total runs. Worst days: 09-29 (107/338) and 10-01 (224/509). 10-02 had 6/246. Reruns that came back green: 14 of 500 sampled successes (2.8%) [V].

By period [V]:

PeriodMain failures
Pre-queue644
Queue on, 10-016
Queue off, 10-01 20:00–23:0045
Queue on, 10-026

Caveat [V/I]: PR #14455 (09-30) and PR #15194 (10-02) made push:main skip test lanes the queue already ran. So the drop is partly real prevention and partly main testing less. This data cannot separate the two [U].

163 incidents (same test, gaps under 6 hours). Incidents lasting more than one run were red for a median of 0.5 h, p90 4.2 h [V].

CategoryIncidentsRuns% runs
Drift / ratchet / guard / pinned-inventory tests5524735%
Behavior / unit tests (mocks, fixtures, expectations)5920129%
Unclassified*3520529%
Infra: AWS VcpuLimitExceeded (runner fleet could not launch)5203%
Deploy: Vercel promote / canary4172%
Timeout / shard cancelled4101%
Typecheck11<1%

\*A CI self-test emits about 70 deliberate ::error lines, which crowds the real failures out of the annotations. A 50-log sample of this bucket resolves mostly to user-route-inventory, entity-conversation-routes and active-thread-invariant.

Was main red on code that was green on its PR? For the 22 largest test incidents I looked at the PR behind the first failing run [V]:

Two caveats:

73 "red main" or "collision" fix commits on main since 09-18, read by their titles [V titles; I categorisation]:

Stated causeCommits
Tests or mocks stale after a single PR ("red main after #X")29
Pinned or generated artifact drift (line numbers, exact counts, sidecars, manifests, inventories, baselines) — e.g. #15745 "no longer pins an exact kind count (queue collision fix)", #15754 "per-file, not an exact shared count", #15751 "anchors on symbols, not line numbers"28
Two PRs explicitly named as colliding ("#14660 vs #14931", "#12985 x #13017 semantic collision")7
Flake (jitter, daysAgo() milliseconds, fake timers)4
Compile or import3
CI config2

3. PR runs (pull_request ci.yml)

Failed / total by day [V]: 09-30 405/1,724; 10-01 460/1,589; 10-02 208/1,398. Sample of 30 failed runs, every 10th, from 10-01 and 10-02:

Classn%
The PR's own repo-policy guard violation (drift/ratchet/fence, lint ratchets, timezone guard, legacy-field, mock-shape)1033%
PR size / dark-setting gate (sizedark)413%
Generated or pinned artifact out of sync (stale-base candidates)†413%
CI plumbing or infra: GitHub API rate limit inside the job (3; one [V], two in the same minute [I]); checkout/npm setup (1)413%
Duplicates: label-only re-statements (2), aggregator with no root cause (1)310%
The PR's own compile or runtime bug27%
Schedule-drift job (same PR twice)27%
LLM eval13% [U]

†Stale-base candidates: #15648 failed the customer-fence baseline while touching 0 related files [I]. #15094, itself a red-main fix PR, failed on the inventory [I].

So about 55% are the PR's own fault, and mostly policy violations rather than functional bugs. About 13% are prone to a stale base, and about 23% are noise. The sample contains no clear flake [V, n=30].

4. Queue removals vs merges since 10-01

Source: GraphQL RemovedFromMergeQueueEvent.reason. Note that the REST removed_from_merge_queue event also fires on successful merges. Coverage is 1,000 of 1,263 PRs, so counts are understated by up to about 20%.

ReasonEvents (PRs)Median time to re-add
manual168 (143)9.7 min
failed_checks80 (65)20.5 min
merge_conflict (textual)58 (49)36.7 min
invalid_merge_commit2
checks_timed_out2

5. Against the hypothesis ("~50% stale-main/overlap, the rest build/runtime/config, flakes rare")

On PRs, the PR's own policy violations dominate.

6. Missing data

PropFlow Docs