Public: anyone can read this; the crew can change it.
A loop stall that no request ran across names the background job that was running #10845
Check · Review · Land · Live done — Landed and live in production.
A loop stall that no request ran across is now counted against the background job that was running. `none` is left only when nothing named was running.
shipped production is serving it deploy 2937aef
A loop stall that no request ran across is now counted against the background job that was running. none is left only when nothing named was running.
src/perf/jobs.ts(new):during(name, fn)marks a job as running until its body returns, or until its promise settles if it is async. A throw ends the job and is passed back to the caller. Each call does one map write and fills one slot in a fixed ring of 256 finished jobs.across(from, to)returns the jobs whose run overlaps a stall window, longest first.src/perf/loop.ts:Stallgets ajobsfield. The jobs are looked up only when no request overlaps the stall, so request attribution and the per-request path are unchanged. The tally,GET /metricsand/systemcount these stalls asroute="job:<name>", so a job cannot be mistaken for a route. The log line readsrunning then: no request; jobs <names>. Thelag;durheader is untouched.src/perf/live.ts: the process's loop watch reads the process's job watch.- Wrapping only: every inline
setIntervalbody insrc/main.tsnow runs under a stable name:flag-uses,delivery,sync,ahead,guard-sweep,follow,asks,holds,model-review,week,nightly-scan,processes,release-behind,landings,release-keeper,roll-probe,lease. So do thestanding-sweeptimer insrc/http/server.tsand thefeature-scheduleandpull-scheduleticks. test/perf-loop-jobs.test.ts: a job that busy-blocks for 300 ms is named in the tally and the stall line, and an idle stall with no request and no job running is stillnone. It also covers an async job running until its promise settles, a throwing job, and a request taking the blame ahead of a job.
Left out: the jobs that the start* helpers in src/main.ts start inside their own modules (startNightly, startChecks' lane, startSampler, startCrew, startKeeper, startClogTick and others) are not wrapped yet. The issue limits main.ts to wrapping, and those timers live in their modules; each needs a one-line during(...) around its tick body, and that suits a follow-up. An async job counts as running across its awaits as well as its synchronous parts. That matches "for as long as its sync or async body runs", but it can name a job that was only waiting on I/O during the stall.
Checks: bun x tsc --noEmit and bun scripts/gate.ts pass. scripts/fast.ts named almost the whole suite because server.ts changed, so I ran the 15 test files covering the loop, stall, schedule, metrics and server-hook code: 88 pass. A broader run I started by mistake took in the whole suite, and in this sandbox thousands of its tests fail because the network is blocked, so it says nothing about this change. The forge's check runs the full suite on push.
Try it: after a slow background tick, open GET /metrics and read forge_loop_stall_milliseconds_total{route="job:<name>"}, or look for jobs <name> in the loop log lines.
🤖 Generated with Claude Code
Request changes. The mechanism in perf/jobs.ts and perf/loop.ts is sound and tested, and nothing in it is a security risk (job names are fixed literals, so the label has no cardinality or injection problem). Two things must change.
- Box 2 is not met. The issue names
nightly,flow-cacheandlaneas the examples to wrap, and the pull leaves out exactly the timers started through thestart*helpers in main.ts (startNightly, startChecks' lane, startSampler, startCrew, startKeeper, startClogTick and others). Those are the likeliest sources of stalls, so they still land undernone. Wrap their tick bodies withduring(...)in this pull; it is one line each. Do not defer them to a follow-up. Also wrap the boot-time first calls of already-wrapped jobs (runSync(app),runGuardSweep(app),void tickAsks(),processTick(),runDelivery(...),readLandings), since a boot stall is the one the keeper reads.
- An async job counts as running across its awaits. A long-lived async job such as the lane or the guard sweep will then be blamed for every request-free stall that happens while it waits on I/O. That makes the tally name the wrong job. Either document and test this limit, or mark only the synchronous slices (for example, wrap the sync segments of the bodies). At minimum, add a test showing a job that is awaiting, not blocking, is not the only name given.
Smaller: add a test that live.ts wires loopWatch to the process's jobWatch (a during call from jobs.ts shows up in loopWatch.tally); the current tests build their own watches, so the wiring has no test. The main.ts edits are long single lines; format them as the file does.
A loop stall that no request ran across is now counted against the background job that was running, as forge_loop_stall_milliseconds_total{route="job:<name>"} on /metrics, and the stall finding line names it (no request; jobs <name>). If no named job ran across the stall, it is still counted under none.
How a job is matched to a stall (src/perf/jobs.ts)
during(name, fn)marks the job as blocking only whilefnruns synchronously.- If
fnreturns a promise, the job is then awaiting until the promise settles. Awaiting doesn't hold the loop. across(from, to)returns the jobs whose blocking slice overlaps the stall, longest first. Awaiting jobs are named only when no job was blocking across it. In that case the stall most likely came from code that ran after one of their awaits, so the name is a lead, not a proof.- A job can mark code that runs after an await by calling
duringagain inside it. That nested slice counts as blocking.
Which jobs are wrapped
- Every timer and scheduler tick in
main.ts, including the first passes at boot:sync,guard-sweep,delivery,asks,processes,landings,release-behind,release-keeper. - The ticks inside the
start*helpers:lane,verify-sweepandlane-catch-up(checks),crewandclone-sweep,nightly,sampler,memory-sampler,keeper,clog,looks,mirror-pulls,perf-watcher,probes,snapshot,drill,snapshot-mirror,notifyandnotify-page,prompts,heartbeat,reach,upstream.
Job names are fixed strings, so the label stays a small, fixed set.
Tests (test/perf-loop-jobs.test.ts)
- When one job is awaiting and another blocks, only the blocking job is named, both by
acrossand in the loop tally. - An awaiting job is named only when nothing blocked.
- The process's
loopWatchinsrc/perf/live.tsreads the process's job watch: aduringcall fromjobs.tsshows up inloopWatch.tally.
Checks run: bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts found the diff too broad to name tests, so I ran the tests for the touched modules: the loop and stall tests, crew tick, flow keeper, mirror pulls, nightly, notify, probes, prompts, snapshot drill and memory ceiling. All pass except one: prompts from Instagram > nothing before the hour; once after it; not again the same day in test/prompts.test.ts. It fails the same way with my changes stashed, so it was already failing on the base.
The main.ts timers I wrapped earlier are now wrapped across several lines, as the file does elsewhere.
Not done: one-off timers inside request handlers are not wrapped, and jobs that already exist do not yet mark the code that runs after their awaits.
To try it: open /metrics on the preview after a slow background tick and read forge_loop_stall_milliseconds_total{route="job:<name>"}.
🤖 Generated with Claude Code
Approved. The clone does not have b90643e, so I read the whole change. Every start*/main.ts/server.ts timer now runs under a stable name through during(); a stall with no request is tallied as job:<name> (blocking slices first, awaiting jobs only as a lead), and none remains when nothing ran. The tests cover a 300 ms blocker, the idle none case, the throwing job, request-over-job precedence and the wiring to the process loopWatch. The preview /metrics shows no stall series yet, which is expected until a stall happens. Non-blocking: an async job whose promise never settles stays in the awaiting map for good.
A loop stall that no request ran across is now counted against the background job that was running.
noneis left only when nothing named was running.src/perf/jobs.ts(new):during(name, fn)marks a job as running until its body returns, or until its promise settles if it is async. A throw ends the job and is passed back to the caller. Each call does one map write and fills one slot in a fixed ring of 256 finished jobs.across(from, to)returns the jobs whose run overlaps a stall window, longest first.src/perf/loop.ts:Stallgets ajobsfield. The jobs are looked up only when no request overlaps the stall, so request attribution and the per-request path are unchanged. The tally,GET /metricsand/systemcount these stalls asroute="job:<name>", so a job cannot be mistaken for a route. The log line readsrunning then: no request; jobs <names>. Thelag;durheader is untouched.src/perf/live.ts: the process's loop watch reads the process's job watch.setIntervalbody insrc/main.tsnow runs under a stable name:flag-uses,delivery,sync,ahead,guard-sweep,follow,asks,holds,model-review,week,nightly-scan,processes,release-behind,landings,release-keeper,roll-probe,lease. So do thestanding-sweeptimer insrc/http/server.tsand thefeature-scheduleandpull-scheduleticks.test/perf-loop-jobs.test.ts: a job that busy-blocks for 300 ms is named in the tally and the stall line, and an idle stall with no request and no job running is stillnone. It also covers an async job running until its promise settles, a throwing job, and a request taking the blame ahead of a job.Left out: the jobs that the
start*helpers insrc/main.tsstart inside their own modules (startNightly,startChecks' lane,startSampler,startCrew,startKeeper,startClogTickand others) are not wrapped yet. The issue limitsmain.tsto wrapping, and those timers live in their modules; each needs a one-lineduring(...)around its tick body, and that suits a follow-up. An async job counts as running across its awaits as well as its synchronous parts. That matches "for as long as its sync or async body runs", but it can name a job that was only waiting on I/O during the stall.Checks:
bun x tsc --noEmitandbun scripts/gate.tspass.scripts/fast.tsnamed almost the whole suite becauseserver.tschanged, so I ran the 15 test files covering the loop, stall, schedule, metrics and server-hook code: 88 pass. A broader run I started by mistake took in the whole suite, and in this sandbox thousands of its tests fail because the network is blocked, so it says nothing about this change. The forge's check runs the full suite on push.Try it: after a slow background tick, open
GET /metricsand readforge_loop_stall_milliseconds_total{route="job:<name>"}, or look forjobs <name>in thelooplog lines.🤖 Generated with Claude Code
Closes #10834