Why pull requests here took hours to land, and what changed

"pr in plugins not draining""work on making this smooth" — maintainer, 2026-09-11

This page records one measurement, what it found, what was fixed, and what is left for the maintainer to decide. Every number below can be re-derived from the GitHub REST API and a clone. The method is at the end.

The denominator

The causes, counted

cause events PRs hit cost state
(d) DIRTY on generated manifest.lock only 37 of the 40 DIRTY states 16 116 PR-hours DIRTY (a union per PR) fixed here: 24 of 37 now resolve automatically, 13 need the App grant (see The one gap, below)
(d) DIRTY on a source file 3 1 (#1625) 6 PR-hours the author's; not automatable
(f) author gone while DIRTY DIRTY windows longer than 8 h 5 (#1575, #1602, #1607, #1614, #1618) 83 of the 116 PR-hours every one of these windows was lock-only, so the (d) fix covers it
(a) a sealed core set reds every PR 4 regressions in 24 h 9–13 open at a time 8.6 h of windows, 91 PR-hours exposed proposal (see Proposal 1, below)
(a′) a core LANE change on @main kills every run 1 (core #3933) 13 open 4.8 h, 63 PR-hours exposed fixed in core (#3968), and core now gates the class
(a″) the Plugins half ran before its core half was sealed 2 runs (#1601, #1602) 2 one re-run each ordering; nothing to fix
(b) the PR's own defect 4 PRs (#1614, #1624, #1628, #1666) 4, plus 2 collateral #1624 reddened main for 0.6 h test-repos now required (#1644)
(c) a race, named once 2 runs 2 one re-run each named below, not re-run to "see"
(e) CI infrastructure 3 runs; job queueing 3 queue p90 9 min per job, max 31 min reported only
(g) over-selection: a scripts/ edit is full validation 1 measured run (#1668, run 34618468550) 1 ~89 runner-min of portal-host shards + ~200 of module tests, 15:50Z → 18:45Z, for a change that reached 1 of 52 compiled projects fixed: devtools/ is a declared no-op dir (core #4050, then this repo)
(h) the module test ledger is OFF on PR runs every PR run all Module tests (MeshWeaver.AI): 1,950 tests (14 min) re-run for a diff that never touched AI handed to #1676's owner, because the ledger: input sits in the modules-floor call that PR rewrites

(d) DIRTY churn — the dominant cause

GitHub's own merge (driver-free, see the method) called a pull request conflicting 40 times across 16 of the 50. 37 of the 40 conflicted on nothing but */manifest.lock. The locks that conflicted most: Hosting 16, Edu 12, Observability 7, Mcp/AI/Mail 6 each, Chat 5, and every AI-provider lock at once whenever src/MeshWeaver.AI moved (it rides into ten bundles).

Why every lock conflicts. A lock's trailer — moduleVersion, sourceCommit, version — changes on every content change to its module. Two pull requests that touch the same module, even in different files, therefore always rewrite the same three lines to different values, and a text merge cannot reconcile them. Since 2026-09-04 the most-touched file on main is Hosting/manifest.lock (143 commits), then Mail/manifest.lock (91). ci.yml is third, at 51.

Why the local merge driver did not help — the root cause, named. .gitattributes declares */manifest.lock merge=mw-manifest-lock, but a merge driver is configuration, not content: it exists only where scripts/setup-git-merge.py has installed it, per checkout. GitHub has no such driver, so its plain text merge stops on a lock that a correctly-configured clone merges cleanly.

That asymmetry is the whole mechanism, and it produces a contradiction that reads like a broken instrument: git merge-tree says CLEAN while the API says mergeable=false for the same two commits. Measured on the 40 real DIRTY states: with the driver armed locally, a local merge-tree reported no conflict at all in 37 of them. It is also why #1676 and #1670 "went DIRTY three times after being fully green" on 2026-09-11: every merge to main regenerates the locks, so any two pull requests touching one module collide pairwise, again and again.

🚨 And the second trap, which the machinery's own note warns about: a conflicting pull request can silently drop its armed auto-merge. Checking that the arm survived one cycle is not a guarantee that it survives the next, so a PR can sit green, unarmed and unnoticed. Read the arm state itself (auto_merge), never the fact that an arm ran.

Why it cost hours, not minutes. The DIRTY windows sum to 116 PR-hours, and 83 of those are windows longer than eight hours: an agent session opened the pull request, moved on, and nobody pushed the one merge that was needed. #1575 alone was DIRTY for 39.6 h across seven windows, every one of them lock-only.

The pull request that added the resolver hit this exact case while it was still in CI. #1678 went DIRTY on Hosting/manifest.lock alone the moment #1673 (a Hosting change) merged at 2026-09-11 ~19:3xZ. Its own classifier answered ['Hosting/manifest.lock'] -> generated-only. It was resolved by hand, because the lane was not on main yet: a merge, the post-merge regeneration, both --check copies, and a push. That restarted a ~40-minute check set over content nobody had changed.

The devtools/ PR (#1679) hit the same case about an hour later. When #1625 (the Edu learning roadmap) merged, #1679 went DIRTY on Edu/manifest.lock alone. The only thing #1679 changed under Edu/ was a one-line doc reference to the moved script. It was resolved the same way, by hand, and its CI restarted from the queue. Two of this change set's own PRs lost a full CI cycle each to cause (d) on the day they were written.

(a) and (a′) — the platform moved under every open pull request

window (UTC) what red PR runs open PRs PR-hours exposed
09-10 16:46 → 18:15 (1.5 h) sets 8280–8285: TeardownTwinParityTest.TwinIsInStepWithCore(LateNackReenqueueTest), shard 2 — core changed the test body; Plugins#1619 ported the twin #1618, #1622, #1623 13 19
09-10 18:57 → 23:47 (4.8 h) core #3933 changed node-repo-module-pack.yml on @main to demand a permission the Plugins caller did not grant: every run startup_failure, zero jobs — 14 PR runs, the main push and 11 release dispatches. Fixed by core #3968 14 × startup_failure 13 63
09-11 07:18 → 08:51 (1.6 h) set 8329 (core #3981): GitHubWebhookProcessorTest / GitHubWebhookRepoRenameTest, shard 1 — GitHubWebhookProcessor began requiring workflow_run.path; Plugins#1649 fixed the fixtures #1623, #1625, #1639, #1642 13 20
09-11 10:31 → 14:56 (4.4 h) sets 8345–8355: NodeTypeRecompileAlcLeakTest.RecompilingANodeType_WithALiveInstance_StillReleasesSupersededContexts, shard 3; green on 8357 #1575, #1657 (+ main) 9 40
09-11 15:44 → 16:48 (1.1 h) set 8361: MeshNodeStreamCacheFaultedEntryReprobeTest.FaultedEntry_IsNotServedTwice_NextReadOpensANewUpstream, shard 2 (#1671); green on 8367 #1664 11 12

A window runs from the seal of the first set that carried the regression (or the merge of the lane change) to the seal or merge that ended it. "PR-hours exposed" is window × open non-draft pull requests: each one that ran the affected shard in that window went red for a reason its diff could not reach, and a narrowed PR that did not run the shard was spared. Only 10 red runs are directly attributable, fewer than the exposure suggests, because the PR matrix is narrowed to the affected scope. The cost shows up as the wait: an author who sees the red either re-runs it or waits for core. Neither is something their diff can fix.

The lane class (a′) now has a platform-side gate. Core's check-workflow-permission-pairing.py (core 05577b3cd, "a caller must grant what its lane demands — the check that would have caught #3933") runs in node-repo-validate.yml against every caller.

(b), (c), (e) — read, not re-run

(g) and (h) — a run that rebuilt what it had already proven

Peer session meshweaver-plugins-a6 measured both on run 34618468550 (#1668).

(g) Over-selection. scripts/ is the gates' directory, so a change there counts as EVERYTHING, and correctly so for a gate. scripts/run-node-tests.py, though, is a local dev loop that no workflow references. A one-line edit to it selected 52 of 52 compiled projects and every module suite. The same diff without that file selected 1 of 52. NOOP_FILES is a closed list of repo-root names by design, so it offered no sanctioned way to say "this script reaches no lane".

The fix is a declared no-op directory, devtools/, in NOOP_DIRS on both sides. The dev tool now lives there and still borrows scripts/compile-check.py. Why these choices:

It also joins both package enumerators' SKIP and gen-manifests.config.json. Without that, gen-manifests.py minted a devtools/manifest.lock and treated the directory as a module.

Control on this repo's real tree, using the platform's node-repo-project-scope.py against .github/compiled-validation.json:

diff build test answer
one line in devtools/run-node-tests.py 0 1 narrowed. The one test project is Memex.Hosts.Test, whose declared input is .
one line in scripts/check-covers.py (a gate) 3 49 shared or unknown input … — full validation

The same over-selection, one gate further on — in flight by another session. The required gate Compile every NodeType (vs core) already compiles each NodeType on its own, from its declared source set. But it compiles all of them on every run. Core's .github/scripts/compile-check.py has no subset option (only --refs, --image, --self-test, --gen-allow), and ci.yml hands it the whole checkout. So a pull request that reaches one module still compiles every NodeType in the repository.

The fix has the same shape as test-repos: on pull_request the gate takes the affected closure from scripts/affected-modules.py, and stays FULL on push, dispatch and schedule. An unknown module name is refused, never compiled as nothing. An empty closure still publishes the required context as a success that says "0 NodeTypes affected", and never as skipped.

This work belongs to session meshweaver-plugins-a6, which Roland asked to build it on 2026-09-11. It is in flight as #1681, "ci(compile-check): one leg per atomic unit". It builds on the devtools/ fix above (#1679), because the selector is only worth something once scripts/ stops forcing ALL. It is not part of this change.

(h) The ledger is off on PR runs. The modules-floor call passes no ledger: input, and the lane defaults to off. So every push to a PR branch re-runs every selected module suite from scratch, even when a Tested record for the identical key exists. The fix is ledger: required plus ledger-token. Two facts shape it:

This change sits inside the modules-floor call that #1676 rewrites, so it was handed to that PR's owner as a comment (#1676) instead of being built beside it.

What changed: resolve-generated-conflicts.yml

Status. This page is the record; the lane's own code lands in #1678. It was split out so that the measurements, the method and tonight's drain events are not held behind an investigation into a delete-then-recreate access-grant race that #1678's run hit and its diff cannot reach.

On every push to main (a merge landing is what makes other pull requests conflict), and every three hours as a safety net, the lane reads every open pull request with GitHub's own text merge. It emulates that merge by running git merge-tree with attr.tree=<empty tree>, so even a clone with the local driver armed sees the conflict GitHub sees. It skips drafts (the opt-out, as for auto-arm), forks, and Dependabot's branches. For each pull request whose conflict set is non-empty and entirely */manifest.lock, it:

  1. merges main into the pull request's head in a throwaway worktree, with no hooks;
  2. regenerates the locks with the repo's own self-tested gen-manifests.py --resolve, which refuses if any source path is unmerged and stages every lock the regeneration moved;
  3. requires both gen-manifests.py --check copies to pass: this repo's, and the platform's canonical one that Validate node repos believes;
  4. pushes a plain fast-forward with the meshweaver-cloud App token, so CI and auto-arm fire on the new head. If the author pushed meanwhile, the push is refused and the next run re-reads the branch. It never force-pushes;
  5. writes one comment on the pull request, edited in place, saying what it did and that the author's local branch is now one merge behind.

A pull request with any source conflict is left untouched for its author. scripts/resolve-generated-conflicts.py --self-test runs in Repo policy gates on every PR and in the lane before it acts. It pins four things:

Verified on real history. Replayed over the 40 measured DIRTY states, the classifier saw all 40 (37 generated-only, 3 source). For comparison, the locally armed driver hid 37 of them. A full resolution of #1575's newest lock-only state (main@2026-09-11T17:17Z) produced a merge that passed both checkers.

The one gap: the App's Workflows grant

GitHub refuses a GitHub App push that brings .github/workflows/** changes onto a branch unless the App holds Workflows: write. The meshweaver-cloud installation holds only contents, metadata and pull_requests (gh api orgs/Systemorph/installations). So when main changed a workflow since the pull request's base, which was true for 13 of the 37 measured lock-only states, the push is refused. The lane reports needs-workflows-grant in its summary and in the PR comment, with the three local commands. The token mint deliberately does not ask for the grant: an installation that lacks it fails the mint outright, which would take down the other 24 cases too. See core's Doc/Architecture/PlatformRefBumpLane for the same refusal on another lane.

The governing rule: no job spans unrelated dependency networks

"we must not have any job going across the atomic unit", where the atomic unit is "all dependency patterns in repo". Roland, 2026-09-11, relayed by session meshweaver-plugins-a6.

The unit is therefore a dependency network: the changed units plus their transitive dependents. It is not a single package. A job may cover one whole network. It must not span two unrelated ones, because then a red in one network fails, or re-runs, the other. The compile gate is being reshaped to fit this: one job per affected network, with a receipt-count aggregate as the required context. That work is in flight by meshweaver-plugins-a6.

Read against this repo's lane, two job families span networks today. Both are recorded here as proposals only:

today spans proposal
Portal hosts (shard 0…3): the moved suites cut into 4 shards by suite time, not by network a shard mixes test projects from unrelated compile-tree networks, so a red shard names a slice, and a re-push re-runs projects whose network did not move. That is where the ~89 portal-host minutes in (g) came from. 🚨 And the build is repeated. Measured by session meshweaver-plugins-a6 on run 34626892604: only shard 0 runs the explicit build steps; shards 1–3 compile their slice's closure implicitly inside dotnet test (940–1150 s against shard 0's 748 s). A shared project therefore compiles up to four times in one run. CLAIMED by session meshweaver-plugins-a6, stacked on #1681 — not a proposal on this page. One leg per affected compiled network (the connected components of the ProjectReference graph over the project-scope selection), building only that closure and testing it --no-build; receipts plus the existing Build + test the portal hosts aggregate name; zero legs on a node-only PR; every network on push and dispatch
Module bundles / Build the workspace (one global build) one build compiles every selected container entry, so one network's failure fails all of them. On 09-11 one artifact ETIMEDOUT on #1661 took down this job and, through needs:, both gates behind it one build per network
test-repos / Gate shard 1/1 on a release-follow (repository_dispatch) run the whole catalog, all 61 packages, is gated in one shard. The same job takes ~10 min on a PR gating one package. On run 34631758615 (24c2d024) the leg took 19 min and the run finished success at 20:18Z. It is not a stuck job: the "in progress since 18:10Z" reading earlier was queue time, not run time one gate leg per network on release-follow runs, with a receipt-count aggregate

Neither of this change set's PRs restructures these jobs, and none adds a job that spans networks. The resolver lane is one job, but it builds and tests nothing and produces no verdict about any network. It acts on each pull request independently and reports each on its own line.

Timing baseline (2026-09-11)

"once we have atomic build jobs, let's measure timings." — Roland, 2026-09-11. "Atomic" means one job per dependency network. This is the before, taken while today's job shapes still run, with the exact instrument recorded below so that the after is the same measurement.

Window: Plugin Catalog CI runs created 2026-09-08T19:00Z → 2026-09-11T19:00Z (72 h). This is fixed, so the drain waves that began after 19:00Z are not in it.

Denominator: 597 runs were created in the window. 223 are kept: 199 pull_request runs and 24 pushes to main, each concluding success or failure. Excluded:

excluded count
pull_request / push runs cancelled 157
startup_failure: 14 PR runs and 1 push. All 15 were created 18:59–22:40Z on 09-10, inside the core-lane window (#3933 → #3968) 15
still running when read 1
release-lane and schedule runs (repository_dispatch 198, schedule 3) 201
kept runs whose jobs could not be fetched 0 (two server errors during the pull each recovered on the first retry)

Cross-check. Session meshweaver-plugins-a6 measured one run independently: main run 34626892604, per job and per step. This instrument gives the same answer on it:

peer this instrument
non-skipped jobs 68 68
runner-minutes 379 378.1
module-bundle legs 37, sum 211, min 0.9, max 16.7 37, sum 210.4, min 0.9, max 16.7
portal-host shards 36.7 / 16.8 / 21.3 (+ ~21.2) 36.7 / 16.8 / 21.3 / 21.2
workspace build · compile gate · test-repos gate shard 7.7 · 4.9 · 9.7 7.7 · 4.9 · 9.7

What the baseline says

The numbers

per run pull_request (N=199) push (N=24)
queue wait, min (median / p90) 0.2 / 1.7 0.1 / 0.1
wall clock, min (median / p90) 37.9 / 53.8 50.6 / 64.3
runner-minutes (median / p90) 159 / 358.8 349.9 / 373.4
runner-minutes per job family: median / p90 over the runs where it ran (n) · total pull_request push
portal-hosts 90.6 / 97.5 (n=199) · 15457.3 93 / 95.6 (n=24) · 2187.6
workspace-build 7.8 / 13.7 (n=184) · 1704.5 13.2 / 13.8 (n=23) · 280.2
compile-check 5.1 / 5.5 (n=199) · 895.3 5 / 5.2 (n=24) · 113
module-tests 152.1 / 182.8 (n=164) · 17393.4
module-bundles 20.9 / 40.9 (n=180) · 3860.9 206.4 / 214.3 (n=23) · 4710.8
test-repos 7.2 / 11.5 (n=199) · 1531.3 10.1 / 18 (n=24) · 289.1
validate 0.5 / 0.6 (n=199) · 93.4 0.5 / 0.6 (n=24) · 12.1
publish-bake 11.1 / 17.2 (n=18) · 247.1
other 12.6 / 18.4 (n=199) · 2614.4 13.4 / 21.1 (n=24) · 362.8
units reached per run: median / p90 pull_request push
module bundles built 7 / 37 37 / 37
module suites as their own job 5 / 37 0 / 0
portal-host shards 4 / 4 4 / 4
module suite (PR runs, own job) n median p90 total
MeshWeaver.AI 113 14.6 15.8 1562.5
MeshWeaver.SelfUpdate.Aks 130 7.9 8.5 990.1
MeshWeaver.Observability 100 9 9.7 875.3
MeshWeaver.Markdown.Export 99 9.2 9.9 869.5
MeshWeaver.Mcp 105 8.2 9 834.9
MeshWeaver.Mail.MicrosoftGraph 99 7.9 8.6 748.9
MeshWeaver.Blazor.Chat 102 7.7 8.4 747
MeshWeaver.AI.Copilot 98 7.8 8.4 732
MeshWeaver.AI.OpenAI 98 7.8 8.5 731.5
MeshWeaver.Notifications.Channels 98 7.8 8.3 729.4
MeshWeaver.AI.AppleIntelligence 98 7.6 8.3 724.7
MeshWeaver.AI.Anthropic 98 7.7 8.5 722.1

(38 module suites in all; the 12 largest by total runner-minutes shown.)

Per pull request (118 PRs; 199 of the 199 kept PR runs mapped to a PR by head branch; 0 run(s) unmapped): runner-minutes per PR median 291, p90 948, max 1427.1.

PR kept runs runner-minutes newest kept run reached (bundles / suites / shards) its conclusion
#1575 4 1427.1 37 / 37 / 4 success
#1635 4 1373.9 37 / 37 / 4 success
#1529 4 1141.4 37 / 37 / 4 success
#1664 3 1079.2 38 / 38 / 4 success
#1531 3 1079.1 37 / 37 / 4 success
#1560 3 1066.8 37 / 37 / 4 success
#1583 3 1059.8 37 / 37 / 4 success
#1646 3 1052 37 / 37 / 4 failure
#1581 3 1045.1 37 / 37 / 4 success
#1634 3 1029.2 37 / 37 / 4 success
#1576 3 1008.5 37 / 37 / 4 success
#1625 9 972.2 20 / 17 / 4 failure
#1622 3 948 29 / 26 / 4 success
#1601 3 855.3 37 / 37 / 4 success
#1614 3 790.3 37 / 37 / 4 success
#1613 2 733 37 / 37 / 4 success
#1603 2 721.8 37 / 37 / 4 success
#1670 2 719 37 / 37 / 4 success
#1572 2 696 37 / 37 / 4 success
#1642 2 695.1 37 / 37 / 4 success

(The 20 costliest of 118 shown.)

Drain event during the drafting of this page

Drain event: the undraft wave of 2026-09-11 evening. At Roland's request ("undraft a few", "let's drain"), session meshweaver-plugins-a6 undrafted and auto-armed Plugins drafts whose stated hold reason no longer applied. It worked in batches of three, grouped so that no two PRs in a batch touch the same manifest.lock, and spaced the waves while #4051 (the bake race fix) and module-load tolerance were undeployed. #1679, #1678, #1575 and #1536 were left alone. The wave raises runner load, and DIRTY churn is likely. The baseline window closes at 2026-09-11T19:00Z, before the wave began, so no baseline run is affected.

Method: re-run exactly this for the after-measurement

The window is fixed: runs created in 2026-09-08T19:00:00Z..2026-09-11T19:00:00Z (72 h). An after-measurement uses a window of the same length, ending at a stated timestamp. REST only, at most one call per second, no GraphQL.

W='2026-09-08T19:00:00Z..2026-09-11T19:00:00Z'      # the after-run: same length, new end
mkdir -p baseline/jobs
# 1. every Plugin Catalog CI run created in the window (workflow id 308964643 = ci.yml)
for page in $(seq 1 20); do
  gh api "repos/Systemorph/MeshWeaver.Plugins/actions/workflows/308964643/runs?per_page=100&page=$page&created=$W" \
    > "baseline/runs-$page.json"; sleep 1
  [ "$(jq '.workflow_runs | length' "baseline/runs-$page.json")" -lt 100 ] && break
done
jq -s '[.[].workflow_runs[]]' baseline/runs-*.json > baseline/runs.json
# 2. KEEP: pull_request, and push to main; conclusion success|failure. Everything else is counted
#    as excluded (cancelled, skipped, startup_failure, still running, release-lane events).
jq -r '.[] | select((.event=="pull_request" or (.event=="push" and .head_branch=="main"))
                    and (.conclusion=="success" or .conclusion=="failure")) | .id' baseline/runs.json > baseline/keep.txt
# 3. the LATEST attempt's jobs of every kept run (page 2 for runs over 100 jobs)
while read -r id; do
  gh api "repos/Systemorph/MeshWeaver.Plugins/actions/runs/$id/jobs?filter=latest&per_page=100" > baseline/j1.json; sleep 1
  if [ "$(jq .total_count baseline/j1.json)" -gt 100 ]; then
    gh api "repos/Systemorph/MeshWeaver.Plugins/actions/runs/$id/jobs?filter=latest&per_page=100&page=2" > baseline/j2.json; sleep 1
  else echo '{"jobs":[]}' > baseline/j2.json; fi
  jq -n --slurpfile r <(jq --argjson id "$id" '.[] | select(.id==$id)' baseline/runs.json) \
        --slurpfile a baseline/j1.json --slurpfile b baseline/j2.json \
        '{run: $r[0], jobs: ($a[0].jobs + $b[0].jobs)}' > "baseline/jobs/$id.json"
done < baseline/keep.txt
# 4. the PR list, to map a run to its PR by HEAD BRANCH: a run's pull_requests array is EMPTY once
#    its PR has merged or closed (measured: 161 of 199 kept PR runs carried no number)
for page in 1 2 3; do
  gh api "repos/Systemorph/MeshWeaver.Plugins/pulls?state=all&sort=created&direction=desc&per_page=100&page=$page" \
    > "baseline/pulls-$page.json"; sleep 1
done
jq -s '[.[][] | {number, ref: .head.ref, created_at, closed_at}]' baseline/pulls-*.json > baseline/pulls.json
#    (widen the page range until prs.jq reports unmapped_runs: 0)
# 5. aggregate: one row per run, then medians / p90 per event, then per PR
jq -s -f rows.jq baseline/jobs/*.json > baseline/rows.json
jq -f stats.jq baseline/rows.json
jq --slurpfile pulls baseline/pulls.json -f prs.jq baseline/rows.json

When GitHub answers with a server error. During the baseline pull (2026-09-11, about 19:58–20:09Z), three job-list calls failed, each on a different, older run and each after real progress: an HTTP 504, an HTTP/2 stream CANCEL and an HTTP 502. The policy applied, which the re-measurement must copy:

In the loop above, a failed gh api call is the point to apply this policy.

Definitions, as the jq below computes them:

rows.jq, with the job-name to family mapping as its first definition:

# One row per kept run. Input: `jq -s` over baseline/jobs/*.json, each {run, jobs}.
def family:
  if   test("^Portal hosts \\(shard")              then "portal-hosts"
  elif test("Build the workspace")                 then "workspace-build"
  elif . == "Compile every NodeType (vs core)"     then "compile-check"
  elif test("Module tests \\(")                    then "module-tests"
  elif test("Module bundle \\(")                   then "module-bundles"
  elif test("^test-repos / ")                      then "test-repos"
  elif test("Validate node repos$")                then "validate"
  elif test("^publish-bake / ")                    then "publish-bake"
  else "other" end;
def ts: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
def mins(a; b): ((b | ts) - (a | ts)) / 60;
[ .[] | . as $f
  | ($f.jobs | map(select(.started_at != null and .completed_at != null and .conclusion != "skipped"))) as $j
  | select(($j | length) > 0)
  | { id: $f.run.id, event: $f.run.event, conclusion: $f.run.conclusion, attempt: $f.run.run_attempt,
      created_at: $f.run.created_at,
      pr: ($f.run.pull_requests[0].number // null), branch: $f.run.head_branch,
      queue_wait_min: mins($f.run.run_started_at; ($j | map(.started_at) | min)),
      wall_min:       mins($f.run.run_started_at; ($j | map(.completed_at) | max)),
      runner_min:     ($j | map(mins(.started_at; .completed_at)) | add),
      family: ($j | group_by(.name | family)
                  | map({key: (.[0].name | family), value: (map(mins(.started_at; .completed_at)) | add)})
                  | from_entries),
      modules: ($j | map(select(.name | test("Module tests \\(")))
                   | map({key: (.name | capture("Module tests \\((?<m>[^)]+)\\)").m),
                          value: mins(.started_at; .completed_at)}) | from_entries),
      units: { bundles: ($j | map(select(.name | test("Module bundle \\("))) | length),
               suites:  ($j | map(select(.name | test("Module tests \\("))) | length),
               shards:  ($j | map(select(.name | test("^Portal hosts \\(shard"))) | length) } } ]

stats.jq:

# Medians and p90 (nearest-rank, lower: index floor((n-1)·p)) per event, from baseline_rows.jq's output.
def pct(p): sort | if length == 0 then null else .[((length - 1) * p | floor)] end;
def r1: if . == null then null else (. * 10 | round / 10) end;
def stat: map(select(. != null)) | {n: length, median: (pct(0.5) | r1), p90: (pct(0.9) | r1), total: (add // 0 | r1)};
["portal-hosts","workspace-build","compile-check","module-tests","module-bundles","test-repos","validate","publish-bake","other"] as $fams
| group_by(.event) | map(. as $rs | {
    event: $rs[0].event, runs: ($rs | length),
    queue_wait_min: ($rs | map(.queue_wait_min) | stat),
    wall_min:       ($rs | map(.wall_min) | stat),
    runner_min:     ($rs | map(.runner_min) | stat),
    families: ($fams | map(. as $f | {key: $f, value: ($rs | map(.family[$f]) | stat)}) | from_entries),
    module_tests: ($rs | map(.modules | to_entries[]) | group_by(.key)
                  | map({key: .[0].key, value: (map(.value) | stat)}) | from_entries),
    units: { bundles: ($rs | map(.units.bundles) | stat), suites: ($rs | map(.units.suites) | stat),
             shards: ($rs | map(.units.shards) | stat) } })

prs.jq:

# Per pull request: kept runs, total runner-minutes, and the units its NEWEST kept run reached
# (the selector's output materialised as jobs: module bundles built, module suites run, portal-host shards).
#
# 🚨 A run's `pull_requests` array is EMPTY once its PR has merged or closed, so it cannot identify
# the PR. Map by HEAD BRANCH instead, through the PR list (`--slurpfile pulls baseline/pulls.json`,
# built from `pulls?state=all`), choosing the PR on that branch that was open when the run was
# created. A run no PR accounts for is counted and printed, never dropped silently.
def r1: (. * 10 | round / 10);
def ts: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
($pulls[0]) as $P
| map(select(.event == "pull_request"))
| map(. as $r
      | ($P | map(select(.ref == $r.branch
                          and (.created_at | ts) <= ($r.created_at | ts)
                          and (.closed_at == null or (.closed_at | ts) >= ($r.created_at | ts))))
            | sort_by(.created_at) | last | .number // null) as $n
      | . + {pr: ($r.pr // $n)})
| (map(select(.pr == null)) | length) as $unmapped
| group_by(.pr)
| map(select(.[0].pr != null)
      | sort_by(.id)
      | {pr: .[0].pr, runs: length, runner_min: (map(.runner_min) | add | r1),
         last_units: (.[-1].units), last_conclusion: .[-1].conclusion})
| sort_by(-.runner_min)
| {unmapped_runs: $unmapped, prs: .}

When to measure the after, and how to mark what distorts it

Trigger — all of it on main, not part of it. Re-measure once both of these are on Plugins main:

  1. per-module deploy (#1676) — landed 02:37Z, 2026-09-12. It changes what a push run does and removes the cancellation, so the push column of the before table describes a pipeline that no longer exists;
  2. the per-network Portal hosts legs and compile gate (#1681, session meshweaver-plugins-a6) — still red in CI at the time of writing.

Then run the identical method above, from the same REST queries through the same three jq programs, over a window of the same length (72 h) ending at a stated timestamp, and add a before/after table with the same rows. Measuring in between describes a half-changed pipeline: state which parts had landed, because the portal hosts are 35 % of PR runner-minutes, the compile gate is ~2 %, and #1676 moves the push numbers rather than the pull_request ones.

Mark, don't average, anything that is not steady state.

Report queue wait and wall clock with and without the marked runs. A wave's queue wait is not the steady state, and a before/after that mixes the two measures the wave, not the job shape.

Proposed, for the maintainer to decide

Proposal 1: which platform set a pull request tests against

Today every run resolves the newest sealed core set (scripts/resolve-platform.py, see PlatformResolution.md). MW_PLATFORM_REF is an incident freeze, never a pin. So a sealed set that regresses a Plugins suite reds every open pull request that runs the shard, at once, until core ships the next set. Measured above: four such windows in 24 hours, 8.6 h in total, 91 PR-hours exposed, and nothing any author could do inside their own diff.

The options, each with the cost it moves:

option a regressing set reds … cost risk
A. status quo: newest sealed set on every run every open PR that runs the shard the 91 PR-hours above; authors re-run or wait none new; the regression is loud everywhere
B. PRs follow main: a PR resolves the newest sealed set that a successful main push run already passed on; main, the release dispatch, the schedule and the canary keep taking the newest sealed set main (and the canary) only: one place, one owner PRs lag core by about one main run (40–60 min). A Plugins half that needs a brand-new core API waits for main to pass a set carrying it, which is today's "wait for the seal" plus one run the resolver reads its own history, so it must go RED when it cannot read it, never fall back to "newest"; a PR merged on set S−1 is re-proven by main on S (same-MAJOR compatibility already holds)
C. canary-first: a set becomes PR-eligible once platform-canary.yml, triggered per seal instead of daily, has compared it green the canary only latency = one canary run; the canary covers a subset of suites, so a regression outside it still reaches PRs the canary becomes a gate on PR input; its evidence rules (#1620) then carry more weight
D. core seals only what Plugins passes: make core's Plugins seal decisive nothing in Plugins; core cannot seal pushes the cost to core's release cadence the circularity PlatformResolution.md records (2026-09-09): Plugins red ⇒ no seal ⇒ Plugins bakes for an identity no portal runs

Recommendation: B. It keeps "never pinned, never a literal". The resolved set is still computed per run, only from a different record. It turns "every PR red" into "main red", and a red main is already watched and owned. The lane class (a′) is not addressed by any option, because uses: cannot take an expression. That class is now gated in core by check-workflow-permission-pairing.py.

✅ DECIDED and IMPLEMENTED — option B (Roland, 2026-09-12)

A pull request resolves the newest sealed set that this repository's own main has already passed. main, the release dispatch and the daily poll are unchanged: they take the newest sealed set, which is what makes main the place a regression appears.

The failure mode is the point, not a side effect. When core seals a set that regresses this repo, main goes red on it and every open pull request keeps building on the last set main passed. One owner, one place. What it replaces is measured above: four such windows in 24 hours, 91 PR-hours exposed, and nothing any author could do inside their own diff.

How it works, in scripts/resolve-platform.py:

What it costs. A core fix is not available to pull requests until main has passed on the set carrying it — normally about one main run. A Plugins half that needs a brand-new core API therefore waits for the seal plus one main run. A pull request merged on set S−1 is re-proven by main on S, and same-MAJOR compatibility already holds (PlatformResolution.md).

The resolver's self-test carries the rule in both directions, including the two cases that must go RED: a sealed set main has not passed is passed over and the lag names both sets; the newest set is taken with no lag once main has passed it; a freeze overrides the ceiling; and an unestablishable ceiling refuses. Disabling the ceiling check turns the first case red, which is what makes it a test rather than a decoration.

Proposal 2: grant Workflows: write to the meshweaver-cloud App

This closes the 13-of-37 gap above with no code change. It is an org-admin action on the App's settings, and the new permission must be accepted on the installation. The trade-off: any lane that mints this App's token can then edit workflow files. Today that means auto-arm and this resolver, both of which act only on same-repo branches.

Proposal 3: fix it upstream of the lane — the lock itself

🚨 The resolver lane is a STOPGAP, and now a stopgap with a named root cause (see the root cause, named, above): the repository declares a merge driver that only exists where someone installed it, and GitHub — the one place mergeability is decided — never has it. The lane removes the wait on main. It does not remove the conflict. Two durable fixes sit upstream of it, and neither was built tonight.

Option A — make the generated content merge cleanly under a plain text merge. Then GitHub's own merge does what a configured clone does, and the driver becomes unnecessary rather than load-bearing. It means: deterministic ordering, one fact per line, and no field that every change rewrites. Today three trailer lines do exactly that — moduleVersion, sourceCommit and version — so any two pull requests touching one module collide on them whatever else they touched. sourceCommit is informational; moduleVersion is a pure function of the files map beside it. A lock carrying only files plus version merges line-wise whenever two PRs touch different files. Cost: a cross-repo schema change — core's canonical .github/scripts/gen-manifests.py is the verdict Validate node repos believes, scripts/tag-modules.py and the trunk-baseline derivation read moduleVersion, and a runtime consumer reads sourceCommit (src/MeshWeaver.PluginCatalog/ModuleManifest.cs). Core first, then every satellite.

Option B — stop committing what every merge regenerates. The lock is derived from the tree, and gen-manifests.py can recompute it at any time; a file that is a pure function of its inputs does not have to be a tracked file. Derive it in CI and at pack time, and the conflict class disappears with the committed content. Cost: the committed lock is currently the second witness the version derivation rests on (the trunk's committed lock beside the published tag — the thing that stops two branches computing the same released number for different trees), and installers diff it cheaply. Removing it needs that witness rebuilt somewhere else first, which is a larger design change than Option A and touches the release path rather than only the schema.

Both are maintainer calls. Until one lands, the lane is what stands between the fleet and the churn measured on this page.

Proposal 4: a per-project unit key and Tested record for compiled suites

The module ledger lets a re-push skip a bundle whose inputs did not move. The compiled test projects and the portal-host shards have no equivalent, so the ~89 portal-host minutes in (g) would recur on every re-push that reaches them. The design question is the key: what a compiled test project's verdict is a function of, meaning its project closure, the resolved platform set, the tester image and the slice. It sits in the same place as #1676's per-module publication, so it belongs with that owner. Nothing is built here.

Left alone, on purpose

Session meshweaver-plugins-85 rewrote the per-module publish path in #1676: modules-floor, publish-modules, publish-newest-only and Hosting/BuildReleaseSeparation.md. Nothing here touches them. The lane's own change adds one self-test step to Repo policy gates, far from #1676's hunks.

✅ The publish starvation — measured here, fixed by #1676 (merged 02:37Z, 2026-09-12)

The measurement stays because it is the evidence for why the change was needed: 78 main push runs were cancelled between 09-08 and 09-11, and in the window this page measures nothing published between 17:17Z and 23:00Z, because every main run was cancelled by the next merge before it could publish. The clearest casualty was the WhatsApp module (#1664): merged, and then served by nobody.

From #1676's commit on main:

This is the same rule as the rest of this page, applied to publication rather than to gating: a unit is a dependency network, and one network's failure must not decide another's.

Method

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.