Repository navigation
Record each session's cause and who changes a task - #515
Merged
Merged
Conversation
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>
This was referenced Oct 17, 2026
alex-clickhouse
added this pull request to stack #521
October 17, 2026 07:20
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
marked this pull request as ready for review
October 17, 2026 12:06
pufit
approved these changes
Oct 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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.actorandToolContext.causecarry 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:
sessions.caused_by_actor_id,task_events.actor_idandtask_events.caused_by_actor_id, all nullable foreign keys toactor_refs, with no backfill;task_status_events.nerve/db/actor_columns.pylists every attribution column, and a test keeps that list equal to the schema.Tasks:
task_status_events.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
Behaviour changes
webinstead ofsystem.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) andweb/src/components/Tasks/TaskTimeline.test.tsx(new), in local and external mode.Stack
main)Independent: #514 (system actor takes the seeded system principal ID).
🤖 Generated with Claude Code