Skip to content

Record each session's cause and who changes a task - #515

Merged
pufit merged 17 commits into
mainfrom
alex/attr-01-cause-tasks
Oct 17, 2026
Merged

pufit merged 17 commits into
mainfrom
alex/attr-01-cause-tasks

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

Nerve records two IDs for a change: the actor (who made the change) and the cause (the person on whose behalf it was made). The cause of all agent work is the session's cause, which is the person who started the session. This PR adds that plumbing and uses it first for task changes. The later PRs in this series use it for model requests, sessions, notifications, plans, workflow runs and schedules.

How

  • Every session stores one cause, sessions.caused_by_actor_id, when it is created. The cause never changes:

    Session Cause
    Created or forked by a person that person
    Cron or hook session the system actor
    Plan implementation session the approver
    Channel, imported Codex or MCP satellite session none
  • Every agent tool call in a session records the system actor as the actor and the session's cause as the cause. A change that a person makes directly records the person as both.

  • One helper, attribution_for(actor, cause), builds the (actor_id, caused_by_actor_id) pair that store methods take. ToolContext.actor and ToolContext.cause carry it to tools.

  • The database layer loads the session causes when it connects and keeps them current, so a lookup is right also just after a restart.

  • Migration v053:

    • adds sessions.caused_by_actor_id, task_events.actor_id and task_events.caused_by_actor_id, all nullable foreign keys to actor_refs, with no backfill;
    • adds the table task_status_events.
  • nerve/db/actor_columns.py lists every attribution column, and a test keeps that list equal to the schema.

  • Tasks:

    • Changes through the REST API record the person. Task tools record the system actor and the session's cause.
    • Changes to the set of task statuses go to task_status_events.
    • The timeline shows "Alice", or "Nerve for Alice".
  • Task changes that write a task's file and row run one at a time under a per-task lock, so an edit always lands in the file that the row names.

Why

Later changes attribute model usage to the session's cause, and record who made session, notification, workflow and schedule changes. They build on this plumbing and only add call sites and columns.

Approximations

  • In a session that several people use, all agent work is attributed to the person who started it.
  • A deferred message or a wakeup in an existing session is attributed to that session's cause.

Behaviour changes

  • REST task create records the origin web instead of system.
  • PATCH /api/tasks/{id} with content no longer changes the status or the file path in its content save, so a move that another person makes at the same time is kept.

Testing

  • tests/test_cause_attribution.py (new) and web/src/components/Tasks/TaskTimeline.test.tsx (new), in local and external mode.
  • Full suite: 5786 passed, 39 skipped. Web: build ok, 715 tests passed.
  • Independent review rounds on the first design and on this one.

Stack

  1. Record each session's cause and who changes a task #515 (this PR): Actor and cause plumbing; task attribution (base main)
  2. Name the session's cause on each model request in external mode #516 Model requests name the session's cause
  3. Record who changes a session, and who deletes it #517 Session change attribution
  4. Record who answers notifications and reviews plans #518 Notification answers, plan reviews and approval decisions
  5. Record who starts, kills and decides workflow runs and review loops #519 Workflow runs and review loops
  6. Record who schedules wakeups and starts cron runs, and run them for that person #520 Schedules: run-now, run-later and wakeups

Independent: #514 (system actor takes the seeded system principal ID).

🤖 Generated with Claude Code

alex-clickhouse and others added 13 commits October 16, 2026 20:36
A change records two actor IDs: the actor that made it and the
principal on whose behalf it was made (the cause). Until now only
sessions and user messages stored an actor, and a tool call had no way
to know for whom the agent worked.

- nerve/identity.py: ActorOrId, the CAUSE_FROM_ACTOR default, and
  attribution_for(), which builds the (actor_id, caused_by_actor_id)
  pair that every store write takes.
- engine.run() takes a cause (default: the actor). The engine keeps the
  cause of each session's current turn, sets it before the client is
  fetched, keeps it after the turn for autonomous CLI turns, and stores
  it on the turn's user row (messages.caused_by_actor_id).
- ToolContext gains actor and cause. Agent tool calls are the system
  actor's changes for the turn's cause; route-built contexts carry the
  request's person as both. The MCP endpoint gives a session-bound call
  its engine session's cause and a satellite call no cause.
- Causes at the call sites that can be decided now: the star hook, REST
  plan approval and revision (the person), tool plan approval and
  revision (the turn's cause), resume after restart (the cause on the
  interrupted turn's user row, else the system actor).
- Migration v053 adds messages.caused_by_actor_id and
  task_events.actor_id / caused_by_actor_id, with no backfill.
  nerve/db/actor_columns.py lists every attribution column; a test keeps
  it equal to the schema.
- Task REST routes record the person; REST create records origin "web",
  not "system". PATCH with content keeps the stored status in its
  upsert, so the status change happens once, through task_update, for
  the person. Task tools record the system actor and the turn's cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The task events API returns actor_id and caused_by_actor_id.
ActorAttribution renders the actor's name, followed by "for <person>"
when the system actor made the change for a person ("Nerve for
Alice"). It resolves names through the actor store, like the chat
labels, and renders nothing without an actor id, so the timeline keeps
the origin text for rows from before the columns existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/accounts.md explains the two IDs (actor and cause), lists the
attribution columns and the cause of each kind of work.
docs/tasks.md says that task_events.actor is the origin of a change and
that actor_id and caused_by_actor_id name who made it and for whom.
docs/api.md adds the new fields to the message, task event and
user_message payloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine kept one mutable cause per session and set it before the
pre-query drain, so autonomous output that buffered from an earlier
turn was processed under the next person's cause. Restart recovery
read the newest user row, which an internal turn does not write, so
an interrupted internal turn could resume as an earlier person.

- A run() turn's cause becomes session_cause() when its query starts,
  after the pre-query drain. The drain runs under the cause of the work
  that it drains.
- A background task keeps the cause of the turn that started it. When
  it settles, the autonomous turn that reports it runs for that cause.
- v053 adds sessions.turn_caused_by_actor_id. The engine writes it when
  a turn starts (run or autonomous), and resume after restart uses it,
  else the system actor. latest_user_turn_cause() is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PATCH with content wrote back the status and file path that it read
before its file write. A move by another person in that window was
undone, and the undo was recorded as a transition by the saver.

upsert_task() accepts KEEP for status and file_path: the stored values
are read in the write's own transaction, so a content save never
changes them and records no transition. The PATCH route uses it; the
status change of the request still goes through task_update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating, updating, reordering and deleting a task status recorded the
person only in a log line. A status definition row can be deleted, so
its history cannot live on the row.

v053 adds the append-only table task_status_events (change,
status_name, detail, actor_id, caused_by_actor_id, created_at). The
task status store writes one row per change in the same transaction,
with the Attribution that the caller passes: the person for the REST
routes, the system actor and the turn's cause for task_status_create.
Both columns are in the actor column registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/accounts.md: a turn's cause is fixed when it starts, background
tasks keep the cause of the turn that started them, and resume uses
sessions.turn_caused_by_actor_id. The column table lists the new
columns. docs/tasks.md describes task_status_events. docs/api.md adds
turn_caused_by_actor_id to session payloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One CLI process serves a session and its tool calls carry no turn, so
the engine reads the session's current cause. Three cases still gave
work to the wrong principal:

- The pre-query drain dropped an autonomous turn with no content after
  3 s. Its late output then ran inside the next person's turn.
- A background agent of one person kept calling tools during another
  person's turn.
- A failed write of the session's turn cause was logged and kept as
  done, so a restart could resume for an earlier cause.

The pre-query drain waits up to 30 s for the first output of a started
autonomous turn, as the idle watcher does, and then to its result. A
run() turn runs with an unknown cause when the drain gave up on an
autonomous turn, or when a background agent or workflow of another
cause still runs; an autonomous turn does the same for the second case.
The turn cause is written to the session row before the turn reaches
the runtime: two failed writes leave an unknown cause in memory, stop a
run() turn, and the next turn retries. Every client teardown path calls
_forget_background_causes().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
task_update (title, note, deadline, tags) and task_write read the task,
wrote the file, then upserted the status and path that they read. A
move by another writer in that window was undone, and the undo was
recorded as a transition by the caller.

Both pass KEEP for file_path, and for status unless task_update was
asked to change it. A requested status is applied in the upsert's
transaction, from the stored status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An autonomous turn took the cause of the background task that settled
last, or the last turn's cause when no task settled. A report of tasks
of two people took the second person's cause, and a turn that a watcher
started took the cause of whoever spoke last.

Each background task records whether it can act before it settles (an
agent, a workflow, a watcher or an unknown kind; not a shell command).
An autonomous turn takes the cause of the tasks it reports, else the
last turn's cause, and is unknown when the settled tasks differ in
cause or a running task that can act has another cause. A run() turn
uses the same running-task check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_cause_for_new_turn cleared the unfinished-autonomous marker as a side
effect. The marker is cleared at the one call site in _run_inner, so
the check can be asked at any point of a turn, for example when a
client is built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KEEP keeps the row's status and path when a change writes a task file,
but the change still wrote the path that it read. When another change
moved the file into or out of done/ in between, the edit went to a file
that the row no longer named, and the caller got success.

nerve/tasks/locks.py gives each task one lock, re-entrant for the
asyncio task that holds it. task_update, task_write, task_done, the
reopen path, the PATCH and move routes and TaskManager.mark_done hold it
from their first read of the row to their last write. Tests race real
moves into and out of done/ against an agent edit, a REST title edit
and a REST content edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/accounts.md says which turns do not start when the turn cause
cannot be stored (turns that a message, a schedule or another Nerve
caller starts) and what happens to a report turn that the agent starts
on its own. It also says what model requests carry while background
work of another principal keeps the session's agent process: the
principal the process was started for, until a turn starts with no
background work running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alex-clickhouse and others added 4 commits October 17, 2026 07:54
The cause of agent work is the person who started the session. One
stored value per session replaces the per-turn cause and the rules
that kept it apart from background work and restarts.

- v053 adds sessions.caused_by_actor_id and no longer adds
  messages.caused_by_actor_id or sessions.turn_caused_by_actor_id.
  The registry follows.
- create_session, SessionManager.get_or_create and _create_session take
  a cause (default: the actor) that applies only when they create the
  row. engine.run(cause=...) passes it to the session that the run
  creates; an existing session keeps its cause.
- engine.session_cause() returns the stored cause, cached when the
  engine reads the session row for a turn or a client. Agent tool calls
  record the system actor and that cause; the MCP endpoint reads it per
  call.
- Creation paths: a person's create and fork take the person, cron and
  hooks the system actor, REST plan approval the approver, tool plan
  approval the approving session's cause, a planner session that a
  revision request creates the requester. MCP satellite and imported
  Codex sessions store no cause; channel sessions have no actor.
- Removed: the per-turn binding, the per-task background causes, the
  30 s wait for autonomous turns, the unknown-cause rules, the turn
  cause write and TurnCauseNotStored, the resume cause logic and the
  per-message cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/accounts.md lists the session's cause for each kind of session,
says that all agent work in a session records that cause, and names the
two approximations: a shared session is attributed to its starter, and
a deferred message or wakeup in an existing session to that session's
cause. The per-turn and billing text is removed. docs/api.md and
docs/tasks.md follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine cached a session's cause only after it read the row for a
turn or a client, so session_cause() and the tool contexts it built
returned no cause for a session with a stored person until then.

The database loads the cause of every session at connect, and
create_session, get_session and delete_session keep the cache. The
engine's session_cause() reads it, and an engine-built ToolContext takes
the value when the client is built. Tests check a fresh engine before
any turn (a person and a stored NULL), creation, a row that another
writer adds, and delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nerve/identity.py still named the user row of an interrupted turn as a
stored cause. Two docstrings in nerve/agent/sessions.py used em dashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse alex-clickhouse changed the title Record the cause of each turn and who changes a task Record each session's cause and who changes a task Oct 17, 2026
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 17, 2026 12:06
@pufit
pufit merged commit 8d7c5d9 into main Oct 17, 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