Skip to content
Open
15 changes: 13 additions & 2 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ These columns store actor IDs:
| `review_loops.created_by_actor_id`, `review_loops.caused_by_actor_id` | Actor and cause of a review loop. Each leg session of the loop is created with this cause |
| `review_loops.killed_by_actor_id`, `review_loops.killed_caused_by_actor_id` | Actor and cause of the kill of a review loop |
| `review_loop_decisions.actor_id`, `review_loop_decisions.caused_by_actor_id` | Actor and cause of one decision on a parked review loop |
| `session_wakeups.created_by_actor_id`, `session_wakeups.caused_by_actor_id` | Who scheduled a wakeup or a deferred message, and on whose behalf. The turn that it fires runs in its session, for the session's cause |
| `cron_logs.actor_id`, `cron_logs.caused_by_actor_id` | Who started a cron or source run, and on whose behalf |

`nerve/db/actor_columns.py` lists every attribution column. Clients resolve
names through `GET /api/actors`, which returns every actor that these columns
Expand All @@ -218,6 +220,7 @@ never changes it:
|---|---|
| A person creates or forks a session (web UI, REST API, browser WebSocket) | That person |
| A cron job or a hook session | The system actor |
| A cron job session that a person's "Run now" creates (an isolated run, or a new generation of a persistent job) | That person. A persistent job's current generation keeps its cause |
| A plan implementation session after an approval through the REST API | The person who approved |
| A plan implementation session after the agent approved the plan | The cause of the session in which the agent approved it |
| A planner session that a revision request creates | The person who asked for the revision |
Expand All @@ -231,13 +234,17 @@ session's cause as cause. A change that a person makes directly records that
person as both. `messages.actor_id` keeps naming who wrote each message; there
is no cause per message or per turn.

This is an approximation in two places:
This is an approximation in three places:

- In a session that several people use, the agent's work in every turn is
attributed to the person who started the session, also in turns that
another person sent.
- A deferred message ("run later") or a wakeup that runs in an existing session
is attributed to that session's cause.
is attributed to that session's cause, also when another person deferred
the message.
- A persistent cron job reuses its current session. When a person's "Run
now" started that session, the later scheduled runs in it are attributed
to that person until the session rotates.

In external mode, the model requests of a session name the session's cause
in the same way, so the same approximation applies to model usage. See
Expand All @@ -254,6 +261,10 @@ in the same way, so the same approximation applies to model usage. See
| A person starts or kills a workflow run or a review loop through the web UI or the REST API, or decides on a parked loop through the REST API or its decision card in the web UI | The person | The person |
| The agent starts or kills a workflow run or a review loop | The system actor | The session's cause |
| The message that relays a web answer into the session that asked | The person who answered | Not recorded (the agent's work in that turn has the session's cause) |
| A scheduled cron or source run (also a catch-up or missed run, and `nerve cron <id>`) | The system actor on the run log | The system actor |
| A person starts a cron or source run from the web UI or with `POST /api/cron/jobs/{job_id}/trigger` | The person on the run log; the system actor for the workflow run that a workflow-run job starts | The person |
| A person defers a message with "Run later" | The person on the wakeup row | The person |
| The agent schedules a wakeup (`ScheduleWakeup`, `schedule_wakeup`) | The system actor | The session's cause |
| Telegram, Slack, or imported Codex input, and channel commands (`/new`, `/stop`, `/star`) | None until a local identity mapping exists | None |
| An answer through Telegram, Slack, or a hosted channel (also a decision on a review-loop card), and the message that relays it | None | None |
| Assistant and tool output | None | None |
Expand Down
31 changes: 28 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,8 +714,8 @@ writes no configuration.

An actor is *who* something is attributed to: a person, or the agent's system
principal. Sessions, messages, task events, notifications, silences, plans,
workflow runs, review loops and review-loop decisions store an actor id,
never a name, so this is where a name is looked up at
workflow runs, review loops, review-loop decisions, wakeups and cron run logs
store an actor id, never a name, so this is where a name is looked up at
render time. A rename changes
every label and moves no stored row.

Expand Down Expand Up @@ -795,6 +795,13 @@ whose behalf all agent work in the session runs. Nerve sets it when it creates
the session and never changes it (an actor id, or `null` when unknown). See
[Attribution](accounts.md#attribution).

Session list rows carry `pending_wakeup_at`, the fire time of the session's
pending wakeup or deferred message (`null` when there is none), with
`pending_wakeup_created_by_actor_id` (who scheduled it: a person, or the
system actor when the agent did) and `pending_wakeup_caused_by_actor_id` (on
whose behalf it was scheduled; the turn that it fires runs for the session's
cause). The `session_running` WebSocket event carries the same three fields.

#### `GET /api/sessions/{id}`
Get session details.

Expand Down Expand Up @@ -1321,15 +1328,33 @@ combine with `offset` for pagination. Each log row carries the
`session_id` of the chat session the run executed in (null for source
runners and missed runs).

Each log row also carries `actor_id` (who started the run) and
`caused_by_actor_id` (on whose behalf). Both are the system actor for a
scheduled run, and the person for a run that a person started with
`POST /api/cron/jobs/{job_id}/trigger`. Both are `null` on older rows. See
[Attribution](accounts.md#attribution).

```json
Response: {
"logs": [ { "id": 12, "job_id": "...", "status": "success", "session_id": "cron:...", ... } ],
"logs": [ { "id": 12, "job_id": "...", "status": "success", "session_id": "cron:...", "actor_id": "…", "caused_by_actor_id": "…", ... } ],
"total": 234,
"limit": 50,
"offset": 0
}
```

#### `POST /api/cron/jobs/{job_id}/trigger`
Run a cron job or a source runner now, and wait until the run ends. The run
log records the signed-in person as actor and cause. A session that the run
creates (an isolated run, or a new generation of a persistent job) has the
person as its cause; a persistent job's current session keeps its cause. The
workflow run that a workflow-run job starts is the system actor's, for the
person. Returns 404 for an unknown job.

```json
Response: { "job_id": "morning-briefing", "triggered": true }
```

### Health

#### `GET /health`
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Claude Agent SDK wrapper providing:
- **Persistent channel mappings** — channel-to-session mapping stored in `channel_sessions` table (survives restarts)
- **Orphan recovery** — on startup, sessions marked `active` in DB but with no live client are transitioned to `idle` (resumable) or `stopped`
- **Automatic cleanup** — periodic task (every 6h) archives stale sessions (default 30 days) and enforces max session count (default 500)
- **Per-run cron sessions** — each cron run gets a unique session ID (`cron:{job_id}:{timestamp}`) to prevent unbounded message accumulation
- **Per-run cron sessions**: each cron run gets a unique session ID (`cron:{job_id}:{run_id}`, the start time and a random suffix) to prevent unbounded message accumulation
- AI-generated session titles via lightweight Haiku API call
- Custom MCP tools (tasks, memory recall, conversation history, sync status, skills CRUD, notifications)
- API calls routed through configurable base URL (direct Anthropic API or local CLIProxyAPI proxy)
Expand Down Expand Up @@ -190,7 +190,7 @@ idle-stream support, cache-TTL policy) gate engine behavior — never

### Cron Job
1. APScheduler triggers at schedule
2. Isolated per-run session created (`cron:{job_id}:{timestamp}`)
2. Isolated per-run session created (`cron:{job_id}:{run_id}`)
3. Agent runs with job prompt and cron model, acting as the system principal — the session and the prompt are attributed to it, not to whoever wrote the schedule
4. Agent uses `notify`/`ask_user` tools to communicate with user
5. Run logged in `cron_logs` table
Expand Down
11 changes: 9 additions & 2 deletions docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,13 +375,13 @@ that name its gate, see below):

### Isolated (default)

Each run creates a fresh session (`cron:{job_id}:{timestamp}`). The agent has no in-context memory of previous runs. This is best for self-contained jobs like daily briefings or cleanup tasks.
Each run creates a fresh session (`cron:{job_id}:{run_id}`; the run ID is the UTC start time `YYYYmmdd-HHMMSS` and eight random hex digits, so two runs that start in the same second get two sessions). The agent has no in-context memory of previous runs. This is best for self-contained jobs like daily briefings or cleanup tasks.

### Persistent

Jobs with `session_mode: persistent` maintain SDK conversation context across runs. Each job owns a **generation chat** — one session that is reused run after run until rotation:

- **First trigger**: Creates a fresh generation session (`cron:{job_id}:{timestamp}`) and runs the prompt.
- **First trigger**: Creates a fresh generation session (`cron:{job_id}:{run_id}`, the same ID form as an isolated run) and runs the prompt.
- **Subsequent triggers**: Resumes the same SDK session and sends the prompt as a new message. The agent sees all prior runs of this generation in-context.
- **Context rotation**: Every `context_rotate_hours` (default: 24) — or daily at `context_rotate_at` — the current chat is **retired and a brand-new chat is started**. The old chat is preserved exactly as it was: it keeps its full message history, stays browsable (and resumable) in the UI like any other session, and ages out via the normal session archival policy. Retiring also indexes the old context into memU, cancels the old session's pending wakeups (so a retired thread can't resurrect itself alongside the new one), and retitles it with its end date (`Cron: {job_id} (until 2026-07-05)`).

Expand Down Expand Up @@ -512,6 +512,13 @@ Every cron and source run is logged in the `cron_logs` SQLite table:
- `status` — `success`, `error`, or `missed` (see [Late runs](#late-runs))
- `output` — First 2000 chars of response / summary
- `error` — Error message if failed
- `actor_id` / `caused_by_actor_id`: who started the run, and on whose
behalf: the system actor for a scheduled, catch-up or missed run, or for
`nerve cron <id>`; the person who triggered the run through the web UI or
`POST /api/cron/jobs/{job_id}/trigger`. A session that a person's run
creates has that person as its cause, so the agent's work in it is the
system actor's for that person. See
[Attribution](accounts.md#attribution).

Source runs also log to `source_run_log` with per-source diagnostics (records fetched/processed, errors).

Expand Down
6 changes: 4 additions & 2 deletions docs/workflow-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,10 @@ A run records who created it and on whose behalf
- Through `workflow_run_start` or `workflow_run_kill`, the actor is the
system actor and the cause is the cause of the session that called the
tool.
- A cron job's run is the system actor's, for itself. A review-loop leg is
the system actor's, for the cause of the loop.
- A cron job's run is the system actor's, for itself. When a person starts
the job with "Run now" (`POST /api/cron/jobs/{job_id}/trigger`), the run
is the system actor's, for that person. A review-loop leg is the system
actor's, for the cause of the loop.
- The run's session is created with the run's cause, so everything the
agent does in it (and, in external mode, its model requests) is for that
cause. A run with no cause (one started by input that names no person)
Expand Down
60 changes: 47 additions & 13 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
set_skill_manager,
worker_onboarding_prompt,
)
from nerve.agent.sessions import SessionManager, SessionStatus
from nerve.agent.sessions import SessionManager, SessionStatus, new_cron_run_id
from nerve.agent.streaming import broadcaster
from nerve.agent.tools import (
ToolContext,
Expand Down Expand Up @@ -1064,9 +1064,13 @@ def _wakeup_fire_at(cls, delay_seconds: Any) -> str:
@classmethod
async def _record_wakeup(
cls, db: Any, session_id: str, tool_input: dict,
*, attribution: Attribution = UNATTRIBUTED,
) -> int | None:
"""Persist a ScheduleWakeup request from its tool input.

``attribution`` is that of the tool call: the system actor and the
session's cause.

Returns the new wakeup id, or ``None`` when there's no prompt to
re-inject (in which case nothing is scheduled).
"""
Expand All @@ -1077,6 +1081,7 @@ async def _record_wakeup(
fire_at = cls._wakeup_fire_at(tool_input.get("delaySeconds"))
wakeup_id = await db.add_wakeup(
session_id, prompt=prompt, fire_at=fire_at, reason=reason,
attribution=attribution,
)
logger.info(
"Recorded wakeup %s for session %s at %s",
Expand Down Expand Up @@ -1352,7 +1357,14 @@ async def _get_or_create_client(
)

async def _record_wakeup_cb(sid: str, tool_input: dict) -> Any:
return await self._record_wakeup(self.db, sid, tool_input)
# An agent action in a session: the system actor, for the
# session's cause.
return await self._record_wakeup(
self.db, sid, tool_input,
attribution=attribution_for(
self.db.system_actor, self.session_cause(sid),
),
)

spec = SessionSpec(
session_id=session_id,
Expand Down Expand Up @@ -1566,13 +1578,13 @@ def has_live_background_tasks(self, session_id: str) -> bool:
runs to defer the terminal 'done' until spend-relevant work ends."""
return self._has_live_background_tasks(session_id)

async def pending_wakeup_at(self, session_id: str) -> str | None:
"""``fire_at`` of the session's pending wakeup, or ``None``.
async def pending_wakeup(self, session_id: str) -> dict | None:
"""The session's pending wakeup row, or ``None``.

``ScheduleWakeup`` keeps at most one pending row per session (a new
request supersedes the old one), so this is a single timestamp
rather than a list. Never raises — a listing/indicator caller must
not fail on an enrichment read.
request supersedes the old one), so this is a single row rather
than a list. Never raises: a listing or indicator caller must not
fail on an enrichment read.
"""
try:
rows = await self.db.list_pending_wakeups(session_id)
Expand All @@ -1581,7 +1593,12 @@ async def pending_wakeup_at(self, session_id: str) -> str | None:
"pending wakeup lookup failed for %s", session_id, exc_info=True,
)
return None
return rows[0].get("fire_at") if rows else None
return rows[0] if rows else None

async def pending_wakeup_at(self, session_id: str) -> str | None:
"""``fire_at`` of the session's pending wakeup, or ``None``."""
wakeup = await self.pending_wakeup(session_id)
return wakeup.get("fire_at") if wakeup else None

async def _broadcast_session_running(
self, session_id: str, is_running: bool,
Expand All @@ -1595,11 +1612,15 @@ async def _broadcast_session_running(
with the transition means the client can redraw the row without a
follow-up list fetch — which it skips for the active session.
"""
wakeup = await self.pending_wakeup(session_id) or {}
await broadcaster.broadcast("__global__", {
"type": "session_running",
"session_id": session_id,
"is_running": is_running,
"pending_wakeup_at": await self.pending_wakeup_at(session_id),
"pending_wakeup_at": wakeup.get("fire_at"),
# Who scheduled the wakeup, and for whom; see GET /api/sessions.
"pending_wakeup_created_by_actor_id": wakeup.get("created_by_actor_id"),
"pending_wakeup_caused_by_actor_id": wakeup.get("caused_by_actor_id"),
"has_background_tasks": self._has_live_background_tasks(session_id),
})

Expand Down Expand Up @@ -3537,6 +3558,8 @@ async def run_cron(
run_id: str | None = None,
cache_ttl: str = "",
effort: str | None = None,
*,
cause: CauseArg = CAUSE_FROM_ACTOR,
) -> str:
"""Run an agent turn for a cron job in an isolated session.

Expand All @@ -3545,14 +3568,19 @@ async def run_cron(
unless the run yielded with a live ``run_in_background`` task, in which
case it is kept alive so the agent can resume when the task completes
(see ``_teardown_oneshot_client``).

``cause``: the cause of the run's session: the system actor (the
default) for a scheduled run; the person who asked for the run when
a person started it.
"""
if run_id is None:
run_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
# A scheduled run is the instance's own work, regardless of who wrote
# the schedule. Human attribution belongs to the schedule mutation.
run_id = new_cron_run_id()
# A cron run is the instance's own work, regardless of who wrote the
# schedule or who started the run. A person who started it is the
# cause of the run's session.
actor = self.db.system_actor
session = await self.sessions.create_cron_session(
job_id, run_id=run_id, actor=actor,
job_id, run_id=run_id, actor=actor, cause=cause,
)
session_id = session["id"]
await self._stamp_cron_session_meta(session_id, "isolated", cache_ttl)
Expand All @@ -3576,6 +3604,8 @@ async def run_persistent_cron(
session_id: str | None = None,
cache_ttl: str = "",
effort: str | None = None,
*,
cause: CauseArg = CAUSE_FROM_ACTOR,
) -> str:
"""Run a persistent cron job that maintains context across runs.

Expand All @@ -3591,11 +3621,15 @@ async def run_persistent_cron(
which would collide with the parked task — so a persistent-cron
background task that outlives its run is not resumed (use an
isolated cron for long background work).

``cause`` is as for :meth:`run_cron`. It applies only when this call
creates the session; a session of earlier runs keeps its cause.
"""
session_id = session_id or f"cron:{job_id}"
actor = self.db.system_actor
await self.sessions.get_or_create(
session_id, title=f"Cron: {job_id}", source="cron", actor=actor,
cause=cause,
)
await self._stamp_cron_session_meta(session_id, "persistent", cache_ttl)
try:
Expand Down
Loading
Loading