Skip to content

Report activity to the platform's local activity socket - #538

Merged
pufit merged 9 commits into
pufit/cron-forecast-schedulefrom
pufit/guest-activity-reporter
Oct 21, 2026
Merged

pufit merged 9 commits into
pufit/cron-forecast-schedulefrom
pufit/guest-activity-reporter

Conversation

@pufit

@pufit pufit commented Oct 21, 2026

Copy link
Copy Markdown
Member

Report activity to the platform's local activity socket

Stacked on #537 (gate forecasts). A platform that parks idle machines may expose a local Unix socket where the application reports what it is doing. When that socket exists, Nerve now keeps it current; otherwise nothing changes.

  • POST /v1/activity {runningTurns, activeSessions, nextScheduledAt, nextScheduledName} on every change (at most once per 250 ms; a turn start is reported promptly) and every platform.activity_interval_seconds (30 s): running turns (sessions + active workflow runs, counted once), connected web clients, and the next tick from CronService.next_scheduled() (cached; refreshed on schedule changes, turn end and the periodic report).
  • Keep-awake leases (POST/DELETE /v1/leases, renewed at half-life, retried before they can lapse) while background work that is not a turn is in flight: memorization, workspace sync, backup, live background agents. Documented coverage in docs/platform-activity.md.
  • Compatibility: task names are sanitised to [A-Za-z0-9._:-]{0,64}; a socket that answers 400 gets one retry without the task name and is then served without it; errors never reach callers or stop the gateway.
  • Config section platform: (activity_socket, activity_interval_seconds, lease_ttl_seconds); activity_socket: "" turns reporting off; nerve doctor shows whether the socket is present.

Tests: 62 new (fake Unix-socket server: payloads, counts, leases, coalescing, 400 fallback, a run-later wakeup reported after the last client disconnects, inert without the socket). Full suite 6,773 passed, 38 skipped at the head of the stack; ruff unchanged.

Generated by Nerve

🤖 Generated with Claude Code

pufit added 9 commits October 21, 2026 01:31
A platform that parks idle machines needs to know, from inside the
machine, whether Nerve is busy and when it next has scheduled work. When
platform.activity_socket (a Unix socket, default /run/nerve/guestd.sock)
exists at startup, the gateway now keeps it current:

- POST /v1/activity with runningTurns (session turns plus queued or
  draining workflow runs, each counted once), activeSessions (open /ws
  clients) and the next scheduled task from the cron service. Sent on
  every change (turn start/end, client connect/leave, schedule change,
  workflow run finished), at most once every 250 ms, and every
  platform.activity_interval_seconds. A turn that starts and ends
  between two reports is still reported once.
- Keep-awake leases (POST/DELETE /v1/leases, renewed every half
  platform.lease_ttl_seconds): nerve-memorize, nerve-config-sync,
  nerve-backup through keep_awake(), and nerve-background while any
  session has a live background agent.

Requests are best effort: failures are logged once at INFO, then at
DEBUG, and never raised. A socket that refuses nextScheduledName gets
reports without it. Without the socket nothing is sent; an empty
platform.activity_socket switches reporting off. SessionManager gains
running_count() and running listeners, the agent registry any_live()
and live listeners. nerve doctor shows whether the socket is present.
The reporter now calls CronService.next_scheduled() and its schedule
listeners directly; it stays inert about the schedule only when the
cron service did not start. A task name is cleaned the way the service
names ticks (other characters become "_", cut at 64), so the socket
never refuses a report for it, and a socket that does not accept task
names gets times only, said once at INFO.

Tests run the reporter against a real CronService: its forecast is
reported, and a finished run is followed at once by a report that
names the task after it.
Config loading keeps platform.activity_interval_seconds and
platform.lease_ttl_seconds as given, like every other setting; the
reporter holds them to 5-300 s and 10-3600 s when it is built and says
so in a warning.

A test drives the reporter with the real engine: its session turns and
a background task reach the socket as a report and the nerve-background
lease. The docs say that a background task that never ends keeps the
machine awake until it is stopped.
The reporter asked the cron service for the next scheduled task before
every report, so a slow gate forecast or a busy database held up the
report of a turn start for up to five seconds. The forecast is now kept
and made again only when it may have changed: when the schedule changes,
when a turn ends (the turn may have scheduled a wakeup) and for the
periodic report. It runs beside the loop. A turn start goes out at once
with the last forecast. Other reports wait for the forecast under way,
and a finished forecast is reported before the next one starts, so a
stream of changes cannot hold reports back.

A socket that refused reports for a reason other than the task's name
got every report twice, once with the name and once without. When the
report is refused without the name too, later reports now go out once
each, without the name, until the socket takes one; the report after
that has the name again.

A failed lease request is retried after a quarter of the lease lifetime
(at most five seconds) instead of half of it. A renewal that times out
at the shortest lifetime then still gets its retry in before the lease
runs out.

A failed forecast and a failure inside the reporting loop are logged as
the reporter's own, not as the socket's.
A 404 to a lease release has a test now: the lease counts as released
and is not tried again.

The test that holds a lease from a worker thread waited by polling,
which kept the event loop awake, so a wake that cannot reach a sleeping
loop passed it as well. It now waits on an event the fake socket sets
when a request arrives, and the worker takes the lease once the loop
has gone to sleep.

The test with the real engine keeps the Codex home in its temporary
directory instead of leaving an empty directory in the working
directory.

The docs say "at most one post every 250 ms", and the background lease
row lists everything it covers: anything live in the session's Agents
panel.
The page is docs/agents.md. The link spelled it in capitals, which only
resolves on a case-insensitive file system.
A wakeup recorded without a turn, as "Run later" in the web UI does,
reached the socket only with the next forecast: the report sent when
the client then left reused the last one, since notify() asked for a
report and no forecast. Now notify(), which client connections and
finished workflow runs use, asks for a new forecast, and the report
waits for it. A turn start is still the one report that goes out at
once with the last forecast.

The report that follows a turn which started and ended between two
reports asks for no forecast of its own: the turn's end asked for one.

Say in the docs which reports wait for a forecast, and that one already
under way is waited for too.
@pufit
pufit merged commit 2f63104 into main Oct 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants