Public: anyone can read this; the crew can change it.
When the forge's own lag explains most of an app's wait, the app's page does not call the app slow #10882
Check · Review · Land · Live done — Landed and live in production.
When the forge's own event-loop lag explains more than half of an app's wait (`forge-ttfb`), the app's speed card and terminal line now say the forge was busy, not the app. The decision is made by calling `forgeWaited`; the rule is not copied.
shipped production is serving it deploy 3a0780e
When the forge's own event-loop lag explains more than half of an app's wait (forge-ttfb), the app's speed card and terminal line now say the forge was busy, not the app. The decision is made by calling forgeWaited; the rule is not copied.
Where the reading comes from. The visitor beacon only sends browser timings, so it cannot say whose wait it was. The forge can: when it relays an answer it knows the wait and its own lag. relayUpstream in src/apps/proxy.ts passes both to noteRelayWait for production answers. The new module src/apps/relay_wait.ts keeps each app's slowest wait of the last 10 minutes in memory. Each update is a single comparison, at most 2,000 apps are held (stalest dropped first), and the readings are lost on restart. This shared in-memory state is noted in AGENTS.md.
Where it shows. appVitals adds an optional wait field, and three places word it with relayWaitWords:
- the "Visitors and speed" card on the app's settings page, as an escaped
<p>; - the terminal's
visitorsLine; - the app API's
vitalsJSON, which gets the field as plain data.
The forge's case reads "the forge was busy, not the app: its own loop lagged 600 ms of the 820 ms to the app's first byte". Otherwise it reads "1.5 s to the app's first byte, the app's own time (the forge lagged 40 ms)".
What to look at.
test/relay-wait.test.tsfeeds one reading the forge explains and one it doesn't, and checks the wording of each. It also checks that the apostrophe is escaped on the page, that exactly half counts as the app's, that the window expires, that bad readings are refused, and that the app cap holds.- In
test/apps-proxy-timing.test.ts, the existing "blocked forge loop" test now also checks that a real relayed request reachesappVisitorsand thatvisitorsLinesays the forge was busy.
Not done.
- The beacon's stored p75 TTFB is not reworded. The forge has no lag reading for those visits, and adding one would need a beacon and schema change.
- Previews are not tracked, because the card counts production only.
- I couldn't keep a plan on the issue: no
keep_plantool was available in this run.
Checks. bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. bun scripts/fast.ts named 1,896 test files, too many to run here, so I ran the ones covering the changed files, and all passed: relay-wait, apps-proxy-timing, app-vitals-days, apps-measure, loop-worst, settings-app-domains, tui-deploys and api-app-open-timing. The push hook asked for a commit starting with Try:, so the branch ends with an empty one.
To try it. Slow a production app's answer while the forge's loop is blocked, then open the repository's Settings → App. "Visitors and speed" shows "Slowest wait in the last 10 minutes: the forge was busy, not the app…".
🤖 Generated with Claude Code
The rule is called, not copied, and the wording and tests are good. Four things to fix in one round.
- Direction: the owner said pipeline first, before any feature. This is a feature that is not on the pipeline list. Either explain in the pull why it cannot wait, or hold it behind the pipeline items.
- Both doors / docs: the terminal line is there, but there is no docs/features/ how-to for the new wait line. Add docs/features/<name>.md, or extend the existing speed-card doc. AGENTS.md is not a how-to.
- Tests: the description says the app API vitals JSON gets the field, but no test pins it. Add one that fails without the field. Also, the Try: empty commit is noise; drop it if the hook allows.
- Design (principle 1, nothing explains itself twice): the settings paragraph and the terminal line each print a long sentence that repeats the numbers (lag and ttfb, and the app lag again in the else branch). Say it once in plain words, e.g. "The forge was busy: 600 ms of the 820 ms wait."
An app's production speed card now shows the slowest wait the forge relayed to it in the last 10 minutes, and says whose wait it was. If the forge's own loop lag (forge-lag) is more than half of forge-ttfb, forgeWaited (in src/perf/loop_worst.ts) puts the wait on the forge. That rule had no caller before; this pull calls it and does not copy it.
What it shows. Each reading is one sentence: the forge was busy: 600 ms of the 820 ms wait, or 820 ms, the app's own.
- Page: Settings → App → Visitors and speed gets a "Slowest wait, 10 min" row in the
dl, beside the p75 figures. There is no separate paragraph. - Terminal: the visitors line ends with
· slowest wait, 10 min: …. - API:
GET /api/v1/repos/:owner/:name/appgives the app's adminsvitals.wait: {ttfbMs, lagMs, at, forge}.
Where it lives. Readings are kept in process memory (src/apps/relay_wait.ts): production only, at most 2,000 apps, lost on restart. The proxy's relayUpstream records them and appVitals reads them.
Changes since review:
- Wording: each reading is said once, with no repeated numbers, in the
dlrow (principles 1, 7 and 8). - API test:
test/apps-measure.test.tsnow pinsvitals.waiton the API JSON, and fails if the field is missing. - How-to:
docs/features/app-slowest-wait.mdcovers the page, the terminal and the API.
Not done:
- Pipeline first: I did not hold this behind the pipeline items. #10858 is labelled P0 Trust and is the box #10838 left open. Without it,
forgeWaitedhas no caller, and an owner reading an app's timings blames the app for the forge's lag. The change is small and only touches the speed card. - The empty
Try:commit stays. It is already pushed, and removing it needs a force push, which is refused here.
Checks run:
bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tspass.scripts/fast.tsnamed almost the whole suite becausesettings.tsreaches most pages, so I picked the 14 files that cover the speed card, the terminal line, the API and the docs myself. All 134 tests in them pass.
To try it: keep the forge busy with a long synchronous task, load a production app, then open its repository's Settings → App within 10 minutes. Or read vitals.wait from the app API.
🤖 Generated with Claude Code
Clean. forgeWaited is called from relay_wait.ts, not copied. The wording is said once, in a dl row, escaped, and both doors carry it (page row and terminal visitors line). The API test pins vitals.wait, and the how-to is in docs/features, not the README. The relay path is a single compare, bounded at 2,000 apps, production only, with no client-written input. Direction: this is not a pipeline item, but it is the P0 Trust box left open by #10838 and is small, so I let it land. The empty Try: commit is noise only.
When the forge's own event-loop lag explains more than half of an app's wait (
forge-ttfb), the app's speed card and terminal line now say the forge was busy, not the app. The decision is made by callingforgeWaited; the rule is not copied.Where the reading comes from. The visitor beacon only sends browser timings, so it cannot say whose wait it was. The forge can: when it relays an answer it knows the wait and its own lag.
relayUpstreaminsrc/apps/proxy.tspasses both tonoteRelayWaitfor production answers. The new modulesrc/apps/relay_wait.tskeeps each app's slowest wait of the last 10 minutes in memory. Each update is a single comparison, at most 2,000 apps are held (stalest dropped first), and the readings are lost on restart. This shared in-memory state is noted inAGENTS.md.Where it shows.
appVitalsadds an optionalwaitfield, and three places word it withrelayWaitWords:<p>;visitorsLine;vitalsJSON, which gets the field as plain data.The forge's case reads "the forge was busy, not the app: its own loop lagged 600 ms of the 820 ms to the app's first byte". Otherwise it reads "1.5 s to the app's first byte, the app's own time (the forge lagged 40 ms)".
What to look at.
test/relay-wait.test.tsfeeds one reading the forge explains and one it doesn't, and checks the wording of each. It also checks that the apostrophe is escaped on the page, that exactly half counts as the app's, that the window expires, that bad readings are refused, and that the app cap holds.test/apps-proxy-timing.test.ts, the existing "blocked forge loop" test now also checks that a real relayed request reachesappVisitorsand thatvisitorsLinesays the forge was busy.Not done.
keep_plantool was available in this run.Checks.
bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tspass.bun scripts/fast.tsnamed 1,896 test files, too many to run here, so I ran the ones covering the changed files, and all passed: relay-wait, apps-proxy-timing, app-vitals-days, apps-measure, loop-worst, settings-app-domains, tui-deploys and api-app-open-timing. The push hook asked for a commit starting withTry:, so the branch ends with an empty one.To try it. Slow a production app's answer while the forge's loop is blocked, then open the repository's Settings → App. "Visitors and speed" shows "Slowest wait in the last 10 minutes: the forge was busy, not the app…".
🤖 Generated with Claude Code
Closes #10858