Files
PaperClipAI/server
DottaandClaude 855e241887 fix(server): record a long event-loop block with the deltas that name its cause (#20709)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - One server process owns the controller leases of its runs, renews
them on timers, and serves every request
> - On a hosted instance, the process stopped for about 103 seconds
without a restart. Every lease lapsed, and two runs needed a manual
Retry (the symptom is fixed in #20694)
> - Nobody can say what blocked the process. The server records no
event-loop delay, and it has no CPU, GC or memory record for that minute
> - This pull request logs one local line for each event-loop block of 2
s or more. The line holds the deltas that tell the possible causes
apart, and a likely-cause class
> - The benefit is that the next stall names its class (main-thread CPU,
GC, paging, CPU throttling, host suspend, or a blocking sync call) and
its exact time, so we can match it to the run timeline and fix the cause

## Linked Issues or Issue Description

Refs #20694

**What happened?**

On a hosted instance, the merged run-event timeline of all active runs
had no events from 08:19:52.04Z to 08:21:35.37Z (103.3 s). Events from
streaming runs arrived about every 0.5 s before and after the gap. The
process did not restart (the run diagnostic shows an observer uptime of
about 24 minutes when the reaper ran). The first event after the gap was
a span with a 60 s timeout that ended after 103.5 s. Thus the timer
callback ran about 43 s late. This agrees with a blocked event loop, or
with a frozen process. Nothing in the process recorded which one.

**Expected behavior**

A long event-loop block is a product failure. It lapses every lease and
freezes every user. The server must record each long block with enough
data to identify its cause.

**Steps to reproduce**

1. Start the server.
2. Block the main thread for more than 2 s, for example with a
synchronous read on a slow disk or a busy loop.
3. Before this change: the logs show nothing. After this change: the
logs show `event loop blocked for <n> ms (likely <cause>)` with the
measured deltas.

## What Changed

- `server/src/event-loop-stall-monitor.ts` (new): one unref'd 1.5 s
timeout, re-armed after each check, measures how late it runs. (Server
startup creates no interval timers, so it is not a `setInterval`.) When
a check is 2 s or more late, it logs once with these fields:
  - stall duration, start and end time (wall clock and monotonic clock)
- process CPU (user and system), GC pause time and count (`perf_hooks`
GC entries)
- major and minor page faults, file-system reads and writes, voluntary
and involuntary context switches (`process.resourceUsage()`)
- cgroup v2 `throttled_usec` delta (Linux; read asynchronously, best
effort)
  - RSS, heap and external memory
- `likelyCause`: `host_suspended`, `gc`, `memory_paging`,
`cpu_throttled`, `main_thread_cpu`, or `blocked_off_cpu` (sync file I/O,
a sync child process, or another off-CPU wait)
- `server/src/index.ts`: starts the monitor when the listener binds. It
stops the monitor at the start of shutdown, and when a failed startup
closes the listener. It writes the line with the existing `logger.warn`.
- This is a local log line only. It is not the Telemetry path and not
the Observability (OpenTelemetry) path. No data leaves the process.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/event-loop-stall-monitor.test.ts
src/__tests__/server-startup-feedback-export.test.ts` (131 passed; the
monitor file passed 5 runs in a row):
- an 800 ms busy loop is recorded as `main_thread_cpu`, with the stall
and CPU time
- an 800 ms `Atomics.wait` (blocks without CPU, like a sync read on a
stalled disk) is recorded as `blocked_off_cpu`
- nothing is logged while the loop is responsive, or after `stop()`
(injected clock)
- the file-read delta of a stall is kept while the cgroup read is
pending
  - server startup still creates no interval timer
- a 100 s stall with 90 s of cgroup throttling is recorded as
`cpu_throttled`, with the stall start and end times
  - each cause class is selected from its deltas
- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.
- CI supplies the broad server suites.

## Risks

- Low risk. The timeout reads only process counters. Every 10 s it reads
one small cgroup file asynchronously. It does not query the database or
the network, so it does not keep an idle instance or its database awake.
It is unref'd and stopped on shutdown.
- `process.cpuUsage()` counts all threads of the process, not only the
main thread. A busy libuv thread pool during a stall can make an off-CPU
block look like `main_thread_cpu`. The raw deltas are in the line, so a
reader can check this.
- If a platform suspends and resumes the process, each resume can log
one `host_suspended` line. This is expected and useful.
- The monitor names the class of a block, not the call stack. If the
class is not enough, the next step is a bounded CPU profile around the
stall time.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude
Code with tool use, shell, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (the
module comment carries the contract)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 22:19:13 -06:00
..