From 4a7fb2c4de5f2d5c2d474f7e98229618d8021d74 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:36:52 +0000 Subject: [PATCH 01/17] Record the cause of each turn and who changes a task 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 --- nerve/agent/engine.py | 57 +- nerve/agent/plan_service.py | 7 + nerve/agent/sessions.py | 7 +- nerve/agent/tools/handlers/plans.py | 7 +- nerve/agent/tools/handlers/tasks.py | 13 +- nerve/agent/tools/registry.py | 25 + nerve/db/actor_columns.py | 20 + nerve/db/messages.py | 54 +- nerve/db/migrations/v053_cause_attribution.py | 61 ++ nerve/db/tasks.py | 32 +- nerve/gateway/routes/_deps.py | 11 +- nerve/gateway/routes/actors.py | 6 +- nerve/gateway/routes/plans.py | 4 +- nerve/gateway/routes/sessions.py | 6 +- nerve/gateway/routes/tasks.py | 63 +- nerve/gateway/server.py | 3 + nerve/identity.py | 76 +- nerve/mcp_server/http.py | 10 + nerve/tasks/manager.py | 9 + tests/test_cause_attribution.py | 750 ++++++++++++++++++ tests/test_db.py | 1 + tests/test_plan_revise.py | 11 + tests/test_resume_after_restart.py | 38 +- tests/test_task_board_api.py | 5 + 24 files changed, 1227 insertions(+), 49 deletions(-) create mode 100644 nerve/db/actor_columns.py create mode 100644 nerve/db/migrations/v053_cause_attribution.py create mode 100644 tests/test_cause_attribution.py diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 8dae74764..b4988d40f 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -13,6 +13,7 @@ import asyncio import contextlib +import functools import json import logging import os @@ -69,7 +70,7 @@ from nerve.agent.tools import init_tools from nerve.config import NerveConfig, RESUME_QUEUE_FILE, load_mcp_servers from nerve.db import Database -from nerve.identity import Actor +from nerve.identity import CAUSE_FROM_ACTOR, Actor, CauseArg, resolve_cause from nerve.observability.langfuse import attributes as lf_attrs from nerve.setup_writer import ( TASK_ONBOARDING_MARKER, @@ -254,6 +255,12 @@ def __init__(self, config: NerveConfig, db: Database): # Read by session-scoped tools (send_file) to avoid dispatching via # stale router context from a prior inbound channel. self._active_channel: dict[str, str] = {} + # Cause actor ID of each session's current turn: the principal on + # whose behalf the turn runs (None = unknown). run() sets it under the + # session lock before it fetches the client, and keeps it when the + # turn ends, so autonomous CLI turns between runs keep the cause of + # the last turn. Read through session_cause(). + self._turn_causes: dict[str, str | None] = {} # Resolved model bound to each session's live SDK client. Used to # detect mid-session model switches (the CLI fixes its model at # connect time, so a change requires recreating the client). @@ -375,12 +382,27 @@ def get_active_channel(self, session_id: str) -> str | None: """Return the channel name currently driving ``session_id`` (or None).""" return self._active_channel.get(session_id) + def session_cause(self, session_id: str) -> str | None: + """The cause actor ID of the session's current turn. + + While no turn runs, the cause of the session's last turn: autonomous + CLI turns between runs (a background task that settles) continue the + work of that turn. ``None`` when the cause is unknown, including a + session with no turn in this process. + """ + return self._turn_causes.get(session_id) + # ------------------------------------------------------------------ # # Backend resolution (sticky per session) # # ------------------------------------------------------------------ # def _build_tool_context(self, session_id: str) -> ToolContext: - """Fresh per-session ToolContext for backend MCP servers.""" + """Fresh per-session ToolContext for backend MCP servers. + + The agent's tool calls are the system actor's changes. The context + lives as long as the client, across turns, so the cause is read from + the session's current turn at the time of each call. + """ return ToolContext( session_id=session_id, workspace=self.config.workspace, @@ -391,6 +413,8 @@ def _build_tool_context(self, session_id: str) -> ToolContext: skill_manager=self._skill_manager, engine=self, notification_service=self.notification_service, + actor=self.db.system_actor, + cause=functools.partial(self.session_cause, session_id), ) def _gateway_port(self) -> int | None: @@ -1670,6 +1694,14 @@ async def resume_enrolled_sessions(self) -> int: continue try: logger.info("Resume-after-restart: continuing session %s", sid) + # The continuation finishes the interrupted turn, so it runs + # for the cause stored on that turn's user row. Without one + # (an old row, input with no known person) it is the + # instance's own work. + cause = ( + await self.db.latest_user_turn_cause(sid) + or self.db.system_actor + ) await self.run( session_id=sid, user_message=_RESUME_AFTER_RESTART_PROMPT, @@ -1680,6 +1712,7 @@ async def resume_enrolled_sessions(self) -> int: # the session keeps their messages; the continuation is # the assistant's. actor=self.db.system_actor, + cause=cause, ) resumed += 1 except Exception as e: @@ -2494,6 +2527,7 @@ async def run( bump_updated_at: bool = True, *, actor: Actor | None, + cause: CauseArg = CAUSE_FROM_ACTOR, ) -> str: """Run the agent for a user message and return the final text response. @@ -2507,6 +2541,15 @@ async def run( reply, whose authorship is its ``role``. Required keyword-only: an autonomous caller must say so rather than inherit a person's identity by omission. + cause: The principal on whose behalf the turn runs: an + :class:`Actor`, an actor ID, or ``None`` for unknown. + The default is the actor: the person for a person, the + system actor for the system actor, unknown for no + actor. Pass the person whose request started work that + the system actor does. The cause is stored on the + turn's user row, given to the turn's tool calls, and + readable through :meth:`session_cause` for the length + of the turn and after it. internal: If True, the user_message is a system-generated trigger (e.g., background task completion) and won't be stored in DB or shown in the UI. @@ -2523,6 +2566,9 @@ async def run( trigger message", and plenty of internal turns (a run-later deferral, the star hook) are user-requested. """ + # Resolved before the lock: an invalid cause fails here, before the + # session is marked as running. + cause_id = resolve_cause(actor, cause) # Serialize runs per session — messages for the same session wait # in order instead of failing with "already running". lock = self._session_locks.setdefault(session_id, asyncio.Lock()) @@ -2539,6 +2585,10 @@ async def run( self.sessions.mark_running(session_id) if channel is not None: self._active_channel[session_id] = channel + # Before _run_inner, so the cause is known when the client is + # fetched or built. Kept after the turn ends; see + # session_cause(). + self._turn_causes[session_id] = cause_id # Mark the turn as in flight so the finally below can # detect "ended without sending done/stopped/error" and # ship a synthetic done. Clearing happens automatically @@ -2554,6 +2604,7 @@ async def run( image_refs=image_refs, bump_updated_at=bump_updated_at, actor=actor, + cause_id=cause_id, ) finally: self.sessions.mark_not_running(session_id) @@ -2600,6 +2651,7 @@ async def _run_inner( bump_updated_at: bool = True, *, actor: Actor | None, + cause_id: str | None, ) -> str: # Ensure session exists in DB await self.sessions.get_or_create(session_id, source=source, actor=actor) @@ -2638,6 +2690,7 @@ async def _run_inner( session_id, "user", db_text, channel=channel, blocks=image_refs, actor=actor, + caused_by=cause_id, ) # Turn accumulator — shared shape with the autonomous-turn drain. diff --git a/nerve/agent/plan_service.py b/nerve/agent/plan_service.py index a8dc12c08..91cb9b8d9 100644 --- a/nerve/agent/plan_service.py +++ b/nerve/agent/plan_service.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from nerve.agent.engine import AgentEngine from nerve.db import Database + from nerve.identity import ActorOrId logger = logging.getLogger(__name__) @@ -57,6 +58,8 @@ async def request_plan_revision( engine: "AgentEngine", plan_id: str, feedback: str, + *, + cause: "ActorOrId | None", ) -> dict: """Persist revision feedback and dispatch a revision prompt to the planner. @@ -65,6 +68,9 @@ async def request_plan_revision( engine: AgentEngine instance (used to ensure session + dispatch run). plan_id: The pending plan to revise. feedback: Free-text feedback explaining what should change. + cause: Who asked for the revision: the person for the HTTP route, + the cause of the calling turn for the tool. The revision turn + runs as the system actor on behalf of this principal. Returns: ``{"plan_id", "task_id", "session_id", "status"}`` on success. @@ -154,6 +160,7 @@ async def request_plan_revision( # The revision prompt is built from a template here, not typed by # the person who asked for the revision. actor=engine.db.system_actor, + cause=cause, ) ) diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index 7a34d80ba..b1ad61312 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -19,7 +19,7 @@ from typing import Any from nerve.db import Database -from nerve.identity import Actor +from nerve.identity import Actor, ActorOrId logger = logging.getLogger(__name__) @@ -645,11 +645,13 @@ async def add_message( bump_updated_at: bool = True, *, actor: Actor | None, + caused_by: ActorOrId | None = None, ) -> int: """Add a message to a session's history. ``actor`` is the principal whose input this row records, or ``None`` - for assistant and tool output — see + for assistant and tool output, and ``caused_by`` is the cause of the + turn that a user row starts. See :meth:`nerve.db.messages.MessageStore.add_message`. ``bump_updated_at=False`` records the message without moving the @@ -662,6 +664,7 @@ async def add_message( native_turn_id=native_turn_id, bump_updated_at=bump_updated_at, actor=actor, + caused_by=caused_by, ) async def get_conversation_history( diff --git a/nerve/agent/tools/handlers/plans.py b/nerve/agent/tools/handlers/plans.py index ae231dc29..61f86b45c 100644 --- a/nerve/agent/tools/handlers/plans.py +++ b/nerve/agent/tools/handlers/plans.py @@ -227,8 +227,10 @@ async def plan_approve_handler(ctx: ToolContext, args: dict) -> ToolResult: plan_type = plan.get("plan_type", "generic") # The agent approved this plan through its own tool, so both the session - # and the run it dispatches are the instance's own work. + # and the run it dispatches are the instance's own work. The run is on + # behalf of the cause of the turn that approved it. impl_actor = ctx.db.system_actor + impl_cause = ctx.cause_id # Mark as implementing (prevents double-approve) await ctx.db.update_plan(plan_id, status="implementing", reviewed_at=now) @@ -301,7 +303,7 @@ async def _run_impl(): try: await engine.run( session_id=impl_session_id, user_message=prompt, source="web", - actor=impl_actor, + actor=impl_actor, cause=impl_cause, ) except Exception: logger.exception("Implementation session %s failed", impl_session_id) @@ -381,6 +383,7 @@ async def plan_revise_handler(ctx: ToolContext, args: dict) -> ToolResult: try: result = await request_plan_revision( db=ctx.db, engine=ctx.engine, plan_id=plan_id, feedback=feedback, + cause=ctx.cause_id, ) except PlanNotFound: return ToolResult.text(f"Plan not found: {plan_id}") diff --git a/nerve/agent/tools/handlers/tasks.py b/nerve/agent/tools/handlers/tasks.py index 223bb1f06..4289c8a95 100644 --- a/nerve/agent/tools/handlers/tasks.py +++ b/nerve/agent/tools/handlers/tasks.py @@ -357,6 +357,7 @@ async def task_create_handler(ctx: ToolContext, args: dict) -> ToolResult: tags=tags_to_string(tags), content=content, actor=ctx.session_id, + attribution=ctx.attribution, ) except Exception: # A claimed ID is now sticky, so a half-done create would burn it: @@ -541,7 +542,8 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: write = lambda: write_task_file(file_path, content) kw = dict( task_id=task_id, file_path=task["file_path"], title=final_title, - status=final_status, content=content, actor=ctx.session_id, **edits, + status=final_status, content=content, actor=ctx.session_id, + attribution=ctx.attribution, **edits, ) # Without a token the order stays file-then-row, as before. With # one the row is written first, so a refused caller writes no file. @@ -574,6 +576,7 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: if status: applied = await ctx.db.transition_task( task_id, status, expect_revision=expect_revision, actor=ctx.session_id, + attribution=ctx.attribution, **({"tags": new_tags_str} if raw_tags is not None else {}), ) elif raw_tags is not None: @@ -668,6 +671,7 @@ async def task_write_handler(ctx: ToolContext, args: dict) -> ToolResult: status=task["status"], deadline=new_deadline or None, content=new_content, + attribution=ctx.attribution, **edits, ) @@ -711,19 +715,21 @@ async def task_done_handler(ctx: ToolContext, args: dict) -> ToolResult: if expect_revision is None: await asyncio.to_thread(move_task_file, src, dst, content) await ctx.db.transition_task( - task_id, "done", actor=ctx.session_id, + task_id, "done", actor=ctx.session_id, attribution=ctx.attribution, file_path=rel_path, content=content, ) else: if not await ctx.db.transition_task( task_id, "done", expect_revision=expect_revision, - actor=ctx.session_id, file_path=rel_path, content=content, + actor=ctx.session_id, attribution=ctx.attribution, + file_path=rel_path, content=content, ): return _revision_conflict(task_id, expect_revision) await asyncio.to_thread(move_task_file, src, dst, content) else: done = await ctx.db.transition_task( task_id, "done", expect_revision=expect_revision, actor=ctx.session_id, + attribution=ctx.attribution, ) if not done and expect_revision is not None: return _revision_conflict(task_id, expect_revision) @@ -803,6 +809,7 @@ async def task_reopen_handler(ctx: ToolContext, args: dict) -> ToolResult: kw: dict = dict( expect=(TERMINAL_STATUS,), expect_revision=expect_revision, actor=ctx.session_id, + attribution=ctx.attribution, ) if src is not None: content = await asyncio.to_thread(src.read_text, encoding="utf-8") diff --git a/nerve/agent/tools/registry.py b/nerve/agent/tools/registry.py index f6078c383..bb6a68070 100644 --- a/nerve/agent/tools/registry.py +++ b/nerve/agent/tools/registry.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import Any, Awaitable, Callable, TYPE_CHECKING +from nerve.identity import Actor, ActorOrId, Attribution, actor_id_of + if TYPE_CHECKING: from nerve.agent.engine import AgentEngine from nerve.config import NerveConfig @@ -39,6 +41,14 @@ class ToolContext: Fields are concrete types rather than Protocols to keep call sites readable; tests construct ``ToolContext`` with mocks/None for fields a given handler doesn't need. + + ``actor`` performs the tool call: the system actor when the agent calls a + tool, the person when an HTTP route invokes a handler. ``cause`` is the + principal on whose behalf the call happens. It is a fixed value, or a + callable that returns the cause when a handler reads it: a context that + the engine builds lives as long as the session's client, across turns, so + it reads the cause of the turn that runs at the time of the call. Read it + through :attr:`cause_id` or :attr:`attribution`, never directly. """ session_id: str @@ -51,6 +61,21 @@ class ToolContext: engine: "AgentEngine | None" = None notification_service: "NotificationService | None" = None runtime_metadata: dict[str, Any] = field(default_factory=dict) + actor: Actor | None = None + cause: "ActorOrId | Callable[[], ActorOrId | None] | None" = None + + @property + def cause_id(self) -> str | None: + """The cause actor ID at the time of the read, or ``None`` if unknown.""" + cause = self.cause() if callable(self.cause) else self.cause + return actor_id_of(cause) + + @property + def attribution(self) -> Attribution: + """The pair that a write made by this tool call records.""" + return Attribution( + actor_id=actor_id_of(self.actor), caused_by_actor_id=self.cause_id, + ) @dataclass diff --git a/nerve/db/actor_columns.py b/nerve/db/actor_columns.py new file mode 100644 index 000000000..d9f610e44 --- /dev/null +++ b/nerve/db/actor_columns.py @@ -0,0 +1,20 @@ +"""Every attribution column that stores an ``actor_refs`` ID. + +One ``(table, column)`` pair per line. A migration that adds an attribution +column appends its pair at the end. The tests compare this list with the +schema: each pair must be a column with a foreign key to ``actor_refs``, and +each such column must be in the list, except ``accounts.actor_id``, which +binds a login to its person and is not attribution. + +``GET /api/actors`` must return every actor that one of these columns names. +""" + +from __future__ import annotations + +ACTOR_COLUMNS: tuple[tuple[str, str], ...] = ( + ("sessions", "created_by_actor_id"), + ("messages", "actor_id"), + ("messages", "caused_by_actor_id"), + ("task_events", "actor_id"), + ("task_events", "caused_by_actor_id"), +) diff --git a/nerve/db/messages.py b/nerve/db/messages.py index 0abbc277f..4126613f9 100644 --- a/nerve/db/messages.py +++ b/nerve/db/messages.py @@ -5,7 +5,7 @@ import json from datetime import datetime, timezone -from nerve.identity import Actor +from nerve.identity import Actor, ActorOrId, actor_id_of class MessageStore: @@ -25,6 +25,7 @@ async def add_message( bump_updated_at: bool = True, *, actor: Actor | None, + caused_by: ActorOrId | None = None, ) -> int: """Insert a message row. ``external_id`` enables idempotent ingest from external sources (Codex thread sync, MCP server). @@ -38,6 +39,10 @@ async def add_message( the principal that prompted output is not stored. ``channel`` is transport provenance, never identity. + ``caused_by`` is the cause of the turn that a user row starts: the + principal on whose behalf the turn runs. Pass it on user rows that + start a turn; every other row keeps ``None``. + ``created_at`` lets external ingesters preserve original Codex timestamps. Defaults to ``CURRENT_TIMESTAMP`` for native callers. @@ -49,27 +54,32 @@ async def add_message( left alone. """ actor_id = actor.actor_id if actor else None + caused_by_actor_id = actor_id_of(caused_by) async with self._atomic(): if created_at is not None: async with self.db.execute( """INSERT INTO messages (session_id, role, content, thinking, blocks, channel, - external_id, native_turn_id, actor_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + external_id, native_turn_id, actor_id, + caused_by_actor_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (session_id, role, content, thinking, json.dumps(blocks) if blocks else None, - channel, external_id, native_turn_id, actor_id, created_at), + channel, external_id, native_turn_id, actor_id, + caused_by_actor_id, created_at), ) as cursor: msg_id = cursor.lastrowid else: async with self.db.execute( """INSERT INTO messages (session_id, role, content, thinking, blocks, channel, - external_id, native_turn_id, actor_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + external_id, native_turn_id, actor_id, + caused_by_actor_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (session_id, role, content, thinking, json.dumps(blocks) if blocks else None, - channel, external_id, native_turn_id, actor_id), + channel, external_id, native_turn_id, actor_id, + caused_by_actor_id), ) as cursor: msg_id = cursor.lastrowid # Update session timestamp and message counter @@ -142,10 +152,11 @@ async def copy_messages_to_session( the native context at, so display and context never disagree. Returns the number of rows copied. - ``actor_id`` is copied with the rest: a fork displays the same - conversation, so its copied turns keep the people who actually sent - them rather than being re-attributed to whoever forked. Who made the - *fork* is recorded on the new session's ``created_by_actor_id``. + ``actor_id`` and ``caused_by_actor_id`` are copied with the rest: a + fork displays the same conversation, so its copied turns keep the + people who actually sent them rather than being re-attributed to + whoever forked. Who made the *fork* is recorded on the new session's + ``created_by_actor_id``. """ boundary_id: int | None = None if up_to_native_turn_id is not None: @@ -173,9 +184,11 @@ async def copy_messages_to_session( async with self.db.execute( f"""INSERT INTO messages (session_id, role, content, thinking, blocks, channel, - external_id, native_turn_id, actor_id, created_at) + external_id, native_turn_id, actor_id, + caused_by_actor_id, created_at) SELECT ?, role, content, thinking, blocks, channel, - 'forkcopy:' || id, native_turn_id, actor_id, created_at + 'forkcopy:' || id, native_turn_id, actor_id, + caused_by_actor_id, created_at FROM messages WHERE {where} ORDER BY id ASC""", params, ) as cursor: @@ -356,6 +369,21 @@ async def merge_workflow_into_call( return row["id"] return None + async def latest_user_turn_cause(self, session_id: str) -> str | None: + """The cause stored on the session's newest user row. + + ``None`` when the session has no user row, or when that row has no + cause (input with no known person, or a row older than v053). + """ + async with self.db.execute( + """SELECT caused_by_actor_id FROM messages + WHERE session_id = ? AND role = 'user' + ORDER BY id DESC LIMIT 1""", + (session_id,), + ) as cursor: + row = await cursor.fetchone() + return row[0] if row else None + async def get_messages( self, session_id: str, limit: int = 500, offset: int = 0 ) -> list[dict]: diff --git a/nerve/db/migrations/v053_cause_attribution.py b/nerve/db/migrations/v053_cause_attribution.py new file mode 100644 index 000000000..c28a1f78a --- /dev/null +++ b/nerve/db/migrations/v053_cause_attribution.py @@ -0,0 +1,61 @@ +"""V53: record the cause of a turn and who changed a task's status. + +Adds three nullable columns that hold an ``actor_refs.id``: + +- ``messages.caused_by_actor_id``: on a user row that starts a turn, the + principal on whose behalf the turn runs. That is the person who sent the + message, or the person whose request started work by the system actor, or + the system actor for autonomous work. Assistant and tool output rows keep + it ``NULL``. +- ``task_events.actor_id``: who changed the status. A person through the UI or + REST, or the system actor for an agent tool call or another internal + process. +- ``task_events.caused_by_actor_id``: on whose behalf the change was made. + +``task_events.actor`` stays. It is the origin of the change (``web``, a +session ID, ``system``, ``backfill``), not an identity. + +**Existing rows stay NULL and nothing is backfilled.** NULL reads as "not +recorded". The free-text origin of an old task event does not name a person, +so a backfill would invent attribution. + +The foreign keys follow v048: an ID that resolves to no actor can never be +stored, and NULL is exempt from the check. Each column is added only when the +table does not have it yet, so a second run does nothing. +""" + +from __future__ import annotations + +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + +# (table, column, DDL type + constraints) +_COLUMNS = ( + ("messages", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), + ("task_events", "actor_id", "TEXT REFERENCES actor_refs(id)"), + ("task_events", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), +) + + +async def _has_column(db: aiosqlite.Connection, table: str, column: str) -> bool: + async with db.execute(f"PRAGMA table_info({table})") as cursor: + rows = await cursor.fetchall() + return any(row[1] == column for row in rows) + + +async def up(db: aiosqlite.Connection) -> None: + added = [] + for table, column, ddl in _COLUMNS: + if await _has_column(db, table, column): + continue + # SQLite allows a REFERENCES clause on ADD COLUMN when the default is + # NULL, so the table is not rebuilt and every existing row is NULL. + await db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") + added.append(f"{table}.{column}") + logger.info( + "v053: cause and task attribution columns %s", + ", ".join(added) if added else "already present", + ) diff --git a/nerve/db/tasks.py b/nerve/db/tasks.py index bad969ff1..3e834ed4b 100644 --- a/nerve/db/tasks.py +++ b/nerve/db/tasks.py @@ -5,6 +5,8 @@ import re from datetime import datetime, timezone +from nerve.identity import UNATTRIBUTED, Attribution + # Spacing between task ranks (``tasks.position``). Wide enough that a # midpoint insert has ~50 levels of headroom before float precision runs @@ -69,6 +71,7 @@ async def upsert_task( actor: str = "system", *, expect_revision: int | None = None, + attribution: Attribution = UNATTRIBUTED, ) -> bool: """Insert or update a task row and its FTS entry. @@ -95,6 +98,9 @@ async def upsert_task( ``revision`` still equals it, and otherwise writes nothing and returns ``False``. Without it the call inserts or updates unconditionally and returns ``True``. + + ``actor`` and ``attribution`` go to the status event that a status + change records; see :meth:`_record_status_event`. """ now = datetime.now(timezone.utc).isoformat() async with self._atomic(): @@ -139,7 +145,9 @@ async def upsert_task( await cursor.close() if expect_revision is not None and (rowcount or 0) != 1: return False - await self._record_status_event(task_id, previous_status, status, actor) + await self._record_status_event( + task_id, previous_status, status, actor, attribution, + ) await self._sync_task_fts(task_id, title, content, tags) return True @@ -151,6 +159,7 @@ async def transition_task( expect: tuple[str, ...] = (), expect_revision: int | None = None, actor: str = "system", + attribution: Attribution = UNATTRIBUTED, content: str | None = None, **fields, ) -> bool: @@ -206,7 +215,9 @@ async def transition_task( await cursor.close() if not applied: return False - await self._record_status_event(task_id, row["status"], to_status, actor) + await self._record_status_event( + task_id, row["status"], to_status, actor, attribution, + ) if content is not None: await self._sync_task_fts( task_id, fields.get("title", row["title"]), content, @@ -241,6 +252,7 @@ async def _record_status_event( from_status: str | None, to_status: str, actor: str, + attribution: Attribution = UNATTRIBUTED, ) -> None: """Append a transition to ``task_events``, if it is one. @@ -254,14 +266,20 @@ async def _record_status_event( is recorded. The single case where reindex *does* change a status — resetting an orphaned row to match its directory — is a real correction and worth a row. + + ``actor`` is the free-text origin of the change (``web``, a session + ID, ``system``). ``attribution`` names the actor that made the change + and the principal it was made for, as ``actor_refs`` IDs. """ if from_status == to_status: return await self.db.execute( - "INSERT INTO task_events (task_id, from_status, to_status, actor, created_at) " - "VALUES (?, ?, ?, ?, ?)", + "INSERT INTO task_events (task_id, from_status, to_status, actor, " + "actor_id, caused_by_actor_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", ( task_id, from_status, to_status, actor or "system", + attribution.actor_id, attribution.caused_by_actor_id, datetime.now(timezone.utc).isoformat(), ), ) @@ -425,6 +443,7 @@ async def move_task( before_id: str | None = None, after_id: str | None = None, actor: str = "system", + attribution: Attribution = UNATTRIBUTED, ) -> dict | None: """Place a task at an explicit slot in a lane; return the new row. @@ -466,7 +485,7 @@ async def move_task( # A pure reorder leaves status alone, and _record_status_event # drops the no-op — so dragging within a lane doesn't pollute # the history with rows that say nothing changed. - await self._record_status_event(task_id, row[0], lane, actor) + await self._record_status_event(task_id, row[0], lane, actor, attribution) async with self.db.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,), ) as cursor: @@ -561,9 +580,10 @@ async def count_tasks( async def update_task_status( self, task_id: str, status: str, actor: str = "system", + *, attribution: Attribution = UNATTRIBUTED, ) -> None: """An unconditional :meth:`transition_task`; a missing row is a no-op.""" - await self.transition_task(task_id, status, actor=actor) + await self.transition_task(task_id, status, actor=actor, attribution=attribution) async def update_task_tags( self, task_id: str, tags: str, *, expect_revision: int | None = None, diff --git a/nerve/gateway/routes/_deps.py b/nerve/gateway/routes/_deps.py index 42ecca5f4..8887f0569 100644 --- a/nerve/gateway/routes/_deps.py +++ b/nerve/gateway/routes/_deps.py @@ -14,6 +14,7 @@ from nerve.agent.tools import ToolContext, ToolRegistry from nerve.db import Database from nerve.external_agents.sync_service import SyncService + from nerve.identity import Actor from nerve.notifications.service import NotificationService @@ -71,7 +72,9 @@ def get_tool_registry() -> "ToolRegistry": return get_deps().engine.registry -def build_route_tool_context(session_id: str = "system") -> "ToolContext": +def build_route_tool_context( + session_id: str = "system", *, actor: "Actor | None" = None, +) -> "ToolContext": """Construct a :class:`ToolContext` for an HTTP-route-initiated tool call. Routes don't belong to an LLM session, so we use a stable @@ -79,6 +82,10 @@ def build_route_tool_context(session_id: str = "system") -> "ToolContext": reads ``ctx.session_id``. Notification-style tools are *not* invoked from routes today, but if they ever are they'll attribute to that well-known sentinel. + + ``actor`` is the request's actor. A person who calls a route makes the + change for themselves, so it is both the actor and the cause of the + handler's writes. """ from nerve.agent.tools import ToolContext # local import to avoid cycle @@ -94,4 +101,6 @@ def build_route_tool_context(session_id: str = "system") -> "ToolContext": skill_manager=engine._skill_manager, engine=engine, notification_service=deps.notification_service, + actor=actor, + cause=actor, ) diff --git a/nerve/gateway/routes/actors.py b/nerve/gateway/routes/actors.py index a6a07a691..9ef0bc205 100644 --- a/nerve/gateway/routes/actors.py +++ b/nerve/gateway/routes/actors.py @@ -1,4 +1,8 @@ -"""Bulk lookup for the actor ids stored on sessions and messages. +"""Bulk lookup for the actor ids stored in attribution columns. + +The attribution columns are listed in :mod:`nerve.db.actor_columns`. The +endpoint returns every ``actor_refs`` row, so it covers each ID that one of +those columns holds. The response is built field by field from ``actor_refs`` plus the login name of the account behind a human actor, so credential and access state cannot leak diff --git a/nerve/gateway/routes/plans.py b/nerve/gateway/routes/plans.py index cb45dc3f7..e0ef23df5 100644 --- a/nerve/gateway/routes/plans.py +++ b/nerve/gateway/routes/plans.py @@ -111,6 +111,7 @@ async def revise_plan(plan_id: str, req: PlanReviseRequest, actor: Actor = Depen engine=deps.engine, plan_id=plan_id, feedback=req.feedback, + cause=actor, ) except PlanNotFound: raise HTTPException(status_code=404, detail="Plan not found") @@ -231,8 +232,9 @@ async def _run_impl(): # Nerve assembles the prompt from the task file and the # approved plan, so the turn is the instance's own work even # though a person's approval started it. The session above - # carries who approved. + # carries who approved, and the turn runs on their behalf. actor=deps.db.system_actor, + cause=actor, ) except Exception: logger.exception("Implementation session %s failed", impl_session_id) diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index bfd355d5e..9a98947c7 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -509,11 +509,12 @@ async def update_session(session_id: str, req: dict, actor: Actor = Depends(requ try: # Nerve generates this trigger, so it uses the system actor. The # person's star action is separate, and ``internal`` persists no - # user message. + # user message. The turn runs on behalf of the person who starred. asyncio.create_task( deps.engine.run( session_id=session_id, user_message=trigger, source="web", internal=True, actor=deps.db.system_actor, + cause=actor, ) ) except Exception as e: # a hook failure must never break the PATCH @@ -689,8 +690,9 @@ async def run_later(req: RunLaterRequest, actor: Actor = Depends(require_auth)): req.session_id, role="user", content=message, blocks=blocks or None, channel="web", # The person composed this message; deferring when it runs does not - # change who wrote it. + # change who wrote it. The deferred turn runs on their behalf. actor=actor, + caused_by=actor, ) scheduled = delay != "none" diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index eba5a9fc1..035ac957f 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -16,10 +17,17 @@ get_deps, get_tool_registry, ) +from nerve.identity import Actor, attribution_for from nerve.tasks.files import write_task_file +logger = logging.getLogger(__name__) + router = APIRouter(dependencies=[Depends(require_auth)]) +# ``task_events.actor`` for a change made through these routes: the origin of +# the change. The person who made it is in ``task_events.actor_id``. +_ROUTE_ORIGIN = "web" + class TaskCreateRequest(BaseModel): title: str @@ -203,12 +211,12 @@ async def list_task_tags(include_done: bool = False): @router.post("/api/tasks") -async def create_task(req: TaskCreateRequest): +async def create_task(req: TaskCreateRequest, actor: Actor = Depends(require_auth)): # Route into the unified handler surface via the live registry — no # behavior duplication between the REST and MCP paths. result = await get_tool_registry().invoke( "task_create", - build_route_tool_context(), + build_route_tool_context(_ROUTE_ORIGIN, actor=actor), { "title": req.title, "content": req.content, @@ -250,7 +258,9 @@ async def create_task(req: TaskCreateRequest): @router.post("/api/tasks/{task_id}/move") -async def move_task(task_id: str, req: TaskMoveRequest): +async def move_task( + task_id: str, req: TaskMoveRequest, actor: Actor = Depends(require_auth), +): """Reorder a task within a lane, or move it to another one.""" deps = get_deps() task = await deps.db.get_task(task_id) @@ -271,7 +281,7 @@ async def move_task(task_id: str, req: TaskMoveRequest): # "web" rather than the default "system" sentinel: this string # lands in task_events.actor, and the point of that column is # telling a person dragging a card from the agent moving it. - build_route_tool_context("web"), + build_route_tool_context(_ROUTE_ORIGIN, actor=actor), {"task_id": task_id, "status": target}, ) if result.is_error: @@ -279,7 +289,7 @@ async def move_task(task_id: str, req: TaskMoveRequest): moved = await deps.db.move_task( task_id, status=target, before_id=req.before_id, after_id=req.after_id, - actor="web", + actor=_ROUTE_ORIGIN, attribution=attribution_for(actor), ) if not moved: raise HTTPException(status_code=404, detail="Task not found") @@ -312,7 +322,9 @@ async def get_task(task_id: str): @router.patch("/api/tasks/{task_id}") -async def update_task(task_id: str, req: TaskUpdateRequest): +async def update_task( + task_id: str, req: TaskUpdateRequest, actor: Actor = Depends(require_auth), +): deps = get_deps() task = await deps.db.get_task(task_id) if not task: @@ -346,12 +358,17 @@ async def update_task(task_id: str, req: TaskUpdateRequest): edits["deadline"] = fields["deadline"] if fields.get("tags"): edits["tags"] = tags_to_string(parse_tags_string(fields["tags"])) + # The stored status, not ``req.status``: a status change goes + # through task_update below, which moves the file into or out of + # done/ and records the transition once, for this person. await deps.db.upsert_task( task_id=task_id, file_path=task["file_path"], title=new_title, - status=req.status or task["status"], + status=task["status"], content=req.content, + actor=_ROUTE_ORIGIN, + attribution=attribution_for(actor), **edits, ) @@ -377,7 +394,7 @@ async def update_task(task_id: str, req: TaskUpdateRequest): if len(payload) > 1: result = await get_tool_registry().invoke( - "task_update", build_route_tool_context("web"), payload, + "task_update", build_route_tool_context(_ROUTE_ORIGIN, actor=actor), payload, ) # Previously swallowed: an unknown status returned {"updated": true} # while nothing had changed. Surface the handler's own message. @@ -402,7 +419,9 @@ async def list_task_statuses(): @router.post("/api/task-statuses") -async def create_task_status(req: TaskStatusCreateRequest): +async def create_task_status( + req: TaskStatusCreateRequest, actor: Actor = Depends(require_auth), +): deps = get_deps() name = (req.name or "").strip().lower() if not STATUS_NAME_RE.match(name): @@ -420,6 +439,7 @@ async def create_task_status(req: TaskStatusCreateRequest): name=name, label=label, color=color, description=(req.description or "").strip(), ) + _log_status_change("created", name, actor) return created @@ -430,17 +450,23 @@ class TaskStatusReorderRequest(BaseModel): @router.post("/api/task-statuses/reorder") -async def reorder_task_statuses(req: TaskStatusReorderRequest): +async def reorder_task_statuses( + req: TaskStatusReorderRequest, actor: Actor = Depends(require_auth), +): """Set board column order in one write. Declared above /{name} so "reorder" isn't captured as a status name. """ deps = get_deps() - return {"statuses": await deps.db.reorder_task_statuses(req.names)} + statuses = await deps.db.reorder_task_statuses(req.names) + _log_status_change("reordered", ",".join(req.names), actor) + return {"statuses": statuses} @router.patch("/api/task-statuses/{name}") -async def update_task_status(name: str, req: TaskStatusUpdateRequest): +async def update_task_status( + name: str, req: TaskStatusUpdateRequest, actor: Actor = Depends(require_auth), +): deps = get_deps() existing = await deps.db.get_task_status_def(name) if not existing: @@ -453,11 +479,12 @@ async def update_task_status(name: str, req: TaskStatusUpdateRequest): description=req.description if req.description is not None else None, sort_order=req.sort_order, ) + _log_status_change("updated", name, actor) return await deps.db.get_task_status_def(name) @router.delete("/api/task-statuses/{name}") -async def delete_task_status(name: str): +async def delete_task_status(name: str, actor: Actor = Depends(require_auth)): deps = get_deps() existing = await deps.db.get_task_status_def(name) if not existing: @@ -475,4 +502,14 @@ async def delete_task_status(name: str): "Reassign them before deleting it.", ) await deps.db.delete_task_status_def(name) + _log_status_change("deleted", name, actor) return {"name": name, "deleted": True} + + +def _log_status_change(change: str, names: str, actor: Actor) -> None: + """Log who changed a status definition. + + ``task_statuses`` has no attribution column, so the log is the only + record of the person. + """ + logger.info("Task status %s %s by actor %s", names, change, actor.actor_id) diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 22db88c6e..713951818 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -1196,6 +1196,9 @@ async def ws_broadcast(session_id: str, message: dict): "content": user_text, "blocks": image_refs or None, "actor_id": connection.actor.actor_id, + # The person's own message starts a turn on their + # behalf; see engine.run's default cause. + "caused_by_actor_id": connection.actor.actor_id, }, exclude=client_id) # Run agent in background, store task for stop support. diff --git a/nerve/identity.py b/nerve/identity.py index 118fb2a36..a1b85aa73 100644 --- a/nerve/identity.py +++ b/nerve/identity.py @@ -17,12 +17,20 @@ taken when the actor was resolved. Renaming an account changes what later requests carry and rewrites nothing stored before. Names are never identity or authorization keys. + +A write records two IDs. The *actor* made the change: a person through the UI, +REST or the browser WebSocket, or the system actor for a tool call, a cron job +or any other internal process. The *cause* is the principal on whose behalf +the work happens: the person for a change that the person made, and for work +by the system actor the person whose request started it, or the system actor +for autonomous work. ``None`` in either position means unknown. +:func:`attribution_for` builds the pair for one write. """ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias if TYPE_CHECKING: # pragma: no cover - typing only, never imported at runtime from nerve.db.accounts import AccountStore @@ -69,6 +77,72 @@ def is_human(self) -> bool: return self.kind == ACTOR_KIND_HUMAN +# An actor, or the ``actor_refs`` ID of one. A cause that comes from storage +# (the user row of an interrupted turn, a wakeup row) is an ID; a cause that +# comes from a request is the resolved :class:`Actor`. +ActorOrId: TypeAlias = "Actor | str" + + +class CauseFromActor: + """Type of the :data:`CAUSE_FROM_ACTOR` sentinel.""" + + __slots__ = () + + def __repr__(self) -> str: + return "CAUSE_FROM_ACTOR" + + +# Default for a cause argument: the cause is the actor. A person acts for +# themselves, the system actor acts for itself, and no actor gives no cause. +# A separate sentinel is necessary because ``None`` is a real cause: unknown. +CAUSE_FROM_ACTOR = CauseFromActor() + +CauseArg: TypeAlias = "ActorOrId | None | CauseFromActor" + + +def actor_id_of(ref: ActorOrId | None) -> str | None: + """The actor ID that ``ref`` names, or ``None`` for no actor.""" + if ref is None: + return None + if isinstance(ref, Actor): + return ref.actor_id + if isinstance(ref, str) and ref: + return ref + raise ValueError(f"an actor reference must be an Actor or an actor ID, got {ref!r}") + + +def resolve_cause(actor: ActorOrId | None, cause: CauseArg = CAUSE_FROM_ACTOR) -> str | None: + """The cause actor ID for one write or one turn.""" + if isinstance(cause, CauseFromActor): + return actor_id_of(actor) + return actor_id_of(cause) + + +@dataclass(frozen=True, slots=True) +class Attribution: + """The ``(actor_id, caused_by_actor_id)`` pair that one write records. + + Every store method that records attribution takes one of these, so that + all writes get the two values from the same rule. The store maps the two + fields to its own column names. + """ + + actor_id: str | None = None + caused_by_actor_id: str | None = None + + +# Nothing is known about who made the change. +UNATTRIBUTED = Attribution() + + +def attribution_for(actor: ActorOrId | None, cause: CauseArg = CAUSE_FROM_ACTOR) -> Attribution: + """Build the pair for a write by ``actor`` on behalf of ``cause``.""" + return Attribution( + actor_id=actor_id_of(actor), + caused_by_actor_id=resolve_cause(actor, cause), + ) + + def _actor_for_account_row(account: dict) -> Actor: """Resolve an account row, checking disablement on every request.""" if not account.get("enabled"): diff --git a/nerve/mcp_server/http.py b/nerve/mcp_server/http.py index 0adbd91f2..e1a7ce651 100644 --- a/nerve/mcp_server/http.py +++ b/nerve/mcp_server/http.py @@ -25,6 +25,7 @@ from __future__ import annotations +import functools import json import logging from typing import TYPE_CHECKING, Callable @@ -188,6 +189,13 @@ async def _resolve(rctx: ServerRequestContext | None = None) -> ToolContext: session_id, runtime_metadata = _bound_identity_from_request( engine.config, rctx ) + # A bound call is the agent's tool call inside its engine session's + # turn, so it carries that turn's cause. A satellite call comes from + # an agent that this instance does not run, so its cause is unknown. + cause = ( + None if session_id is None + else functools.partial(engine.session_cause, session_id) + ) if session_id is None: client_name, mcp_session_id, _ = _resolve_client_info(rctx) @@ -217,6 +225,8 @@ async def _resolve(rctx: ServerRequestContext | None = None) -> ToolContext: engine=engine, notification_service=engine.notification_service, runtime_metadata=runtime_metadata, + actor=engine.db.system_actor, + cause=cause, ) return _resolve diff --git a/nerve/tasks/manager.py b/nerve/tasks/manager.py index 4893af0c5..7adee2fc5 100644 --- a/nerve/tasks/manager.py +++ b/nerve/tasks/manager.py @@ -9,6 +9,7 @@ from nerve.config import ensure_path_not_tracked_config from nerve.db import Database +from nerve.identity import Attribution, attribution_for from nerve.tasks.files import move_task_file from nerve.tasks.models import ( Task, @@ -33,6 +34,10 @@ def __init__(self, workspace: Path, db: Database): self.active_dir.mkdir(parents=True, exist_ok=True) self.done_dir.mkdir(parents=True, exist_ok=True) + def _own_attribution(self) -> Attribution: + """This manager's writes are Nerve's own work: system for system.""" + return attribution_for(self.db.system_actor) + async def reindex(self) -> int: """Scan task files and rebuild the SQLite + FTS index. @@ -94,6 +99,7 @@ async def reindex(self) -> int: title=title, status=status, content=content, + attribution=self._own_attribution(), **edits, ) count += 1 @@ -155,10 +161,12 @@ async def mark_done(self, task_id: str, expect_revision: int | None = None) -> b await asyncio.to_thread(move) return await self.db.transition_task( task_id, "done", file_path=rel_path, content=content, + attribution=self._own_attribution(), ) if not await self.db.transition_task( task_id, "done", expect_revision=expect_revision, file_path=rel_path, content=content, + attribution=self._own_attribution(), ): return False await asyncio.to_thread(move) @@ -166,6 +174,7 @@ async def mark_done(self, task_id: str, expect_revision: int | None = None) -> b return await self.db.transition_task( task_id, "done", expect_revision=expect_revision, + attribution=self._own_attribution(), ) async def get_overdue_tasks(self) -> list[Task]: diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py new file mode 100644 index 000000000..6533c8bf9 --- /dev/null +++ b/tests/test_cause_attribution.py @@ -0,0 +1,750 @@ +"""Actor and cause attribution: the turn cause plumbing and task attribution. + +A write records who made it (``actor_id``) and on whose behalf +(``caused_by_actor_id``). A person who changes something directly is both. A +tool call by the agent is the system actor's change, made for the cause of the +turn it runs in. These tests drive the real routes, engine, tool registry and +database; only the model runtime is replaced, by a client factory that makes +tool calls the way the agent would inside the turn. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from types import SimpleNamespace +from unittest.mock import patch + +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from nerve.agent.engine import AgentEngine +from nerve.agent.tools.handlers.plans import plan_approve_handler +from nerve.agent.tools.registry import ToolContext +from nerve.config import NerveConfig, set_config +from nerve.db import Database +from nerve.db.actor_columns import ACTOR_COLUMNS +from nerve.gateway.auth import ( + ACTOR_CONTEXT_HEADER, + AUTH_MODE_ENV, + AUTH_MODE_EXTERNAL, + create_session_token, + hash_password, + pin_auth_mode, + pin_jwt_secret, +) +from nerve.gateway.routes import init_deps, register_all_routes +from nerve.identity import ( + CAUSE_FROM_ACTOR, + UNATTRIBUTED, + Actor, + Attribution, + actor_id_of, + attribution_for, + resolve_cause, +) +from nerve.mcp_server.http import build_ctx_resolver +from nerve.mcp_server.session import SatelliteSessionResolver +from nerve.migrate import bootstrap_identity + +_SECRET = "test-secret-for-cause-attribution-pad-32b" +# One hash for both people: the tests sign in with tokens, and a hash per +# fixture is the slowest part of the setup. +_PASSWORD_HASH = hash_password("correct-horse-battery-staple") + +# External-mode principals (synthetic). +ALICE_PRINCIPAL = "6f1c2a8e-3b4d-4e5f-8a9b-0c1d2e3f4a5b" +BOB_PRINCIPAL = "0a9b8c7d-6e5f-4a3b-9c2d-1e0f9a8b7c6d" + +ALICE = Actor(actor_id="00000000-0000-4000-8000-0000000a11ce", kind="human") +SYSTEM = Actor(actor_id="00000000-0000-4000-8000-00000000515e", kind="system") + + +# --------------------------------------------------------------------------- # +# The helper and the tool context # +# --------------------------------------------------------------------------- # + + +class TestAttributionHelper: + def test_the_cause_defaults_to_the_actor(self): + assert attribution_for(ALICE) == Attribution(ALICE.actor_id, ALICE.actor_id) + assert attribution_for(SYSTEM) == Attribution(SYSTEM.actor_id, SYSTEM.actor_id) + assert attribution_for(None) == UNATTRIBUTED + + def test_an_explicit_cause_wins_and_none_stays_unknown(self): + assert attribution_for(SYSTEM, ALICE) == Attribution( + SYSTEM.actor_id, ALICE.actor_id, + ) + assert attribution_for(SYSTEM, None) == Attribution(SYSTEM.actor_id, None) + + def test_a_cause_can_be_a_stored_id(self): + assert resolve_cause(SYSTEM, ALICE.actor_id) == ALICE.actor_id + assert resolve_cause(SYSTEM, CAUSE_FROM_ACTOR) == SYSTEM.actor_id + + @pytest.mark.parametrize("bad", ["", 42, object()]) + def test_a_reference_that_names_no_actor_is_refused(self, bad): + with pytest.raises(ValueError): + actor_id_of(bad) + + +class TestToolContextCause: + def test_a_fixed_cause(self): + ctx = ToolContext(session_id="s", actor=SYSTEM, cause=ALICE) + assert ctx.cause_id == ALICE.actor_id + assert ctx.attribution == Attribution(SYSTEM.actor_id, ALICE.actor_id) + + def test_a_callable_cause_is_read_at_the_time_of_the_call(self): + current = {"cause": ALICE.actor_id} + ctx = ToolContext(session_id="s", actor=SYSTEM, cause=lambda: current["cause"]) + assert ctx.cause_id == ALICE.actor_id + current["cause"] = None + assert ctx.attribution == Attribution(SYSTEM.actor_id, None) + + def test_a_context_without_identity_is_unattributed(self): + assert ToolContext(session_id="s").attribution == UNATTRIBUTED + + +# --------------------------------------------------------------------------- # +# Local-mode install with two people # +# --------------------------------------------------------------------------- # + + +class _Install: + def __init__(self, db: Database, engine: AgentEngine, config: NerveConfig): + self.db = db + self.engine = engine + self.config = config + self.people: dict[str, dict] = {} + # Records of the model runtime stand-in, one per turn. + self.turns: list[dict] = [] + + @property + def system_actor_id(self) -> str: + return self.db.system_actor_id + + def actor_id(self, name: str) -> str: + return self.people[name]["actor_id"] + + def headers(self, name: str) -> dict: + token = create_session_token(_SECRET, self.people[name]["id"]) + return {"Authorization": f"Bearer {token}"} + + def app(self) -> FastAPI: + app = FastAPI() + app.include_router(register_all_routes()) + return app + + async def events(self, task_id: str) -> list[tuple]: + return [ + (row["from_status"], row["to_status"], row["actor"], + row["actor_id"], row["caused_by_actor_id"]) + for row in await self.db.list_task_events(task_id) + ] + + async def user_rows(self, session_id: str) -> list[tuple]: + return [ + (row["content"], row["actor_id"], row["caused_by_actor_id"]) + for row in await self.db.get_messages(session_id) + if row["role"] == "user" + ] + + def agent_creates_a_task_in_each_turn(self, monkeypatch, barrier: int = 0) -> None: + """Replace the model runtime with an agent that calls ``task_create``. + + The stand-in runs where the real client is fetched, inside the turn, + and calls the tool through the session's own tool context, like the + Claude backend's in-process MCP server. With ``barrier`` > 0, each + turn waits until that many turns are in flight before its tool call. + """ + engine = self.engine + arrived = 0 + all_in = asyncio.Event() + + async def _agent(session_id, source, model, **kwargs): + nonlocal arrived + cause_at_start = engine.session_cause(session_id) + if barrier: + arrived += 1 + if arrived >= barrier: + all_in.set() + await asyncio.wait_for(all_in.wait(), timeout=5) + ctx = engine._build_tool_context(session_id) + result = await engine.registry.invoke( + "task_create", ctx, + {"title": f"Made in {session_id}", "confirm_duplicate": True}, + ) + self.turns.append({ + "session_id": session_id, + "cause": cause_at_start, + "task_id": (result.structured or {}).get("task_id"), + }) + raise RuntimeError("no model in this test") + + monkeypatch.setattr(engine, "_get_or_create_client", _agent) + + def turn_in(self, session_id: str) -> dict: + return next(turn for turn in self.turns if turn["session_id"] == session_id) + + +@pytest_asyncio.fixture +async def install(tmp_path, open_identity_db, wire_identity_store): + config = NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "codex": {"home_dir": str(tmp_path / "codex-home")}, + "auth": {"jwt_secret": _SECRET}, + "sessions": {"star_project_hook": True}, + }) + (config.workspace / "memory" / "tasks" / "active").mkdir(parents=True) + set_config(config) + pin_jwt_secret(_SECRET) + database, identity = await open_identity_db(tmp_path / "nerve.db") + wire_identity_store(database) + engine = AgentEngine(config, database) + init_deps(engine, database) + installed = _Install(database, engine, config) + await database.update_account_login( + identity.owner_account_id, username="alice", credential=_PASSWORD_HASH, + ) + installed.people["alice"] = { + "id": identity.owner_account_id, "actor_id": identity.owner_actor_id, + } + bob = await database.create_managed_account( + username="bob", credential=_PASSWORD_HASH, display_name="Bob", + ) + installed.people["bob"] = bob + try: + yield installed + finally: + await database.close() + set_config(NerveConfig()) + + +def _client(app: FastAPI) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://nerve-test", + ) + + +async def _new_session(client, headers) -> str: + return (await client.post("/api/sessions", headers=headers, json={})).json()["id"] + + +async def _chat(client, headers, session_id: str, message: str) -> None: + response = await client.post( + "/api/chat", headers=headers, + json={"session_id": session_id, "message": message}, + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- # +# Schema # +# --------------------------------------------------------------------------- # + + +async def _actor_ref_columns(db: Database) -> set[tuple[str, str]]: + async with db.db.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) as cursor: + tables = [row[0] async for row in cursor] + found = set() + for table in tables: + async with db.db.execute(f"PRAGMA foreign_key_list({table})") as cursor: + for row in await cursor.fetchall(): + if row["table"] == "actor_refs": + found.add((table, row["from"])) + return found + + +@pytest.mark.asyncio +async def test_the_registry_lists_every_attribution_column(install): + assert len(set(ACTOR_COLUMNS)) == len(ACTOR_COLUMNS) + in_schema = await _actor_ref_columns(install.db) + # accounts.actor_id binds a login to its person; it is not attribution. + assert in_schema - {("accounts", "actor_id")} == set(ACTOR_COLUMNS) + + +@pytest.mark.asyncio +async def test_v053_keeps_old_rows_null_and_is_idempotent(install): + raw = install.db.db + await raw.execute("ALTER TABLE messages DROP COLUMN caused_by_actor_id") + await raw.execute("ALTER TABLE task_events DROP COLUMN actor_id") + await raw.execute("ALTER TABLE task_events DROP COLUMN caused_by_actor_id") + await raw.execute( + "INSERT INTO sessions (id, title, source, status, created_at, updated_at)" + " VALUES ('old', 'Old chat', 'web', 'idle', '2020-01-01', '2020-01-01')" + ) + await raw.execute( + "INSERT INTO messages (session_id, role, content, created_at)" + " VALUES ('old', 'user', 'from before', '2020-01-01')" + ) + await raw.execute( + "INSERT INTO task_events (task_id, from_status, to_status, actor, created_at)" + " VALUES ('old-task', NULL, 'pending', 'web', '2020-01-01')" + ) + await raw.commit() + + from nerve.db.migrations.v053_cause_attribution import up + + await up(raw) + await up(raw) + await raw.commit() + assert await install.user_rows("old") == [("from before", None, None)] + assert await install.events("old-task") == [(None, "pending", "web", None, None)] + + +# --------------------------------------------------------------------------- # +# Task changes through REST # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_rest_task_changes_record_each_person(install): + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + created = await client.post( + "/api/tasks", headers=install.headers("alice"), + json={"title": "Write the report"}, + ) + assert created.status_code == 200 + task_id = created.json()["task"]["id"] + moved = await client.post( + f"/api/tasks/{task_id}/move", headers=install.headers("bob"), + json={"status": "in_progress"}, + ) + assert moved.status_code == 200 + events = ( + await client.get(f"/api/tasks/{task_id}/events", headers=install.headers("bob")) + ).json()["events"] + + assert [ + (e["to_status"], e["actor"], e["actor_id"], e["caused_by_actor_id"]) + for e in events + ] == [ + ("pending", "web", alice, alice), + ("in_progress", "web", bob, bob), + ] + + +@pytest.mark.asyncio +async def test_rest_patch_with_content_records_one_change_for_the_person(install): + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), + json={"title": "Edit and move"}, + )).json()["task"]["id"] + patched = await client.patch( + f"/api/tasks/{task_id}", headers=install.headers("bob"), + json={"content": "# Edit and move\n\nNew body.\n", "status": "deferred"}, + ) + assert patched.status_code == 200 + finished = await client.patch( + f"/api/tasks/{task_id}", headers=install.headers("alice"), + json={"content": "# Edit and move\n\nBody again.\n", "status": "done"}, + ) + assert finished.status_code == 200 + assert (await install.db.get_task(task_id))["file_path"].startswith( + "memory/tasks/done/", + ) + reopened = await client.patch( + f"/api/tasks/{task_id}", headers=install.headers("bob"), + json={"content": "# Edit and move\n\nOnce more.\n", "status": "pending"}, + ) + assert reopened.status_code == 200 + + assert await install.events(task_id) == [ + (None, "pending", "web", alice, alice), + ("pending", "deferred", "web", bob, bob), + ("deferred", "done", "web", alice, alice), + ("done", "pending", "web", bob, bob), + ] + # Leaving done goes through the reopen path, so the file leaves done/. + task = await install.db.get_task(task_id) + assert task["file_path"].startswith("memory/tasks/active/") + assert (install.config.workspace / task["file_path"]).is_file() + + +@pytest.mark.asyncio +async def test_task_status_definition_changes_take_the_person(install, caplog): + caplog.set_level("INFO", logger="nerve.gateway.routes.tasks") + async with _client(install.app()) as client: + response = await client.post( + "/api/task-statuses", headers=install.headers("bob"), + json={"name": "review"}, + ) + assert response.status_code == 200 + assert f"by actor {install.actor_id('bob')}" in caplog.text + + +# --------------------------------------------------------------------------- # +# Turns: the cause on the user row, in the engine and on tool changes # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_a_tool_change_inside_a_persons_turn_is_system_for_that_person( + install, monkeypatch, +): + install.agent_creates_a_task_in_each_turn(monkeypatch) + bob = install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("bob")) + await _chat(client, install.headers("bob"), session_id, "make a task") + + turn = install.turn_in(session_id) + assert turn["cause"] == bob + assert await install.user_rows(session_id) == [("make a task", bob, bob)] + assert await install.events(turn["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, bob), + ] + # Autonomous CLI turns between runs keep the last cause. + assert install.engine.session_cause(session_id) == bob + + +@pytest.mark.asyncio +async def test_a_cron_turns_tool_change_is_system_for_system(install, monkeypatch): + install.agent_creates_a_task_in_each_turn(monkeypatch) + await install.engine.run_cron(job_id="nightly", prompt="tidy the board") + + turn = install.turns[0] + system = install.system_actor_id + assert turn["cause"] == system + assert await install.user_rows(turn["session_id"]) == [ + ("tidy the board", system, system), + ] + assert await install.events(turn["task_id"]) == [ + (None, "pending", turn["session_id"], system, system), + ] + + +@pytest.mark.asyncio +async def test_concurrent_turns_of_two_people_keep_their_causes(install, monkeypatch): + install.agent_creates_a_task_in_each_turn(monkeypatch, barrier=2) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + hers = await _new_session(client, install.headers("alice")) + his = await _new_session(client, install.headers("bob")) + await asyncio.gather( + _chat(client, install.headers("alice"), hers, "from Alice"), + _chat(client, install.headers("bob"), his, "from Bob"), + ) + + for session_id, person in ((hers, alice), (his, bob)): + turn = install.turn_in(session_id) + assert turn["cause"] == person + assert await install.events(turn["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, person), + ] + + +@pytest.mark.asyncio +async def test_input_with_no_person_has_no_cause(install, monkeypatch): + install.agent_creates_a_task_in_each_turn(monkeypatch) + await install.db.create_session("chat-app", source="telegram", actor=None) + await install.engine.run( + "chat-app", "hello from a chat app", source="telegram", actor=None, + ) + + turn = install.turn_in("chat-app") + assert turn["cause"] is None + assert await install.user_rows("chat-app") == [("hello from a chat app", None, None)] + assert await install.events(turn["task_id"]) == [ + (None, "pending", "chat-app", install.system_actor_id, None), + ] + + +@pytest.mark.asyncio +async def test_an_explicit_unknown_cause_is_not_replaced_by_the_actor( + install, monkeypatch, +): + install.agent_creates_a_task_in_each_turn(monkeypatch) + await install.db.create_session("leg", source="workflow", actor=None) + await install.engine.run( + "leg", "continue", source="workflow", + actor=install.db.system_actor, cause=None, + ) + assert install.turn_in("leg")["cause"] is None + assert await install.user_rows("leg") == [("continue", install.system_actor_id, None)] + + +@pytest.mark.asyncio +async def test_resume_after_restart_keeps_the_cause_of_the_interrupted_turn( + install, monkeypatch, tmp_path, +): + alice = install.actor_id("alice") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + monkeypatch.setattr( + install.engine, "_get_or_create_client", + _raise_no_model, + ) + await _chat(client, install.headers("alice"), session_id, "long job") + await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) + # A restart: the new process has no turn cause in memory. + install.engine._turn_causes.clear() + + install.agent_creates_a_task_in_each_turn(monkeypatch) + queue = tmp_path / "resume-after-restart" + queue.write_text(f"{session_id}\n") + with patch("nerve.agent.engine.RESUME_QUEUE_FILE", queue): + assert await install.engine.resume_enrolled_sessions() == 1 + + turn = install.turn_in(session_id) + assert turn["cause"] == alice + assert await install.events(turn["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, alice), + ] + + +async def _raise_no_model(*args, **kwargs): + raise RuntimeError("no model in this test") + + +@pytest.mark.asyncio +async def test_run_later_and_fork_keep_the_cause_on_user_rows(install): + alice = install.actor_id("alice") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + await client.post( + "/api/sessions/run-later", headers=install.headers("alice"), + json={"session_id": session_id, "message": "later", "delay": "none"}, + ) + await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) + fork = await client.post( + "/api/sessions/fork", headers=install.headers("bob"), + json={"source_session_id": session_id}, + ) + + assert await install.user_rows(session_id) == [("later", alice, alice)] + assert await install.user_rows(fork.json()["id"]) == [("later", alice, alice)] + assert await install.db.latest_user_turn_cause(session_id) == alice + + +# --------------------------------------------------------------------------- # +# Turns that the system actor runs for a person # +# --------------------------------------------------------------------------- # + + +def _record_runs(install: _Install, monkeypatch) -> list[dict]: + runs: list[dict] = [] + ran = asyncio.Event() + + async def _run(*args, **kwargs): + runs.append(kwargs) + ran.set() + return "" + + monkeypatch.setattr(install.engine, "run", _run) + install.ran = ran # type: ignore[attr-defined] + return runs + + +async def _pending_plan(install: _Install, suffix: str) -> str: + task_id, plan_id = f"task-{suffix}", f"plan-{suffix}" + task_file = install.config.workspace / f"{task_id}.md" + task_file.write_text("# Plan task\n", encoding="utf-8") + await install.db.upsert_task( + task_id=task_id, file_path=task_file.name, title="Plan task", + status="pending", content="# Plan task\n", + ) + await install.db.create_plan( + plan_id=plan_id, task_id=task_id, content="Ship it", + session_id="proposal", version=1, + ) + return plan_id + + +@pytest.mark.asyncio +async def test_rest_plan_approval_runs_for_the_approver(install, monkeypatch): + runs = _record_runs(install, monkeypatch) + plan_id = await _pending_plan(install, "rest") + async with _client(install.app()) as client: + response = await client.post( + f"/api/plans/{plan_id}/approve", headers=install.headers("bob"), + ) + assert response.status_code == 200 + await asyncio.wait_for(install.ran.wait(), timeout=5) + assert runs[0]["actor"] == install.db.system_actor + assert actor_id_of(runs[0]["cause"]) == install.actor_id("bob") + + +@pytest.mark.asyncio +async def test_tool_plan_approval_runs_for_the_cause_of_the_turn(install, monkeypatch): + runs = _record_runs(install, monkeypatch) + plan_id = await _pending_plan(install, "tool") + alice = install.actor_id("alice") + ctx = ToolContext( + session_id="alices-turn", workspace=install.config.workspace, + db=install.db, config=install.config, engine=install.engine, + actor=install.db.system_actor, cause=lambda: alice, + ) + await plan_approve_handler(ctx, {"plan_id": plan_id}) + await asyncio.wait_for(install.ran.wait(), timeout=5) + assert runs[0]["actor"] == install.db.system_actor + assert runs[0]["cause"] == alice + + +@pytest.mark.asyncio +async def test_rest_plan_revision_runs_for_the_requester(install, monkeypatch): + runs = _record_runs(install, monkeypatch) + plan_id = await _pending_plan(install, "revise") + async with _client(install.app()) as client: + response = await client.post( + f"/api/plans/{plan_id}/revise", headers=install.headers("bob"), + json={"feedback": "add tests"}, + ) + assert response.status_code == 200 + await asyncio.wait_for(install.ran.wait(), timeout=5) + assert runs[0]["actor"] == install.db.system_actor + assert actor_id_of(runs[0]["cause"]) == install.actor_id("bob") + + +@pytest.mark.asyncio +async def test_the_star_hook_runs_for_the_person_who_starred(install, monkeypatch): + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runs = _record_runs(install, monkeypatch) + response = await client.patch( + f"/api/sessions/{session_id}", headers=install.headers("bob"), + json={"starred": 1}, + ) + assert response.status_code == 200 + await asyncio.wait_for(install.ran.wait(), timeout=5) + assert runs[0]["actor"] == install.db.system_actor + assert actor_id_of(runs[0]["cause"]) == install.actor_id("bob") + + +# --------------------------------------------------------------------------- # +# The MCP endpoint # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_mcp_contexts_carry_the_system_actor_and_the_bound_turn_cause( + install, monkeypatch, +): + resolve = build_ctx_resolver(install.engine, SatelliteSessionResolver(install.db)) + rctx = SimpleNamespace(request=None, session=None) + + satellite = await resolve(rctx) + assert satellite.attribution == Attribution(install.system_actor_id, None) + + monkeypatch.setattr( + "nerve.mcp_server.http._bound_identity_from_request", + lambda config, rctx: ("codex-session", {}), + ) + bound = await resolve(rctx) + install.engine._turn_causes["codex-session"] = install.actor_id("alice") + assert bound.attribution == Attribution( + install.system_actor_id, install.actor_id("alice"), + ) + + +# --------------------------------------------------------------------------- # +# The actor directory # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_the_actor_directory_names_every_id_in_every_registered_column( + install, monkeypatch, +): + install.agent_creates_a_task_in_each_turn(monkeypatch) + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + await _chat(client, install.headers("bob"), session_id, "make a task") + await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Direct"}, + ) + directory = { + row["id"] + for row in ( + await client.get("/api/actors", headers=install.headers("alice")) + ).json()["actors"] + } + + referenced: dict[tuple[str, str], set[str]] = {} + for table, column in ACTOR_COLUMNS: + async with install.db.db.execute( + f"SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL" + ) as cursor: + referenced[(table, column)] = {row[0] async for row in cursor} + assert referenced[(table, column)] <= directory, (table, column) + # The scenario fills each column that this change adds. + for pair in ( + ("messages", "caused_by_actor_id"), + ("task_events", "actor_id"), + ("task_events", "caused_by_actor_id"), + ): + assert referenced[pair], pair + + +# --------------------------------------------------------------------------- # +# External mode # +# --------------------------------------------------------------------------- # + + +def _b64url(value: object) -> str: + return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode() + + +def _as(principal_id: str, name: str) -> dict: + payload = {"principal_id": principal_id, "profile": {"display_name": name}} + token = _b64url({"alg": "ES256", "typ": "JWT"}) + "." + _b64url(payload) + ".x" + return {ACTOR_CONTEXT_HEADER: token} + + +@pytest_asyncio.fixture +async def external_install(tmp_path, monkeypatch, wire_identity_store): + monkeypatch.setenv(AUTH_MODE_ENV, AUTH_MODE_EXTERNAL) + pin_auth_mode(AUTH_MODE_EXTERNAL) + config = NerveConfig.from_dict({ + "workspace": str(tmp_path / "ws"), + "codex": {"home_dir": str(tmp_path / "codex-home")}, + "auth": {"jwt_secret": _SECRET}, + }) + (config.workspace / "memory" / "tasks" / "active").mkdir(parents=True) + set_config(config) + database = Database(tmp_path / "nerve.db") + await database.connect() + try: + await bootstrap_identity(database, config) + wire_identity_store(database) + engine = AgentEngine(config, database) + init_deps(engine, database) + yield _Install(database, engine, config) + finally: + await database.close() + set_config(NerveConfig()) + + +@pytest.mark.asyncio +async def test_external_mode_records_principals_as_actor_and_cause( + external_install, monkeypatch, +): + install = external_install + install.agent_creates_a_task_in_each_turn(monkeypatch) + alice, bob = _as(ALICE_PRINCIPAL, "Alice"), _as(BOB_PRINCIPAL, "Bob") + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=alice, json={"title": "Hosted task"}, + )).json()["task"]["id"] + await client.post( + f"/api/tasks/{task_id}/move", headers=bob, json={"status": "deferred"}, + ) + session_id = await _new_session(client, bob) + await _chat(client, bob, session_id, "make a task") + + assert await install.events(task_id) == [ + (None, "pending", "web", ALICE_PRINCIPAL, ALICE_PRINCIPAL), + ("pending", "deferred", "web", BOB_PRINCIPAL, BOB_PRINCIPAL), + ] + turn = install.turn_in(session_id) + assert turn["cause"] == BOB_PRINCIPAL + assert await install.events(turn["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, BOB_PRINCIPAL), + ] diff --git a/tests/test_db.py b/tests/test_db.py index dbf402ae9..c5045a8e5 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -939,6 +939,7 @@ async def _patch(self, db, tmp_path, monkeypatch, content, initial_tags="old"): monkeypatch.setattr(tasks_route, "get_deps", lambda: type("D", (), {"db": db})()) await tasks_route.update_task( "t1", tasks_route.TaskUpdateRequest(content=content), + actor=db.system_actor, ) return await db.get_task("t1") diff --git a/tests/test_plan_revise.py b/tests/test_plan_revise.py index 54bdf7630..7a9c4e2d8 100644 --- a/tests/test_plan_revise.py +++ b/tests/test_plan_revise.py @@ -82,12 +82,14 @@ async def run( source: str = "web", *, actor: Actor | None, + cause=None, ) -> None: self.runs.append({ "session_id": session_id, "user_message": user_message, "source": source, "actor": actor, + "cause": cause, }) self.run_event.set() @@ -124,6 +126,7 @@ async def test_dispatches_plan_update_prompt(self, db: Database, tmp_path): result = await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="be more specific about edge cases", + cause=None, ) # Wait for the background dispatch task to actually call engine.run. @@ -147,6 +150,7 @@ async def test_stores_feedback_and_writes_task_note(self, db: Database, tmp_path await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="needs more tests", + cause=None, ) await asyncio.wait_for(engine.run_event.wait(), timeout=1.0) @@ -169,6 +173,7 @@ async def test_routes_to_proposer_session(self, db: Database, tmp_path): await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="rev", + cause=None, ) await asyncio.wait_for(engine.run_event.wait(), timeout=1.0) @@ -191,6 +196,7 @@ async def test_falls_back_to_cron_planner_when_session_unknown( await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="rev", + cause=None, ) await asyncio.wait_for(engine.run_event.wait(), timeout=1.0) @@ -206,6 +212,7 @@ async def test_refuses_non_pending_plan(self, db: Database, tmp_path): await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="rev", + cause=None, ) # Nothing should have been dispatched. assert engine.runs == [] @@ -219,6 +226,7 @@ async def test_refuses_superseded_plan(self, db: Database, tmp_path): await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="rev", + cause=None, ) async def test_raises_plan_not_found(self, db: Database, tmp_path): @@ -228,6 +236,7 @@ async def test_raises_plan_not_found(self, db: Database, tmp_path): await request_plan_revision( db=db, engine=engine, plan_id="plan-does-not-exist", feedback="rev", + cause=None, ) assert engine.runs == [] @@ -242,6 +251,7 @@ async def test_raises_task_not_found(self, db: Database, tmp_path): await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback="rev", + cause=None, ) assert engine.runs == [] @@ -252,6 +262,7 @@ async def test_empty_feedback_raises_value_error(self, db: Database, tmp_path): await request_plan_revision( db=db, engine=engine, plan_id="plan-orig", feedback=" ", + cause=None, ) diff --git a/tests/test_resume_after_restart.py b/tests/test_resume_after_restart.py index 8bd911b4f..8f93540e7 100644 --- a/tests/test_resume_after_restart.py +++ b/tests/test_resume_after_restart.py @@ -21,15 +21,22 @@ # Daemon side: AgentEngine.resume_enrolled_sessions # # --------------------------------------------------------------------------- # -def _engine_with(sessions_by_id: dict) -> AgentEngine: - """Bare AgentEngine stub exercising only resume_enrolled_sessions' deps.""" +def _engine_with(sessions_by_id: dict, causes: dict | None = None) -> AgentEngine: + """Bare AgentEngine stub exercising only resume_enrolled_sessions' deps. + + ``causes`` maps a session ID to the cause stored on its newest user row. + """ engine = AgentEngine.__new__(AgentEngine) async def _get_session(sid): return sessions_by_id.get(sid) + async def _latest_user_turn_cause(sid): + return (causes or {}).get(sid) + engine.db = SimpleNamespace( get_session=AsyncMock(side_effect=_get_session), + latest_user_turn_cause=AsyncMock(side_effect=_latest_user_turn_cause), # The resume trigger is the instance's own, so the drainer passes the # system actor to run(). system_actor=FAKE_SYSTEM_ACTOR, @@ -73,6 +80,33 @@ async def test_resumes_valid_session_and_drains_file(tmp_path): assert kwargs["user_message"] == _RESUME_AFTER_RESTART_PROMPT +ALICE_ACTOR_ID = "00000000-0000-4000-8000-0000000a11ce" + + +@pytest.mark.asyncio +async def test_resume_runs_for_the_cause_of_the_interrupted_turn(tmp_path): + qf = tmp_path / "resume-after-restart" + qf.write_text("S1\n") + engine = _engine_with({"S1": _session("S1")}, causes={"S1": ALICE_ACTOR_ID}) + with patch("nerve.agent.engine.RESUME_QUEUE_FILE", qf): + assert await engine.resume_enrolled_sessions() == 1 + kwargs = engine.run.await_args.kwargs + assert kwargs["actor"] == FAKE_SYSTEM_ACTOR + assert kwargs["cause"] == ALICE_ACTOR_ID + + +@pytest.mark.asyncio +async def test_resume_without_a_stored_cause_runs_for_the_system_actor(tmp_path): + qf = tmp_path / "resume-after-restart" + qf.write_text("S1\n") + engine = _engine_with({"S1": _session("S1")}) + with patch("nerve.agent.engine.RESUME_QUEUE_FILE", qf): + assert await engine.resume_enrolled_sessions() == 1 + kwargs = engine.run.await_args.kwargs + assert kwargs["actor"] == FAKE_SYSTEM_ACTOR + assert kwargs["cause"] == FAKE_SYSTEM_ACTOR + + @pytest.mark.asyncio async def test_skips_missing_archived_external_and_no_sdk(tmp_path): qf = tmp_path / "resume-after-restart" diff --git a/tests/test_task_board_api.py b/tests/test_task_board_api.py index d4f0b06bf..319150fd8 100644 --- a/tests/test_task_board_api.py +++ b/tests/test_task_board_api.py @@ -22,6 +22,8 @@ import pytest_asyncio from nerve.db import Database +from tests.actor_rows import ensure_actor_row +from tests.conftest import TEST_ACTOR @pytest.mark.asyncio @@ -55,6 +57,9 @@ async def setup(self, db: Database, tmp_path, bypass_auth): _skill_manager=None, ) init_deps(engine=engine, db=db) # type: ignore[arg-type] + # The routes store the request's actor on task events, and the + # foreign key needs its row. + await ensure_actor_row(db, TEST_ACTOR) app = FastAPI() app.include_router(tasks_router) From be8de6d9f080a23d8dade41a64406352372e3d5f Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:36:52 +0000 Subject: [PATCH 02/17] Show who changed a task status, and for whom, in the timeline The task events API returns actor_id and caused_by_actor_id. ActorAttribution renders the actor's name, followed by "for " 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 --- web/src/api/client.ts | 5 + web/src/components/Chat/ActorLabel.tsx | 34 +++++ .../components/Tasks/TaskDetailBody.test.tsx | 4 + .../components/Tasks/TaskTimeline.test.tsx | 117 ++++++++++++++++++ web/src/components/Tasks/TaskTimeline.tsx | 16 ++- 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 web/src/components/Tasks/TaskTimeline.test.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 046168e28..5cdc33a17 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -545,7 +545,12 @@ export interface TaskEvent { /** null on the row recording the task's creation. */ from_status: string | null; to_status: string; + /** Origin of the change: `web`, a session id, `system` or `backfill`. */ actor: string; + /** Who made the change. `null` on rows written before it was recorded. */ + actor_id: string | null; + /** On whose behalf the change was made. `null` when unknown. */ + caused_by_actor_id: string | null; created_at: string; } diff --git a/web/src/components/Chat/ActorLabel.tsx b/web/src/components/Chat/ActorLabel.tsx index 0ec97f572..61fcb7a2e 100644 --- a/web/src/components/Chat/ActorLabel.tsx +++ b/web/src/components/Chat/ActorLabel.tsx @@ -41,6 +41,40 @@ function tooltip(verb: string, actorId: string, attribution: Attribution): strin return `${verb} ${name}`; } +/** + * Who made a change, and for whom: the actor's name, followed by + * "for " when the change was made on behalf of another person. That + * is the system actor (Nerve) doing work that a person started. A cause that + * is the actor itself, or the system actor, adds nothing. Without an actor id + * nothing renders, so a caller can show its own fallback for older rows. + */ +export function ActorAttribution({ actorId, causeId, verb = 'By' }: { + actorId: string | null | undefined; + causeId: string | null | undefined; + /** Leads the tooltip, for example "Changed by". */ + verb?: string; +}) { + const actor = useAttribution(actorId); + const otherCauseId = actorId && causeId && causeId !== actorId ? causeId : null; + const cause = useAttribution(otherCauseId); + if (!actorId) return null; + const forPerson = otherCauseId && !cause.system ? cause.name : null; + const label = forPerson ? `${actor.name} for ${forPerson}` : actor.name; + const title = forPerson + ? `${verb} ${actor.name} on behalf of ${forPerson}` + : tooltip(verb, actorId, actor); + return ( + + {actor.system && + ); +} + export function ActorLabel({ actorId }: { actorId: string | null | undefined }) { const attribution = useAttribution(actorId); if (!actorId) return null; diff --git a/web/src/components/Tasks/TaskDetailBody.test.tsx b/web/src/components/Tasks/TaskDetailBody.test.tsx index 636fe5a92..c0f1e8aa5 100644 --- a/web/src/components/Tasks/TaskDetailBody.test.tsx +++ b/web/src/components/Tasks/TaskDetailBody.test.tsx @@ -8,6 +8,10 @@ import { TaskDetailBody } from './TaskDetailBody'; vi.mock('../../api/client', () => ({ api: { updateTask: vi.fn() }, + // The timeline's actor labels read the viewer through the auth store, + // which wires these on import. + setToken: vi.fn(), clearToken: vi.fn(), getToken: vi.fn(), + setUnauthorizedHandler: vi.fn(), })); /** diff --git a/web/src/components/Tasks/TaskTimeline.test.tsx b/web/src/components/Tasks/TaskTimeline.test.tsx new file mode 100644 index 000000000..1b8d51c7f --- /dev/null +++ b/web/src/components/Tasks/TaskTimeline.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ActorRef, TaskEvent } from '../../api/client'; + +vi.mock('../../api/client', () => ({ + api: { + listTaskEvents: vi.fn(), + listActors: vi.fn(), + }, + // ActorLabel reads the viewer through the auth store, which wires these. + setToken: vi.fn(), clearToken: vi.fn(), getToken: vi.fn(), + setUnauthorizedHandler: vi.fn(), +})); +vi.mock('../../stores/helpers/draftStorage', () => ({ clearAllDrafts: vi.fn() })); +vi.mock('../../stores/helpers/readStorage', () => ({ clearAllReads: vi.fn() })); + +const { api } = await import('../../api/client'); +const { useActorStore } = await import('../../stores/actorStore'); +const { TaskTimeline } = await import('./TaskTimeline'); +const { ActorAttribution } = await import('../Chat/ActorLabel'); + +const listTaskEvents = vi.mocked(api.listTaskEvents); +const listActors = vi.mocked(api.listActors); + +const ALICE = 'actor-alice'; +const BOB = 'actor-bob'; +const SYSTEM = 'actor-system'; + +function actorRef(id: string, overrides: Partial = {}): ActorRef { + return { id, kind: 'human', display_name: null, username: null, ...overrides }; +} + +let nextId = 1; +function event(overrides: Partial): TaskEvent { + return { + id: nextId++, + task_id: 't1', + from_status: 'pending', + to_status: 'in_progress', + actor: 'web', + actor_id: null, + caused_by_actor_id: null, + created_at: '2026-01-01T00:00:00+00:00', + ...overrides, + }; +} + +function attributions(): string[] { + return [...document.querySelectorAll('[data-attribution="actor"]')] + .map((el) => el.textContent ?? ''); +} + +beforeEach(() => { + vi.clearAllMocks(); + useActorStore.getState().reset(); + listActors.mockResolvedValue({ + actors: [ + actorRef(ALICE, { display_name: 'Alice' }), + actorRef(BOB, { display_name: 'Bob' }), + actorRef(SYSTEM, { kind: 'system' }), + ], + }); +}); + +describe('TaskTimeline attribution', () => { + it('names the person, Nerve for a person, and Nerve on its own', async () => { + listTaskEvents.mockResolvedValue({ + events: [ + event({ from_status: null, to_status: 'pending', actor_id: ALICE, caused_by_actor_id: ALICE }), + event({ actor: 'sess-1', actor_id: SYSTEM, caused_by_actor_id: BOB }), + event({ to_status: 'done', actor: 'cron-1', actor_id: SYSTEM, caused_by_actor_id: SYSTEM }), + ], + }); + render(); + + await waitFor(() => expect(attributions()).toEqual(['Alice', 'Nerve for Bob', 'Nerve'])); + expect(screen.getByTitle('Changed by Nerve on behalf of Bob')).toBeInTheDocument(); + expect(screen.getByTitle('Changed by Alice')).toBeInTheDocument(); + // The origin text is not shown once the actor is known. + expect(screen.queryByText(/sess-1/)).toBeNull(); + }); + + it('keeps the origin text for rows without an actor id', async () => { + listTaskEvents.mockResolvedValue({ + events: [ + event({ from_status: null, to_status: 'pending', actor: 'backfill' }), + event({ actor: 'web' }), + event({ to_status: 'deferred', actor: 'system' }), + ], + }); + render(); + + expect(await screen.findByText(/· backfill/)).toBeInTheDocument(); + expect(screen.getByText(/· web/)).toBeInTheDocument(); + expect(screen.queryByText(/· system/)).toBeNull(); + expect(attributions()).toEqual([]); + }); +}); + +describe('ActorAttribution', () => { + it('shows Nerve alone when the cause is unknown', async () => { + render(); + expect(await screen.findByText('Nerve')).toBeInTheDocument(); + expect(screen.getByTitle('By Nerve itself — scheduled or autonomous work')).toBeInTheDocument(); + }); + + it('shows a person acting for themselves once', async () => { + render(); + expect(await screen.findByText('Bob')).toBeInTheDocument(); + expect(attributions()).toEqual(['Bob']); + }); + + it('renders nothing without an actor, so the caller can fall back', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/web/src/components/Tasks/TaskTimeline.tsx b/web/src/components/Tasks/TaskTimeline.tsx index 78f39376b..85bd3cd27 100644 --- a/web/src/components/Tasks/TaskTimeline.tsx +++ b/web/src/components/Tasks/TaskTimeline.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { api, type TaskEvent } from '../../api/client'; +import { ActorAttribution } from '../Chat/ActorLabel'; import { StatusBadge } from './StatusControls'; import { formatTimeAgo } from '../../utils/dateGroups'; @@ -78,8 +79,19 @@ export function TaskTimeline({ taskId, currentStatus }: {
{formatTimeAgo(event.created_at)} - {event.actor && event.actor !== 'system' && ( - · {event.actor} + {event.actor_id ? ( + + {' · '} + + + ) : ( + // Rows written before the actor columns existed name only + // the origin of the change. + event.actor && event.actor !== 'system' && · {event.actor} )}
From 0f3ae55e32a0ce54b7e7ad4a7bfc811868f0d379 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:36:52 +0000 Subject: [PATCH 03/17] Document actor and cause attribution 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 --- docs/accounts.md | 61 +++++++++++++++++++++++++++++++++++++----------- docs/api.md | 29 +++++++++++++++-------- docs/tasks.md | 26 +++++++++++++++------ 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index 2c28a37b4..0734e21e0 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -169,22 +169,55 @@ In external mode: ## Attribution -Sessions store who created them in `created_by_actor_id`. User messages store -who supplied the content in `actor_id`. Both columns contain stable actor IDs; -clients resolve names through `GET /api/actors` — the display name, or the -username when none is set. +Nerve can record two actor IDs for a change: -| Event | Stored actor | +- The **actor** made the change: a person through the web UI, the REST API or + the browser WebSocket, or the system actor when the agent or Nerve did it (a + tool call, a cron job, any internal process). +- The **cause** is the principal on whose behalf the change was made. For a + change that a person made directly, the cause is that person. For work by + the system actor, the cause is the person whose request started the work, + or the system actor for autonomous work. + +These columns store actor IDs: + +| Column | What it records | |---|---| -| A person creates a session or sends a message | That person's actor | -| Nerve creates a session or prompt, including MCP and Codex sessions | The system actor | -| Telegram, Slack, or imported Codex input | None until a local identity mapping exists | -| Assistant and tool output | None | - -Existing records remain unattributed. Nerve does not infer identity from channel -or source metadata. Account and session administration actions are not yet -recorded as audit events. Missing attribution means legacy history, unidentified -external input, or assistant/tool output; autonomous work uses the system actor. +| `sessions.created_by_actor_id` | Who created the session | +| `messages.actor_id` | Who supplied the content of a user message | +| `messages.caused_by_actor_id` | The cause of the turn that a user message starts | +| `task_events.actor_id`, `task_events.caused_by_actor_id` | Actor and cause of a task status change | + +`nerve/db/actor_columns.py` lists every attribution column. Clients resolve +names through `GET /api/actors`, which returns every actor that these columns +can name: the display name, or the username when none is set. The web UI shows +the system actor with a person as cause as "Nerve for ". + +Each agent turn has one cause. Tool calls in the turn record the system actor +as actor and the turn's cause as cause. + +| Work | Actor | Cause | +|---|---|---| +| A person sends a message, or changes a task through the web UI or the REST API | The person | The person | +| Nerve creates a session, including MCP and Codex sessions | The system actor | Not recorded | +| A cron job or a hook | The system actor | The system actor | +| A plan implementation or a plan revision that a person started | The system actor | That person | +| A plan approved or a revision requested by the agent in a turn | The system actor | The cause of that turn | +| The hook turn that starring a session starts | The system actor | The person who starred it | +| A turn resumed after a restart | The system actor | The cause on the interrupted turn's user message, else the system actor | +| A turn that the agent starts between turns, when a background task finishes | The system actor | The cause of the session's last turn | +| A tool call from an external MCP client | The system actor | None | +| Workflow legs, wakeups, deferred messages when they run, and answers relayed into a session | The system actor | The system actor | +| Telegram, Slack, or imported Codex input | None until a local identity mapping exists | None | +| Assistant and tool output | None | None | + +Existing records remain unattributed: Nerve does not backfill these columns. +Nerve does not infer identity from channel or source metadata. The free-text +origin fields, such as `task_events.actor`, stay as they are; they record where +a change came from, not who made it. Account and session administration actions +are not yet recorded as audit events. Missing attribution means legacy history, +unidentified external input, or assistant/tool output; autonomous work uses the +system actor. ## Session-signing secret diff --git a/docs/api.md b/docs/api.md index d6f8df1fd..ea79eb508 100644 --- a/docs/api.md +++ b/docs/api.md @@ -713,9 +713,9 @@ writes no configuration. ### Actors An actor is *who* something is attributed to: a person, or the agent's system -principal. Sessions and messages 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. +principal. Sessions, messages and task events 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. ```json { "id": "…", "kind": "human", "display_name": "Alice", "username": "alice" } @@ -795,14 +795,16 @@ Get session details. Get messages for a session. ```json -Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "...", "actor_id": "…" }] } +Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "...", "actor_id": "…", "caused_by_actor_id": "…" }] } ``` `actor_id` is who supplied the message: the signed-in person who typed it, or the system principal for a prompt Nerve composed itself. It is `null` on Slack, Telegram, and imported Codex human input until identity mappings exist; -on assistant/tool output; and on legacy history. `channel` stays transport -provenance and is never identity. See +on assistant/tool output; and on legacy history. `caused_by_actor_id` is set on +a user message that starts a turn: the principal on whose behalf the turn runs. +It is `null` on other rows, on input with no known person, and on legacy +history. `channel` stays transport provenance and is never identity. See [Accounts and identity](accounts.md) for what is and is not attributed. #### `DELETE /api/sessions/{id}` @@ -996,11 +998,17 @@ A task's status history, oldest first. ```json Response: { "events": [ - { "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "system", "created_at": "..." }, - { "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "created_at": "..." } + { "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "web", "actor_id": "…", "caused_by_actor_id": "…", "created_at": "..." }, + { "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "actor_id": "…", "caused_by_actor_id": "…", "created_at": "..." } ] } ``` +`actor` is the origin of the change (`web`, a session id, `system`, +`backfill`). `actor_id` is who made the change and `caused_by_actor_id` on +whose behalf, both actor ids for [`GET /api/actors`](#actors) and `null` on +rows from before they were recorded. See +[Status History](tasks.md#status-history). + #### `POST /api/tasks/{id}/move` Place a task in a lane — the board's drag-and-drop write. Send *intent* ("between these two cards"), not a computed rank: the server resolves the @@ -1375,8 +1383,9 @@ fresh. { type: "error", session_id: "main", error: "..." } // Another client of this session sent a message (the sender sees its own -// optimistically; actor_id is who sent it, matching the stored row) -{ type: "user_message", session_id: "main", content: "Hello", blocks: null, actor_id: "…" } +// optimistically; actor_id is who sent it and caused_by_actor_id the cause of +// the turn it starts, matching the stored row) +{ type: "user_message", session_id: "main", content: "Hello", blocks: null, actor_id: "…", caused_by_actor_id: "…" } // Session switch confirmed (includes running state, lifecycle status, buffered events for reconnect) { type: "session_status", session_id: "abc123", is_running: true, status: "active", buffered_events: [...] } diff --git a/docs/tasks.md b/docs/tasks.md index 4a571f75f..9fa3fa310 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -119,8 +119,9 @@ Two rules keep ranks meaningful: ### Status History Every status transition is appended to `task_events` (`task_id`, -`from_status`, `to_status`, `actor`, `created_at`), added in migration v044 -and seeded with one origin row per existing task. `from_status` is NULL on +`from_status`, `to_status`, `actor`, `actor_id`, `caused_by_actor_id`, +`created_at`), added in migration v044 and seeded with one origin row per +existing task. `from_status` is NULL on the row recording a task's creation, so an aging calculation can tell "created here" from "moved here". @@ -134,11 +135,22 @@ rewrites every row with the status it already had. The one case where reindex *does* change a status, resetting an orphaned row to match its directory, is a real correction and gets a row. -`actor` is the session id for agent-driven changes, `web` for the HTTP API, -`backfill` on the origin rows v044 seeded for tasks that predate the table, -and `system` otherwise. `backfill` is load-bearing rather than cosmetic: a -real creation also records a NULL `from_status`, so the actor is the only -thing distinguishing a synthesized origin from one that actually happened. +`actor` is the origin of the change, not an identity: the session id for +agent-driven changes, `web` for the HTTP API, `backfill` on the origin rows +v044 seeded for tasks that predate the table, and `system` otherwise. +`backfill` is load-bearing rather than cosmetic: a real creation also records +a NULL `from_status`, so the actor is the only thing distinguishing a +synthesized origin from one that actually happened. + +`actor_id` and `caused_by_actor_id` (migration v053) name who made the change +and on whose behalf, as actor IDs (see +[Attribution](accounts.md#attribution)). A change through the HTTP API records +the person as both. A change by an agent tool call records the system actor +and the cause of the turn: the person whose message started the turn, or the +system actor for a cron job. Rows written before v053 have NULL in both +columns. The `GET /api/tasks/{id}/events` rows include both columns, and the +timeline shows "Nerve for " when the agent changed the status for a +person. This powers the aging indicator on board cards and the timeline in the task detail view. Tasks whose last transition predates v044 report no entry time From dddf58126266baad80699ff01a5f934dc7733954 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:22:47 +0000 Subject: [PATCH 04/17] Bind a turn's cause to the turn and to the work it starts 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 --- nerve/agent/engine.py | 110 +++++-- nerve/db/actor_columns.py | 1 + nerve/db/messages.py | 15 - nerve/db/migrations/v053_cause_attribution.py | 7 +- nerve/db/sessions.py | 11 + tests/test_autonomous_turns.py | 3 + tests/test_cause_attribution.py | 296 ++++++++++++++---- tests/test_resume_after_restart.py | 14 +- 8 files changed, 348 insertions(+), 109 deletions(-) diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index b4988d40f..e64c1d620 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -255,12 +255,20 @@ def __init__(self, config: NerveConfig, db: Database): # Read by session-scoped tools (send_file) to avoid dispatching via # stale router context from a prior inbound channel. self._active_channel: dict[str, str] = {} - # Cause actor ID of each session's current turn: the principal on - # whose behalf the turn runs (None = unknown). run() sets it under the - # session lock before it fetches the client, and keeps it when the - # turn ends, so autonomous CLI turns between runs keep the cause of - # the last turn. Read through session_cause(). + # Cause actor ID of the agent turn each session's runtime runs now, or + # ran last: the principal on whose behalf the turn runs (None = + # unknown). Set by _begin_runtime_turn when a turn starts: a run() + # turn after the pre-query drain, an autonomous turn when the drain + # opens it. Read through session_cause(). self._turn_causes: dict[str, str | None] = {} + # Cause of each live background task (Bash/Agent run_in_background, + # Monitor): the cause of the turn that started it. session_id -> + # task_id -> cause. + self._bg_task_causes: dict[str, dict[str, str | None]] = {} + # Cause for a session's next autonomous turn: the cause of the + # background task that settled last. The CLI starts that turn to + # report the settled task. + self._autonomous_causes: dict[str, str | None] = {} # Resolved model bound to each session's live SDK client. Used to # detect mid-session model switches (the CLI fixes its model at # connect time, so a change requires recreating the client). @@ -383,15 +391,39 @@ def get_active_channel(self, session_id: str) -> str | None: return self._active_channel.get(session_id) def session_cause(self, session_id: str) -> str | None: - """The cause actor ID of the session's current turn. - - While no turn runs, the cause of the session's last turn: autonomous - CLI turns between runs (a background task that settles) continue the - work of that turn. ``None`` when the cause is unknown, including a - session with no turn in this process. + """The cause actor ID of the agent turn that the session's runtime runs. + + A run() turn takes this value when its query starts, after the + autonomous output that buffered before it has drained under the cause + of the turn that produced it. An autonomous turn takes the cause of + the background task whose settlement started it. While no turn runs, + the value of the last turn stays. ``None`` when the cause is unknown, + including a session with no turn in this process. """ return self._turn_causes.get(session_id) + async def _begin_runtime_turn(self, session_id: str, cause_id: str | None) -> None: + """Make ``cause_id`` the cause of the turn that the runtime starts now. + + The value is also stored on the session row, so that a restart can + resume the interrupted turn for the same cause. A failed write only + costs that recovery, so it does not stop the turn. + """ + if session_id in self._turn_causes and self._turn_causes[session_id] == cause_id: + return + self._turn_causes[session_id] = cause_id + try: + await self.db.set_session_turn_cause(session_id, cause_id) + except Exception as e: + logger.warning( + "Could not store the turn cause of session %s: %s", session_id, e, + ) + + def _forget_background_causes(self, session_id: str) -> None: + """Drop the causes of background tasks that died with their client.""" + self._bg_task_causes.pop(session_id, None) + self._autonomous_causes.pop(session_id, None) + # ------------------------------------------------------------------ # # Backend resolution (sticky per session) # # ------------------------------------------------------------------ # @@ -1472,6 +1504,7 @@ async def _discard_client( subsequent fires) long after the agent turn had finished. """ self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) if background_memorize: await self.schedule_memorize(session_id) else: @@ -1695,13 +1728,10 @@ async def resume_enrolled_sessions(self) -> int: try: logger.info("Resume-after-restart: continuing session %s", sid) # The continuation finishes the interrupted turn, so it runs - # for the cause stored on that turn's user row. Without one - # (an old row, input with no known person) it is the - # instance's own work. - cause = ( - await self.db.latest_user_turn_cause(sid) - or self.db.system_actor - ) + # for the cause stored when that turn started. Without one + # (a row older than v053, input with no known person) it is + # the instance's own work. + cause = session.get("turn_caused_by_actor_id") or self.db.system_actor await self.run( session_id=sid, user_message=_RESUME_AFTER_RESTART_PROMPT, @@ -2072,6 +2102,8 @@ async def _handle_system_event( if not task_id: return + self._track_background_cause(session_id, subtype, task_id, data) + registry = self._bg_task_registry.setdefault(session_id, {}) entry = registry.get(task_id) if entry is None: @@ -2145,6 +2177,26 @@ async def _handle_system_event( "tasks": list(registry.values()), }) + def _track_background_cause( + self, session_id: str, subtype: str, task_id: str, data: dict, + ) -> None: + """Bind a background task to the cause of the turn that started it. + + When the task settles, its cause becomes the cause of the session's + next autonomous turn, which the runtime starts to report the result. + """ + causes = self._bg_task_causes.setdefault(session_id, {}) + if subtype == "task_started": + causes.setdefault(task_id, self._turn_causes.get(session_id)) + return + settled = subtype == "task_notification" or ( + subtype == "task_updated" + and str((data.get("patch") or {}).get("status") or "") + in self._BG_TERMINAL_STATUSES + ) + if settled and task_id in causes: + self._autonomous_causes[session_id] = causes.pop(task_id) + async def _emit_workflow_progress( self, session_id: str, @@ -2546,10 +2598,10 @@ async def run( The default is the actor: the person for a person, the system actor for the system actor, unknown for no actor. Pass the person whose request started work that - the system actor does. The cause is stored on the - turn's user row, given to the turn's tool calls, and - readable through :meth:`session_cause` for the length - of the turn and after it. + the system actor does. The cause is fixed for the + turn: it is stored on the turn's user row, becomes + :meth:`session_cause` when the turn's query starts, and + goes to the background tasks that the turn starts. internal: If True, the user_message is a system-generated trigger (e.g., background task completion) and won't be stored in DB or shown in the UI. @@ -2585,10 +2637,6 @@ async def run( self.sessions.mark_running(session_id) if channel is not None: self._active_channel[session_id] = channel - # Before _run_inner, so the cause is known when the client is - # fetched or built. Kept after the turn ends; see - # session_cause(). - self._turn_causes[session_id] = cause_id # Mark the turn as in flight so the finally below can # detect "ended without sending done/stopped/error" and # ship a synthetic done. Clearing happens automatically @@ -2773,6 +2821,10 @@ async def _run_inner( # done backstop still covers THIS turn. broadcaster.mark_turn_open(session_id) + # This turn starts in the runtime now. The drain above ran under + # the cause of the work it drained. + await self._begin_runtime_turn(session_id, cause_id) + # New user turn: settled background-task chips are stale now. self._prune_bg_tasks(session_id) @@ -3123,6 +3175,12 @@ async def _open_turn() -> None: if st is not None: return st = _TurnState() + # The runtime reports a settled background task, so the turn + # continues the work of the turn that started the task. + if session_id in self._autonomous_causes: + await self._begin_runtime_turn( + session_id, self._autonomous_causes.pop(session_id), + ) # Leading marker block → "background continuation" chip in the # UI, both live (auto_turn event) and after reload (persisted). st.ordered_blocks.append({"type": "auto"}) diff --git a/nerve/db/actor_columns.py b/nerve/db/actor_columns.py index d9f610e44..83fcbf8b7 100644 --- a/nerve/db/actor_columns.py +++ b/nerve/db/actor_columns.py @@ -17,4 +17,5 @@ ("messages", "caused_by_actor_id"), ("task_events", "actor_id"), ("task_events", "caused_by_actor_id"), + ("sessions", "turn_caused_by_actor_id"), ) diff --git a/nerve/db/messages.py b/nerve/db/messages.py index 4126613f9..32a56536e 100644 --- a/nerve/db/messages.py +++ b/nerve/db/messages.py @@ -369,21 +369,6 @@ async def merge_workflow_into_call( return row["id"] return None - async def latest_user_turn_cause(self, session_id: str) -> str | None: - """The cause stored on the session's newest user row. - - ``None`` when the session has no user row, or when that row has no - cause (input with no known person, or a row older than v053). - """ - async with self.db.execute( - """SELECT caused_by_actor_id FROM messages - WHERE session_id = ? AND role = 'user' - ORDER BY id DESC LIMIT 1""", - (session_id,), - ) as cursor: - row = await cursor.fetchone() - return row[0] if row else None - async def get_messages( self, session_id: str, limit: int = 500, offset: int = 0 ) -> list[dict]: diff --git a/nerve/db/migrations/v053_cause_attribution.py b/nerve/db/migrations/v053_cause_attribution.py index c28a1f78a..96ebbc981 100644 --- a/nerve/db/migrations/v053_cause_attribution.py +++ b/nerve/db/migrations/v053_cause_attribution.py @@ -1,12 +1,16 @@ """V53: record the cause of a turn and who changed a task's status. -Adds three nullable columns that hold an ``actor_refs.id``: +Adds four nullable columns that hold an ``actor_refs.id``: - ``messages.caused_by_actor_id``: on a user row that starts a turn, the principal on whose behalf the turn runs. That is the person who sent the message, or the person whose request started work by the system actor, or the system actor for autonomous work. Assistant and tool output rows keep it ``NULL``. +- ``sessions.turn_caused_by_actor_id``: the cause of the agent turn that the + session runs now, or ran last. It is written when the turn starts, so after + a restart it names the cause of the interrupted turn, also of an internal + turn that has no user row. - ``task_events.actor_id``: who changed the status. A person through the UI or REST, or the system actor for an agent tool call or another internal process. @@ -35,6 +39,7 @@ # (table, column, DDL type + constraints) _COLUMNS = ( ("messages", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), + ("sessions", "turn_caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), ("task_events", "actor_id", "TEXT REFERENCES actor_refs(id)"), ("task_events", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), ) diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index e20941ab8..e18bda875 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -322,6 +322,17 @@ async def update_session_fields(self, session_id: str, fields: dict) -> None: return await self._write(*built) + async def set_session_turn_cause(self, session_id: str, cause_id: str | None) -> None: + """Store the cause of the agent turn that the session starts now. + + Written when the turn starts, so after a restart the row names the + cause of the interrupted turn. ``None`` stores an unknown cause. + """ + await self._write( + "UPDATE sessions SET turn_caused_by_actor_id = ? WHERE id = ?", + (cause_id, session_id), + ) + async def bind_native_thread( self, backend: str, native_thread_id: str, session_id: str, ) -> None: diff --git a/tests/test_autonomous_turns.py b/tests/test_autonomous_turns.py index 9033f19b0..7f9ea740e 100644 --- a/tests/test_autonomous_turns.py +++ b/tests/test_autonomous_turns.py @@ -41,6 +41,9 @@ def _make_engine() -> AgentEngine: ), ) engine._bg_task_registry = {} + engine._bg_task_causes = {} + engine._autonomous_causes = {} + engine._turn_causes = {} engine._workflows = {} engine._idle_watchers = {} engine._session_locks = {} diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 6533c8bf9..618fd37f2 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -21,6 +21,7 @@ import pytest_asyncio from fastapi import FastAPI +from nerve.agent.backends import SystemEvent, TextDelta, TurnCompleted from nerve.agent.engine import AgentEngine from nerve.agent.tools.handlers.plans import plan_approve_handler from nerve.agent.tools.registry import ToolContext @@ -112,14 +113,76 @@ def test_a_context_without_identity_is_unattributed(self): # --------------------------------------------------------------------------- # +class _FakeRuntime: + """A model runtime client that makes tool calls the way the agent does. + + The engine uses it through the client API (``start_turn``, + ``receive_turn``, the idle-event calls). Each turn calls ``task_create`` + through the session's own tool context, as the Claude CLI calls the + in-process MCP server, and then completes. + + - ``turn_events``: events that the next turn emits before its tool call, + for example a background task that it starts. + - ``buffered``: autonomous output that arrived between runs; the engine + drains it before the next turn. + - ``in_flight``: for an autonomous turn in flight, pairs of (tool call + first?, next batch) that ``receive_idle_events`` returns. + - ``fail``: the next turn dies after its first output, like a runtime + that is killed mid-turn (a restart). The engine does not retry a turn + that has output. + """ + + native_session_id = None + + def __init__(self, install: "_Install", session_id: str): + self.install = install + self.session_id = session_id + self.turn_events: list = [] + self.buffered: list[list] = [] + self.in_flight: list[tuple[bool, list]] = [] + self.before_tool_call = None + self.fail = False + + def try_receive_idle_events(self): + return self.buffered.pop(0) if self.buffered else None + + async def receive_idle_events(self, timeout=None): + if not self.in_flight: + return None + tool_call, batch = self.in_flight.pop(0) + if tool_call: + await self.install.agent_tool_call(self.session_id) + return batch + + async def start_turn(self, turn_input) -> None: + return None + + async def disconnect(self) -> None: + return None + + async def receive_turn(self): + events, self.turn_events = self.turn_events, [] + for event in events: + yield event + if self.fail: + self.fail = False + yield TextDelta(text="Working on it") + raise RuntimeError("the runtime died mid-turn") + if self.before_tool_call is not None: + await self.before_tool_call() + await self.install.agent_tool_call(self.session_id) + yield TurnCompleted() + + class _Install: def __init__(self, db: Database, engine: AgentEngine, config: NerveConfig): self.db = db self.engine = engine self.config = config self.people: dict[str, dict] = {} - # Records of the model runtime stand-in, one per turn. + # One record per tool call of the fake runtime. self.turns: list[dict] = [] + self.runtimes: dict[str, _FakeRuntime] = {} @property def system_actor_id(self) -> str: @@ -151,42 +214,45 @@ async def user_rows(self, session_id: str) -> list[tuple]: if row["role"] == "user" ] - def agent_creates_a_task_in_each_turn(self, monkeypatch, barrier: int = 0) -> None: - """Replace the model runtime with an agent that calls ``task_create``. - - The stand-in runs where the real client is fetched, inside the turn, - and calls the tool through the session's own tool context, like the - Claude backend's in-process MCP server. With ``barrier`` > 0, each - turn waits until that many turns are in flight before its tool call. - """ + async def agent_tool_call(self, session_id: str) -> None: + """The agent calls ``task_create`` in the session's current turn.""" engine = self.engine - arrived = 0 - all_in = asyncio.Event() - - async def _agent(session_id, source, model, **kwargs): - nonlocal arrived - cause_at_start = engine.session_cause(session_id) - if barrier: - arrived += 1 - if arrived >= barrier: - all_in.set() - await asyncio.wait_for(all_in.wait(), timeout=5) - ctx = engine._build_tool_context(session_id) - result = await engine.registry.invoke( - "task_create", ctx, - {"title": f"Made in {session_id}", "confirm_duplicate": True}, - ) - self.turns.append({ - "session_id": session_id, - "cause": cause_at_start, - "task_id": (result.structured or {}).get("task_id"), - }) - raise RuntimeError("no model in this test") - - monkeypatch.setattr(engine, "_get_or_create_client", _agent) + cause = engine.session_cause(session_id) + result = await engine.registry.invoke( + "task_create", engine._build_tool_context(session_id), + {"title": f"Made in {session_id} #{len(self.turns)}", + "confirm_duplicate": True}, + ) + self.turns.append({ + "session_id": session_id, + "cause": cause, + "task_id": (result.structured or {}).get("task_id"), + }) + + def use_fake_runtime(self, monkeypatch) -> None: + """Serve every session's turns from a :class:`_FakeRuntime`.""" + + async def _client(session_id, source, model, **kwargs): + if session_id not in self.runtimes: + self.runtimes[session_id] = _FakeRuntime(self, session_id) + return self.runtimes[session_id] + + monkeypatch.setattr(self.engine, "_get_or_create_client", _client) + + def runtime(self, session_id: str) -> _FakeRuntime: + if session_id not in self.runtimes: + self.runtimes[session_id] = _FakeRuntime(self, session_id) + return self.runtimes[session_id] + + def turns_in(self, session_id: str) -> list[dict]: + return [turn for turn in self.turns if turn["session_id"] == session_id] def turn_in(self, session_id: str) -> dict: - return next(turn for turn in self.turns if turn["session_id"] == session_id) + (turn,) = self.turns_in(session_id) + return turn + + async def turn_cause_of(self, session_id: str) -> str | None: + return (await self.db.get_session(session_id))["turn_caused_by_actor_id"] @pytest_asyncio.fixture @@ -389,7 +455,7 @@ async def test_task_status_definition_changes_take_the_person(install, caplog): async def test_a_tool_change_inside_a_persons_turn_is_system_for_that_person( install, monkeypatch, ): - install.agent_creates_a_task_in_each_turn(monkeypatch) + install.use_fake_runtime(monkeypatch) bob = install.actor_id("bob") async with _client(install.app()) as client: session_id = await _new_session(client, install.headers("bob")) @@ -401,13 +467,14 @@ async def test_a_tool_change_inside_a_persons_turn_is_system_for_that_person( assert await install.events(turn["task_id"]) == [ (None, "pending", session_id, install.system_actor_id, bob), ] - # Autonomous CLI turns between runs keep the last cause. + assert await install.turn_cause_of(session_id) == bob + # The value stays after the turn, for autonomous turns between runs. assert install.engine.session_cause(session_id) == bob @pytest.mark.asyncio async def test_a_cron_turns_tool_change_is_system_for_system(install, monkeypatch): - install.agent_creates_a_task_in_each_turn(monkeypatch) + install.use_fake_runtime(monkeypatch) await install.engine.run_cron(job_id="nightly", prompt="tidy the board") turn = install.turns[0] @@ -423,11 +490,18 @@ async def test_a_cron_turns_tool_change_is_system_for_system(install, monkeypatc @pytest.mark.asyncio async def test_concurrent_turns_of_two_people_keep_their_causes(install, monkeypatch): - install.agent_creates_a_task_in_each_turn(monkeypatch, barrier=2) + install.use_fake_runtime(monkeypatch) alice, bob = install.actor_id("alice"), install.actor_id("bob") + both_in_flight = asyncio.Barrier(2) + + async def _wait_for_the_other_turn(): + await asyncio.wait_for(both_in_flight.wait(), timeout=5) + async with _client(install.app()) as client: hers = await _new_session(client, install.headers("alice")) his = await _new_session(client, install.headers("bob")) + install.runtime(hers).before_tool_call = _wait_for_the_other_turn + install.runtime(his).before_tool_call = _wait_for_the_other_turn await asyncio.gather( _chat(client, install.headers("alice"), hers, "from Alice"), _chat(client, install.headers("bob"), his, "from Bob"), @@ -441,9 +515,89 @@ async def test_concurrent_turns_of_two_people_keep_their_causes(install, monkeyp ] +@pytest.mark.asyncio +async def test_autonomous_work_keeps_the_cause_of_the_turn_that_started_it( + install, monkeypatch, +): + """Alice's background task settles while Bob starts a turn in her session. + + The continuation that reports Alice's task drains before Bob's turn. It + runs for Alice; Bob's turn runs for Bob. + """ + install.use_fake_runtime(monkeypatch) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runtime = install.runtime(session_id) + # Alice's turn starts a background task. + runtime.turn_events = [ + SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + ] + await _chat(client, install.headers("alice"), session_id, "run the build") + + # The task settles and the CLI starts a turn to report it, which + # buffers while no run is active. Its tool call comes mid-turn. + runtime.buffered = [[ + SystemEvent( + subtype="task_notification", + data={"task_id": "bg-1", "status": "completed"}, + ), + SystemEvent(subtype="init"), + TextDelta(text="The build passed."), + ]] + runtime.in_flight = [(True, [TurnCompleted()])] + await _chat(client, install.headers("bob"), session_id, "and now this") + + hers, continuation, his = install.turns_in(session_id) + assert (hers["cause"], continuation["cause"], his["cause"]) == (alice, alice, bob) + assert await install.events(continuation["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, alice), + ] + assert await install.events(his["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, bob), + ] + assert await install.turn_cause_of(session_id) == bob + + +@pytest.mark.asyncio +async def test_an_autonomous_turn_after_a_later_turn_keeps_the_starters_cause( + install, monkeypatch, +): + """A background task that Alice started settles after Bob's turn ended.""" + install.use_fake_runtime(monkeypatch) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runtime = install.runtime(session_id) + runtime.turn_events = [ + SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + ] + await _chat(client, install.headers("alice"), session_id, "start a long job") + await _chat(client, install.headers("bob"), session_id, "something else") + + # The idle watcher drains the continuation between runs. + runtime.buffered = [[ + SystemEvent( + subtype="task_updated", + data={"task_id": "bg-1", "patch": {"status": "completed"}}, + ), + SystemEvent(subtype="init"), + TextDelta(text="The long job finished."), + ]] + runtime.in_flight = [(True, [TurnCompleted()])] + await install.engine._drain_pending_messages( + session_id, runtime, "web", "web", manage_framing=True, + ) + + assert [turn["cause"] for turn in install.turns_in(session_id)] == [ + alice, bob, alice, + ] + assert install.engine.session_cause(session_id) == alice + + @pytest.mark.asyncio async def test_input_with_no_person_has_no_cause(install, monkeypatch): - install.agent_creates_a_task_in_each_turn(monkeypatch) + install.use_fake_runtime(monkeypatch) await install.db.create_session("chat-app", source="telegram", actor=None) await install.engine.run( "chat-app", "hello from a chat app", source="telegram", actor=None, @@ -461,7 +615,7 @@ async def test_input_with_no_person_has_no_cause(install, monkeypatch): async def test_an_explicit_unknown_cause_is_not_replaced_by_the_actor( install, monkeypatch, ): - install.agent_creates_a_task_in_each_turn(monkeypatch) + install.use_fake_runtime(monkeypatch) await install.db.create_session("leg", source="workflow", actor=None) await install.engine.run( "leg", "continue", source="workflow", @@ -471,27 +625,29 @@ async def test_an_explicit_unknown_cause_is_not_replaced_by_the_actor( assert await install.user_rows("leg") == [("continue", install.system_actor_id, None)] +async def _restart_and_resume(install: _Install, session_id: str, tmp_path) -> None: + """Restart the process and resume ``session_id`` from the resume queue.""" + await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) + # The new process has no turn causes in memory. + install.engine._turn_causes.clear() + queue = tmp_path / "resume-after-restart" + queue.write_text(f"{session_id}\n") + with patch("nerve.agent.engine.RESUME_QUEUE_FILE", queue): + assert await install.engine.resume_enrolled_sessions() == 1 + + @pytest.mark.asyncio async def test_resume_after_restart_keeps_the_cause_of_the_interrupted_turn( install, monkeypatch, tmp_path, ): + install.use_fake_runtime(monkeypatch) alice = install.actor_id("alice") async with _client(install.app()) as client: session_id = await _new_session(client, install.headers("alice")) - monkeypatch.setattr( - install.engine, "_get_or_create_client", - _raise_no_model, - ) + install.runtime(session_id).fail = True await _chat(client, install.headers("alice"), session_id, "long job") - await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) - # A restart: the new process has no turn cause in memory. - install.engine._turn_causes.clear() - install.agent_creates_a_task_in_each_turn(monkeypatch) - queue = tmp_path / "resume-after-restart" - queue.write_text(f"{session_id}\n") - with patch("nerve.agent.engine.RESUME_QUEUE_FILE", queue): - assert await install.engine.resume_enrolled_sessions() == 1 + await _restart_and_resume(install, session_id, tmp_path) turn = install.turn_in(session_id) assert turn["cause"] == alice @@ -500,8 +656,35 @@ async def test_resume_after_restart_keeps_the_cause_of_the_interrupted_turn( ] -async def _raise_no_model(*args, **kwargs): - raise RuntimeError("no model in this test") +@pytest.mark.asyncio +async def test_resume_of_an_interrupted_internal_turn_is_not_a_persons( + install, monkeypatch, tmp_path, +): + """The interrupted turn is internal, so it has no user row. + + The newest user row is Alice's, from an earlier turn. The resumed work is + the system actor's own. + """ + install.use_fake_runtime(monkeypatch) + alice = install.actor_id("alice") + system = install.system_actor_id + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + await _chat(client, install.headers("alice"), session_id, "hello") + install.runtime(session_id).fail = True + await install.engine.run( + session_id, "[system tick]", internal=True, actor=install.db.system_actor, + ) + assert await install.user_rows(session_id) == [("hello", alice, alice)] + assert await install.turn_cause_of(session_id) == system + + await _restart_and_resume(install, session_id, tmp_path) + + resumed = install.turns_in(session_id)[-1] + assert resumed["cause"] == system + assert await install.events(resumed["task_id"]) == [ + (None, "pending", session_id, system, system), + ] @pytest.mark.asyncio @@ -521,7 +704,6 @@ async def test_run_later_and_fork_keep_the_cause_on_user_rows(install): assert await install.user_rows(session_id) == [("later", alice, alice)] assert await install.user_rows(fork.json()["id"]) == [("later", alice, alice)] - assert await install.db.latest_user_turn_cause(session_id) == alice # --------------------------------------------------------------------------- # @@ -653,7 +835,7 @@ async def test_mcp_contexts_carry_the_system_actor_and_the_bound_turn_cause( async def test_the_actor_directory_names_every_id_in_every_registered_column( install, monkeypatch, ): - install.agent_creates_a_task_in_each_turn(monkeypatch) + install.use_fake_runtime(monkeypatch) async with _client(install.app()) as client: session_id = await _new_session(client, install.headers("alice")) await _chat(client, install.headers("bob"), session_id, "make a task") @@ -727,7 +909,7 @@ async def test_external_mode_records_principals_as_actor_and_cause( external_install, monkeypatch, ): install = external_install - install.agent_creates_a_task_in_each_turn(monkeypatch) + install.use_fake_runtime(monkeypatch) alice, bob = _as(ALICE_PRINCIPAL, "Alice"), _as(BOB_PRINCIPAL, "Bob") async with _client(install.app()) as client: task_id = (await client.post( diff --git a/tests/test_resume_after_restart.py b/tests/test_resume_after_restart.py index 8f93540e7..361fe0157 100644 --- a/tests/test_resume_after_restart.py +++ b/tests/test_resume_after_restart.py @@ -21,22 +21,15 @@ # Daemon side: AgentEngine.resume_enrolled_sessions # # --------------------------------------------------------------------------- # -def _engine_with(sessions_by_id: dict, causes: dict | None = None) -> AgentEngine: - """Bare AgentEngine stub exercising only resume_enrolled_sessions' deps. - - ``causes`` maps a session ID to the cause stored on its newest user row. - """ +def _engine_with(sessions_by_id: dict) -> AgentEngine: + """Bare AgentEngine stub exercising only resume_enrolled_sessions' deps.""" engine = AgentEngine.__new__(AgentEngine) async def _get_session(sid): return sessions_by_id.get(sid) - async def _latest_user_turn_cause(sid): - return (causes or {}).get(sid) - engine.db = SimpleNamespace( get_session=AsyncMock(side_effect=_get_session), - latest_user_turn_cause=AsyncMock(side_effect=_latest_user_turn_cause), # The resume trigger is the instance's own, so the drainer passes the # system actor to run(). system_actor=FAKE_SYSTEM_ACTOR, @@ -87,7 +80,8 @@ async def test_resumes_valid_session_and_drains_file(tmp_path): async def test_resume_runs_for_the_cause_of_the_interrupted_turn(tmp_path): qf = tmp_path / "resume-after-restart" qf.write_text("S1\n") - engine = _engine_with({"S1": _session("S1")}, causes={"S1": ALICE_ACTOR_ID}) + session = {**_session("S1"), "turn_caused_by_actor_id": ALICE_ACTOR_ID} + engine = _engine_with({"S1": session}) with patch("nerve.agent.engine.RESUME_QUEUE_FILE", qf): assert await engine.resume_enrolled_sessions() == 1 kwargs = engine.run.await_args.kwargs From e460470aad48b422379770758601f013e1755573 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:23:35 +0000 Subject: [PATCH 05/17] Keep a concurrent status change when PATCH saves content 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 --- nerve/db/tasks.py | 15 +++++++++--- nerve/gateway/routes/tasks.py | 12 +++++---- tests/test_cause_attribution.py | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/nerve/db/tasks.py b/nerve/db/tasks.py index 3e834ed4b..2e58fa379 100644 --- a/nerve/db/tasks.py +++ b/nerve/db/tasks.py @@ -59,9 +59,9 @@ class TaskStore: async def upsert_task( self, task_id: str, - file_path: str, + file_path: str | _Keep, title: str, - status: str = "pending", + status: str | _Keep = "pending", source: str | None | _Keep = KEEP, source_url: str | None | _Keep = KEEP, deadline: str | None | _Keep = KEEP, @@ -77,6 +77,11 @@ async def upsert_task( ``file_path``, ``title``, ``status`` and ``content`` are a full replace. Every caller rebuilds them from the markdown file. + ``file_path`` and ``status`` also take :data:`KEEP`, which keeps the + stored value, read in the same transaction as the write. A caller that + edits only the content passes it, so that a status change that lands + between its read and this write is kept and is not recorded again. + :data:`KEEP` needs an existing row. ``source``, ``source_url``, ``deadline``, ``tags`` and ``position`` are preserve-on-omit. An omitted column keeps its stored value. To @@ -108,6 +113,10 @@ async def upsert_task( # write. The statement below stays a plain full replace, and the # FTS text sees the tags that actually land in the row. stored = await self._stored_task_columns(task_id) + if stored is None and (file_path is KEEP or status is KEEP): + raise ValueError(f"task {task_id!r} has no row to keep values from") + file_path = _resolve_kept(file_path, stored, "file_path", None) + status = _resolve_kept(status, stored, "status", None) source = _resolve_kept(source, stored, "source", None) source_url = _resolve_kept(source_url, stored, "source_url", None) deadline = _resolve_kept(deadline, stored, "deadline", None) @@ -343,7 +352,7 @@ async def _lane_top_position(self, status: str) -> float: async def _stored_task_columns(self, task_id: str) -> dict | None: """The columns an upsert can preserve, or ``None`` for a new task.""" async with self.db.execute( - "SELECT status, position, source, source_url, deadline, tags " + "SELECT status, file_path, position, source, source_url, deadline, tags " "FROM tasks WHERE id = ?", (task_id,), ) as cursor: diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index 035ac957f..16297b303 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -11,6 +11,7 @@ from nerve.agent.streaming import emit_task_event from nerve.config import ensure_path_not_tracked_config, get_config from nerve.db.task_statuses import STATUS_NAME_RE, TERMINAL_STATUS, normalize_color +from nerve.db.tasks import KEEP from nerve.gateway.auth import require_auth from nerve.gateway.routes._deps import ( build_route_tool_context, @@ -358,14 +359,15 @@ async def update_task( edits["deadline"] = fields["deadline"] if fields.get("tags"): edits["tags"] = tags_to_string(parse_tags_string(fields["tags"])) - # The stored status, not ``req.status``: a status change goes - # through task_update below, which moves the file into or out of - # done/ and records the transition once, for this person. + # KEEP, not ``req.status`` or the status read above: a status + # change goes through task_update below, which moves the file + # into or out of done/ and records the transition once, for this + # person. A move by somebody else after the read is kept. await deps.db.upsert_task( task_id=task_id, - file_path=task["file_path"], + file_path=KEEP, title=new_title, - status=task["status"], + status=KEEP, content=req.content, actor=_ROUTE_ORIGIN, attribution=attribution_for(actor), diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 618fd37f2..78c2d9d08 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -434,6 +434,49 @@ async def test_rest_patch_with_content_records_one_change_for_the_person(install assert (install.config.workspace / task["file_path"]).is_file() +@pytest.mark.asyncio +async def test_rest_patch_content_keeps_a_concurrent_move(install, monkeypatch): + """A move that lands while PATCH writes the file is not undone. + + Alice saves new content while Bob moves the card. The content save must + not write back the status that it read before Bob's move. + """ + from nerve.gateway.routes import tasks as tasks_route + + alice, bob = install.actor_id("alice"), install.actor_id("bob") + loop = asyncio.get_running_loop() + real_write = tasks_route.write_task_file + + def write_while_bob_moves(path, content): + real_write(path, content) + asyncio.run_coroutine_threadsafe( + install.db.move_task( + task_id, status="deferred", actor="web", + attribution=attribution_for(bob), + ), + loop, + ).result(timeout=5) + + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, + )).json()["task"]["id"] + monkeypatch.setattr(tasks_route, "write_task_file", write_while_bob_moves) + response = await client.patch( + f"/api/tasks/{task_id}", headers=install.headers("alice"), + json={"content": "# Raced\n\nNew body.\n"}, + ) + assert response.status_code == 200 + + assert await install.events(task_id) == [ + (None, "pending", "web", alice, alice), + ("pending", "deferred", "web", bob, bob), + ] + task = await install.db.get_task(task_id) + assert task["status"] == "deferred" + assert "New body." in (install.config.workspace / task["file_path"]).read_text() + + @pytest.mark.asyncio async def test_task_status_definition_changes_take_the_person(install, caplog): caplog.set_level("INFO", logger="nerve.gateway.routes.tasks") From a707ec1db3484d54a26f8c58de2987922d42ba5f Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:24:37 +0000 Subject: [PATCH 06/17] Record who changes the set of task statuses 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 --- nerve/agent/tools/handlers/tasks.py | 1 + nerve/db/actor_columns.py | 2 + nerve/db/migrations/v053_cause_attribution.py | 27 ++++- nerve/db/task_statuses.py | 100 ++++++++++++++---- nerve/gateway/routes/tasks.py | 24 ++--- tests/test_cause_attribution.py | 48 +++++++-- 6 files changed, 155 insertions(+), 47 deletions(-) diff --git a/nerve/agent/tools/handlers/tasks.py b/nerve/agent/tools/handlers/tasks.py index 4289c8a95..25203e79a 100644 --- a/nerve/agent/tools/handlers/tasks.py +++ b/nerve/agent/tools/handlers/tasks.py @@ -872,6 +872,7 @@ async def task_status_create_handler(ctx: ToolContext, args: dict) -> ToolResult created = await ctx.db.create_task_status( name=name, label=label, color=color, description=description, + attribution=ctx.attribution, ) return ToolResult.text( f"Created task status '{created['name']}' " diff --git a/nerve/db/actor_columns.py b/nerve/db/actor_columns.py index 83fcbf8b7..62224621f 100644 --- a/nerve/db/actor_columns.py +++ b/nerve/db/actor_columns.py @@ -18,4 +18,6 @@ ("task_events", "actor_id"), ("task_events", "caused_by_actor_id"), ("sessions", "turn_caused_by_actor_id"), + ("task_status_events", "actor_id"), + ("task_status_events", "caused_by_actor_id"), ) diff --git a/nerve/db/migrations/v053_cause_attribution.py b/nerve/db/migrations/v053_cause_attribution.py index 96ebbc981..9b3ef542b 100644 --- a/nerve/db/migrations/v053_cause_attribution.py +++ b/nerve/db/migrations/v053_cause_attribution.py @@ -1,4 +1,4 @@ -"""V53: record the cause of a turn and who changed a task's status. +"""V53: record the cause of a turn and who changes tasks and task statuses. Adds four nullable columns that hold an ``actor_refs.id``: @@ -19,6 +19,11 @@ ``task_events.actor`` stays. It is the origin of the change (``web``, a session ID, ``system``, ``backfill``), not an identity. +Adds the table ``task_status_events``: one append-only row for each change to +the set of task statuses (``created``, ``updated``, ``reordered``, +``deleted``), with its actor and cause. A status definition row can be +deleted, so its history cannot live on the row. + **Existing rows stay NULL and nothing is backfilled.** NULL reads as "not recorded". The free-text origin of an old task event does not name a person, so a backfill would invent attribution. @@ -44,6 +49,23 @@ ("task_events", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), ) +# ``status_name`` is NULL for ``reordered``, which changes the order of all +# statuses; ``detail`` is a JSON object with the changed values. +_STATUS_EVENTS_SQL = """ +CREATE TABLE IF NOT EXISTS task_status_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + change TEXT NOT NULL, + status_name TEXT, + detail TEXT, + actor_id TEXT REFERENCES actor_refs(id), + caused_by_actor_id TEXT REFERENCES actor_refs(id), + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_task_status_events_created + ON task_status_events(created_at); +""" + async def _has_column(db: aiosqlite.Connection, table: str, column: str) -> bool: async with db.execute(f"PRAGMA table_info({table})") as cursor: @@ -60,7 +82,8 @@ async def up(db: aiosqlite.Connection) -> None: # NULL, so the table is not rebuilt and every existing row is NULL. await db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") added.append(f"{table}.{column}") + await db.executescript(_STATUS_EVENTS_SQL) logger.info( - "v053: cause and task attribution columns %s", + "v053: cause and task attribution columns %s; task_status_events ready", ", ".join(added) if added else "already present", ) diff --git a/nerve/db/task_statuses.py b/nerve/db/task_statuses.py index c77148b3d..ed5333d74 100644 --- a/nerve/db/task_statuses.py +++ b/nerve/db/task_statuses.py @@ -8,10 +8,13 @@ from __future__ import annotations +import json import random import re from datetime import datetime, timezone +from nerve.identity import UNATTRIBUTED, Attribution + # Status names are slug-like so they're safe to store on tasks.status and # render without escaping: lowercase alphanumerics + underscores, starting # with an alphanumeric. @@ -59,7 +62,39 @@ def normalize_color(color: str | None) -> str: class TaskStatusStore: - """Mixin providing CRUD for the configurable ``task_statuses`` table.""" + """Mixin providing CRUD for the configurable ``task_statuses`` table. + + Every change also appends a row to ``task_status_events`` in the same + transaction, with the ``attribution`` that the caller passes. + """ + + async def _record_status_def_event( + self, + change: str, + status_name: str | None, + detail: dict | None, + attribution: Attribution, + ) -> None: + """Append a status definition change. Caller must hold ``_atomic()``.""" + await self.db.execute( + """INSERT INTO task_status_events + (change, status_name, detail, actor_id, caused_by_actor_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + change, status_name, + json.dumps(detail, sort_keys=True) if detail else None, + attribution.actor_id, attribution.caused_by_actor_id, + datetime.now(timezone.utc).isoformat(), + ), + ) + + async def list_task_status_events(self, limit: int = 200) -> list[dict]: + """Status definition changes, oldest first.""" + async with self.db.execute( + "SELECT * FROM task_status_events ORDER BY created_at ASC, id ASC LIMIT ?", + (limit,), + ) as cursor: + return [dict(row) async for row in cursor] async def list_task_statuses(self) -> list[dict]: """Return all status definitions ordered by sort_order then name.""" @@ -68,7 +103,9 @@ async def list_task_statuses(self) -> list[dict]: ) as cursor: return [dict(row) async for row in cursor] - async def reorder_task_statuses(self, names: list[str]) -> list[dict]: + async def reorder_task_statuses( + self, names: list[str], *, attribution: Attribution = UNATTRIBUTED, + ) -> list[dict]: """Rewrite ``sort_order`` to match the given sequence of names. Takes the whole desired order rather than one status's new index: @@ -93,6 +130,9 @@ async def reorder_task_statuses(self, names: list[str]) -> list[dict]: "UPDATE task_statuses SET sort_order = ? WHERE name = ?", [(i, name) for i, name in enumerate(ordered)], ) + await self._record_status_def_event( + "reordered", None, {"order": ordered}, attribution, + ) return await self.list_task_statuses() @@ -117,6 +157,8 @@ async def create_task_status( color: str | None = None, description: str = "", is_system: int = 0, + *, + attribution: Attribution = UNATTRIBUTED, ) -> dict: """Insert a new status definition. Caller validates name uniqueness.""" now = datetime.now(timezone.utc).isoformat() @@ -124,14 +166,20 @@ async def create_task_status( "SELECT COALESCE(MAX(sort_order), -1) FROM task_statuses" ) as cur: next_order = (await cur.fetchone())[0] + 1 + color = normalize_color(color) async with self._atomic(): await self.db.execute( """INSERT INTO task_statuses (name, label, color, description, is_system, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)""", - (name, label, normalize_color(color), description, + (name, label, color, description, 1 if is_system else 0, next_order, now), ) + await self._record_status_def_event( + "created", name, + {"label": label, "color": color, "description": description}, + attribution, + ) return await self.get_task_status_def(name) # type: ignore[return-value] async def update_task_status_def( @@ -142,33 +190,43 @@ async def update_task_status_def( color: str | None = None, description: str | None = None, sort_order: int | None = None, + attribution: Attribution = UNATTRIBUTED, ) -> None: """Patch mutable fields of a status definition (never the name).""" - sets: list[str] = [] - params: list = [] + changes: dict = {} if label is not None: - sets.append("label = ?") - params.append(label) + changes["label"] = label if color is not None: - sets.append("color = ?") - params.append(normalize_color(color)) + changes["color"] = normalize_color(color) if description is not None: - sets.append("description = ?") - params.append(description) + changes["description"] = description if sort_order is not None: - sets.append("sort_order = ?") - params.append(sort_order) - if not sets: + changes["sort_order"] = sort_order + if not changes: return - params.append(name) - await self._write( - f"UPDATE task_statuses SET {', '.join(sets)} WHERE name = ?", - tuple(params), - ) + async with self._atomic(): + cursor = await self.db.execute( + f"UPDATE task_statuses SET {', '.join(f'{c} = ?' for c in changes)} " + "WHERE name = ?", + (*changes.values(), name), + ) + updated = (cursor.rowcount or 0) == 1 + await cursor.close() + if updated: + await self._record_status_def_event("updated", name, changes, attribution) - async def delete_task_status_def(self, name: str) -> None: + async def delete_task_status_def( + self, name: str, *, attribution: Attribution = UNATTRIBUTED, + ) -> None: """Delete a status definition. Caller enforces protection rules.""" - await self._write("DELETE FROM task_statuses WHERE name = ?", (name,)) + async with self._atomic(): + cursor = await self.db.execute( + "DELETE FROM task_statuses WHERE name = ?", (name,), + ) + deleted = (cursor.rowcount or 0) == 1 + await cursor.close() + if deleted: + await self._record_status_def_event("deleted", name, None, attribution) async def count_tasks_with_status(self, name: str) -> int: """Count tasks currently set to the given status.""" diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index 16297b303..0cad7f44c 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import logging from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -21,8 +20,6 @@ from nerve.identity import Actor, attribution_for from nerve.tasks.files import write_task_file -logger = logging.getLogger(__name__) - router = APIRouter(dependencies=[Depends(require_auth)]) # ``task_events.actor`` for a change made through these routes: the origin of @@ -440,8 +437,8 @@ async def create_task_status( created = await deps.db.create_task_status( name=name, label=label, color=color, description=(req.description or "").strip(), + attribution=attribution_for(actor), ) - _log_status_change("created", name, actor) return created @@ -460,8 +457,9 @@ async def reorder_task_statuses( Declared above /{name} so "reorder" isn't captured as a status name. """ deps = get_deps() - statuses = await deps.db.reorder_task_statuses(req.names) - _log_status_change("reordered", ",".join(req.names), actor) + statuses = await deps.db.reorder_task_statuses( + req.names, attribution=attribution_for(actor), + ) return {"statuses": statuses} @@ -480,8 +478,8 @@ async def update_task_status( color=req.color if req.color is not None else None, description=req.description if req.description is not None else None, sort_order=req.sort_order, + attribution=attribution_for(actor), ) - _log_status_change("updated", name, actor) return await deps.db.get_task_status_def(name) @@ -503,15 +501,5 @@ async def delete_task_status(name: str, actor: Actor = Depends(require_auth)): detail=f"{in_use} task(s) use the '{name}' status. " "Reassign them before deleting it.", ) - await deps.db.delete_task_status_def(name) - _log_status_change("deleted", name, actor) + await deps.db.delete_task_status_def(name, attribution=attribution_for(actor)) return {"name": name, "deleted": True} - - -def _log_status_change(change: str, names: str, actor: Actor) -> None: - """Log who changed a status definition. - - ``task_statuses`` has no attribution column, so the log is the only - record of the person. - """ - logger.info("Task status %s %s by actor %s", names, change, actor.actor_id) diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 78c2d9d08..4ebea6c01 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -478,15 +478,51 @@ def write_while_bob_moves(path, content): @pytest.mark.asyncio -async def test_task_status_definition_changes_take_the_person(install, caplog): - caplog.set_level("INFO", logger="nerve.gateway.routes.tasks") +async def test_task_status_definition_changes_record_actor_and_cause(install): + from nerve.agent.tools.handlers.tasks import task_status_create_handler + + alice, bob = install.actor_id("alice"), install.actor_id("bob") async with _client(install.app()) as client: - response = await client.post( + created = await client.post( "/api/task-statuses", headers=install.headers("bob"), - json={"name": "review"}, + json={"name": "review", "label": "Review", "color": "#123456"}, ) - assert response.status_code == 200 - assert f"by actor {install.actor_id('bob')}" in caplog.text + assert created.status_code == 200 + updated = await client.patch( + "/api/task-statuses/review", headers=install.headers("alice"), + json={"label": "In review"}, + ) + assert updated.status_code == 200 + reordered = await client.post( + "/api/task-statuses/reorder", headers=install.headers("bob"), + json={"names": ["review", "pending"]}, + ) + assert reordered.status_code == 200 + deleted = await client.delete( + "/api/task-statuses/review", headers=install.headers("alice"), + ) + assert deleted.status_code == 200 + + # The agent adds a status in a turn that Alice started. + ctx = ToolContext( + session_id="alices-turn", db=install.db, + actor=install.db.system_actor, cause=alice, + ) + await task_status_create_handler(ctx, {"name": "blocked"}) + + events = await install.db.list_task_status_events() + assert [ + (e["change"], e["status_name"], e["actor_id"], e["caused_by_actor_id"]) + for e in events + ] == [ + ("created", "review", bob, bob), + ("updated", "review", alice, alice), + ("reordered", None, bob, bob), + ("deleted", "review", alice, alice), + ("created", "blocked", install.system_actor_id, alice), + ] + assert json.loads(events[1]["detail"]) == {"label": "In review"} + assert json.loads(events[2]["detail"])["order"][:2] == ["review", "pending"] # --------------------------------------------------------------------------- # From e0e66333ab5b1b1b9e94a3d5ab358688a2da93b4 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:24:37 +0000 Subject: [PATCH 07/17] Document the turn cause binding and task status events 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 --- docs/accounts.md | 14 ++++++++++---- docs/api.md | 4 ++++ docs/tasks.md | 6 ++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index 0734e21e0..a50726f3e 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -186,15 +186,21 @@ These columns store actor IDs: | `sessions.created_by_actor_id` | Who created the session | | `messages.actor_id` | Who supplied the content of a user message | | `messages.caused_by_actor_id` | The cause of the turn that a user message starts | +| `sessions.turn_caused_by_actor_id` | The cause of the agent turn that the session runs now, or ran last | | `task_events.actor_id`, `task_events.caused_by_actor_id` | Actor and cause of a task status change | +| `task_status_events.actor_id`, `task_status_events.caused_by_actor_id` | Actor and cause of a change to the set of task statuses | `nerve/db/actor_columns.py` lists every attribution column. Clients resolve names through `GET /api/actors`, which returns every actor that these columns can name: the display name, or the username when none is set. The web UI shows the system actor with a person as cause as "Nerve for ". -Each agent turn has one cause. Tool calls in the turn record the system actor -as actor and the turn's cause as cause. +Each agent turn has one cause, fixed when the turn starts. Tool calls in the +turn record the system actor as actor and the turn's cause as cause. A +background task (a command or an agent that runs in the background) keeps the +cause of the turn that started it, and so does the turn that the agent starts +to report the result of the task. When a person sends a message while earlier +background output waits, Nerve processes that output for its own cause first. | Work | Actor | Cause | |---|---|---| @@ -204,8 +210,8 @@ as actor and the turn's cause as cause. | A plan implementation or a plan revision that a person started | The system actor | That person | | A plan approved or a revision requested by the agent in a turn | The system actor | The cause of that turn | | The hook turn that starring a session starts | The system actor | The person who starred it | -| A turn resumed after a restart | The system actor | The cause on the interrupted turn's user message, else the system actor | -| A turn that the agent starts between turns, when a background task finishes | The system actor | The cause of the session's last turn | +| A turn resumed after a restart | The system actor | The cause stored when the interrupted turn started (`sessions.turn_caused_by_actor_id`), else the system actor | +| A turn that the agent starts between turns, when a background task finishes | The system actor | The cause of the turn that started the task | | A tool call from an external MCP client | The system actor | None | | Workflow legs, wakeups, deferred messages when they run, and answers relayed into a session | The system actor | The system actor | | Telegram, Slack, or imported Codex input | None until a local identity mapping exists | None | diff --git a/docs/api.md b/docs/api.md index ea79eb508..ce777bf46 100644 --- a/docs/api.md +++ b/docs/api.md @@ -788,6 +788,10 @@ Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_b account id. It is `null` for legacy rows and sessions caused by an unidentified external person. Every session payload carries the field. +Session payloads also carry `turn_caused_by_actor_id`: the cause of the agent +turn that the session runs now, or ran last (an actor id, or `null` when +unknown). See [Attribution](accounts.md#attribution). + #### `GET /api/sessions/{id}` Get session details. diff --git a/docs/tasks.md b/docs/tasks.md index 9fa3fa310..fb7a034d1 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -152,6 +152,12 @@ columns. The `GET /api/tasks/{id}/events` rows include both columns, and the timeline shows "Nerve for " when the agent changed the status for a person. +Changes to the set of statuses (create, update, reorder, delete, through the +HTTP API or the `task_status_create` tool) append a row to +`task_status_events` (`change`, `status_name`, `detail`, `actor_id`, +`caused_by_actor_id`, `created_at`), added in migration v053. `status_name` is +NULL for a reorder, and `detail` is a JSON object with the new values. + This powers the aging indicator on board cards and the timeline in the task detail view. Tasks whose last transition predates v044 report no entry time rather than a guessed one, so the UI stays silent instead of showing an age From 2c2ed9c2aa483c000430bc47c06449838c87f1cc Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:05:12 +0000 Subject: [PATCH 08/17] Run a turn with no cause while other runtime output can arrive 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 --- docs/accounts.md | 14 ++- nerve/agent/engine.py | 148 ++++++++++++++++++++---- tests/test_autonomous_turns.py | 2 + tests/test_cause_attribution.py | 195 ++++++++++++++++++++++++++++---- 4 files changed, 313 insertions(+), 46 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index a50726f3e..442437fd9 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -200,7 +200,19 @@ turn record the system actor as actor and the turn's cause as cause. A background task (a command or an agent that runs in the background) keeps the cause of the turn that started it, and so does the turn that the agent starts to report the result of the task. When a person sends a message while earlier -background output waits, Nerve processes that output for its own cause first. +background output waits, Nerve processes that output for its own cause first: +it waits up to 30 seconds for the first output of a report turn that has +started, and then to the end of that turn. + +One agent process serves a session, and its tool calls do not say which turn +they belong to. So a turn runs with an unknown cause, not with its person's +cause, when output of another cause can still arrive during it: a report turn +that showed no output within the 30 seconds, or a background agent that +another person's turn started and that still runs. Nerve records an unknown +cause rather than the wrong person. + +Nerve stores the cause on the session before the turn reaches the agent. If +that write fails, the turn does not start. | Work | Actor | Cause | |---|---|---| diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index e64c1d620..3f7e6b9dc 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -86,6 +86,16 @@ logger = logging.getLogger(__name__) +class TurnCauseNotStored(RuntimeError): + """The cause of a turn could not be written to the session row.""" + + def __init__(self, session_id: str) -> None: + super().__init__(f"could not store the turn cause of session {session_id}") + + +# ``_persisted_turn_causes`` value for a session whose row has no known write. +_NOT_STORED = object() + _SURROGATE_RE = re.compile(r"[\ud800-\udfff]") # Injected as an internal turn when a session is resumed after a @@ -269,6 +279,13 @@ def __init__(self, config: NerveConfig, db: Database): # background task that settled last. The CLI starts that turn to # report the settled task. self._autonomous_causes: dict[str, str | None] = {} + # The turn cause last written to each session row. It differs from + # _turn_causes only after a failed write, which the next turn retries. + self._persisted_turn_causes: dict[str, str | None] = {} + # Sessions whose runtime can still emit output of an autonomous turn + # that a drain gave up on (no content in time, or no result in time). + # The next run() turn of the session runs with an unknown cause. + self._unfinished_autonomous: set[str] = set() # Resolved model bound to each session's live SDK client. Used to # detect mid-session model switches (the CLI fixes its model at # connect time, so a change requires recreating the client). @@ -402,27 +419,97 @@ def session_cause(self, session_id: str) -> str | None: """ return self._turn_causes.get(session_id) - async def _begin_runtime_turn(self, session_id: str, cause_id: str | None) -> None: + async def _begin_runtime_turn( + self, session_id: str, cause_id: str | None, *, must_persist: bool, + ) -> None: """Make ``cause_id`` the cause of the turn that the runtime starts now. - The value is also stored on the session row, so that a restart can - resume the interrupted turn for the same cause. A failed write only - costs that recovery, so it does not stop the turn. + The value is stored on the session row first, so that a restart + resumes the interrupted turn for the same cause. A write that fails + twice gives the session an unknown cause in memory: tool calls then + record no cause, not the cause of an earlier turn. With + ``must_persist`` (a run() turn, before its query) the failure also + raises :class:`TurnCauseNotStored`, so the turn does not start. The + next turn retries the write. """ - if session_id in self._turn_causes and self._turn_causes[session_id] == cause_id: - return + if self._persisted_turn_causes.get(session_id, _NOT_STORED) != cause_id: + error: Exception | None = None + for _ in range(2): + try: + await self.db.set_session_turn_cause(session_id, cause_id) + except Exception as e: # noqa: BLE001 - retried, then reported + error = e + continue + self._persisted_turn_causes[session_id] = cause_id + error = None + break + if error is not None: + self._persisted_turn_causes.pop(session_id, None) + self._turn_causes[session_id] = None + logger.error( + "Could not store the turn cause of session %s: %s", + session_id, error, + ) + if must_persist: + raise TurnCauseNotStored(session_id) from error + return self._turn_causes[session_id] = cause_id - try: - await self.db.set_session_turn_cause(session_id, cause_id) - except Exception as e: - logger.warning( - "Could not store the turn cause of session %s: %s", session_id, e, + + def _cause_for_new_turn(self, session_id: str, cause_id: str | None) -> str | None: + """The cause that a run() turn can claim when its query starts. + + One runtime process serves the session, and its tool calls carry no + turn. While output of another cause can still arrive, a tool call + cannot be told apart. The turn then runs with an unknown cause (None) + and does not claim that output for this turn's person: + + - an autonomous turn that the pre-query drain gave up on can still + emit; + - a background agent or workflow of another cause can still make tool + calls. + """ + if session_id in self._unfinished_autonomous: + self._unfinished_autonomous.discard(session_id) + logger.info( + "Session %s: earlier autonomous output can still arrive; " + "the new turn runs with an unknown cause", session_id, + ) + return None + if self._live_agent_cause_differs(session_id, cause_id): + logger.info( + "Session %s: a background agent of another cause is running; " + "the new turn runs with an unknown cause", session_id, ) + return None + return cause_id + + def _live_agent_cause_differs(self, session_id: str, cause_id: str | None) -> bool: + """Whether a running background agent or workflow has another cause. + + Background commands are left out: they do not call the session's + tools. + """ + registry = self._bg_task_registry.get(session_id) or {} + for task_id, task_cause in (self._bg_task_causes.get(session_id) or {}).items(): + entry = registry.get(task_id) or {} + if ( + entry.get("status") == "running" + and entry.get("tool") in ("Agent", "Workflow") + and task_cause != cause_id + ): + return True + return False def _forget_background_causes(self, session_id: str) -> None: - """Drop the causes of background tasks that died with their client.""" + """Drop the causes of runtime work that died with its client. + + Every path that tears down a session's client calls this: discard, + a dead client, a model change, a crash retry, a stop and a turn + error. + """ self._bg_task_causes.pop(session_id, None) self._autonomous_causes.pop(session_id, None) + self._unfinished_autonomous.discard(session_id) # ------------------------------------------------------------------ # # Backend resolution (sticky per session) # @@ -1167,6 +1254,7 @@ async def _get_or_create_client( session_id, ) self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) self.sessions.remove_client(session_id) self._session_backends.pop(session_id, None) self._session_models.pop(session_id, None) @@ -1182,6 +1270,7 @@ async def _get_or_create_client( session_id, bound_model, requested_model, ) self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) self.sessions.remove_client(session_id) self._session_backends.pop(session_id, None) self._session_models.pop(session_id, None) @@ -2799,15 +2888,17 @@ async def _run_inner( # idle watcher claimed it). Without this, receive_response() # below would consume the stale turn and terminate on ITS # ResultMessage — answering this message with the previous - # turn's output (off-by-one desync). The short first-content - # timeout keeps a just-started autonomous turn from delaying - # the user's message for long; if its content arrives later it - # interleaves into this turn's stream (still rendered) and the - # idle watcher self-heals the remainder. + # turn's output (off-by-one desync). A started autonomous turn + # is drained to its result: the runtime answers this message + # only after that turn anyway, and its output belongs to its own + # cause. If no content comes within the first-content timeout, + # the drain gives up; late content then interleaves into this + # turn's stream (still rendered), the idle watcher self-heals + # the remainder, and this turn runs with an unknown cause. try: await self._drain_pending_messages( session_id, client, source, channel, - first_content_timeout=3.0, + first_content_timeout=self._PRE_QUERY_FIRST_CONTENT_SECONDS, ) except asyncio.CancelledError: raise @@ -2823,7 +2914,10 @@ async def _run_inner( # This turn starts in the runtime now. The drain above ran under # the cause of the work it drained. - await self._begin_runtime_turn(session_id, cause_id) + await self._begin_runtime_turn( + session_id, self._cause_for_new_turn(session_id, cause_id), + must_persist=True, + ) # New user turn: settled background-task chips are stale now. self._prune_bg_tasks(session_id) @@ -2915,6 +3009,7 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: session_id, _qerr, ) self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) self.sessions.remove_client(session_id) unregister_handler(session_id) await self._safe_disconnect(client) @@ -2952,6 +3047,7 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: session_id, _recv_err, ) self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) self.sessions.remove_client(session_id) unregister_handler(session_id) await self._safe_disconnect(client) @@ -2988,6 +3084,7 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: ) await self.sessions.mark_stopped(session_id) self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) unregister_handler(session_id) client = self.sessions.remove_client(session_id) if client: @@ -3084,6 +3181,7 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: await self.schedule_memorize(session_id) # Clear resume — CLI state may be corrupted after error self._stop_idle_watcher(session_id) + self._forget_background_causes(session_id) unregister_handler(session_id) client = self.sessions.remove_client(session_id) await self.sessions.mark_error(session_id, error_msg) @@ -3124,6 +3222,9 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: # How often the idle watcher probes the SDK buffer (seconds). _IDLE_STREAM_POLL_SECONDS = 0.5 + # How long the pre-query drain waits for the first content of an + # autonomous turn that has started. The same as the idle watcher's wait. + _PRE_QUERY_FIRST_CONTENT_SECONDS = 30.0 async def _drain_pending_messages( self, @@ -3178,8 +3279,11 @@ async def _open_turn() -> None: # The runtime reports a settled background task, so the turn # continues the work of the turn that started the task. if session_id in self._autonomous_causes: + cause_id = self._autonomous_causes.pop(session_id) + if self._live_agent_cause_differs(session_id, cause_id): + cause_id = None await self._begin_runtime_turn( - session_id, self._autonomous_causes.pop(session_id), + session_id, cause_id, must_persist=False, ) # Leading marker block → "background continuation" chip in the # UI, both live (auto_turn event) and after reload (persisted). @@ -3252,6 +3356,8 @@ async def _close_turn() -> None: session_id, first_content_timeout, ) await _close_turn() # empty — dropped + # The runtime can still emit this turn. + self._unfinished_autonomous.add(session_id) break logger.warning( "Autonomous turn idle timeout (%ss) for session %s " @@ -3264,6 +3370,7 @@ async def _close_turn() -> None: else "[Background turn interrupted: runtime went silent]" ) await _close_turn() + self._unfinished_autonomous.add(session_id) raise if batch is None: logger.warning( @@ -3326,6 +3433,7 @@ async def _close_turn() -> None: ) if turn_done: await _close_turn() + self._unfinished_autonomous.discard(session_id) # A ModelObserved whose batch never produced content is # dropped with it — an empty assistant message carries # nothing worth persisting. diff --git a/tests/test_autonomous_turns.py b/tests/test_autonomous_turns.py index 7f9ea740e..1c71dced0 100644 --- a/tests/test_autonomous_turns.py +++ b/tests/test_autonomous_turns.py @@ -44,6 +44,8 @@ def _make_engine() -> AgentEngine: engine._bg_task_causes = {} engine._autonomous_causes = {} engine._turn_causes = {} + engine._persisted_turn_causes = {} + engine._unfinished_autonomous = set() engine._workflows = {} engine._idle_watchers = {} engine._session_locks = {} diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 4ebea6c01..a97b681a7 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -13,6 +13,7 @@ import asyncio import base64 import json +import sqlite3 from types import SimpleNamespace from unittest.mock import patch @@ -125,8 +126,10 @@ class _FakeRuntime: for example a background task that it starts. - ``buffered``: autonomous output that arrived between runs; the engine drains it before the next turn. - - ``in_flight``: for an autonomous turn in flight, pairs of (tool call - first?, next batch) that ``receive_idle_events`` returns. + - ``in_flight``: for an autonomous turn in flight, the steps that + ``receive_idle_events`` takes: (seconds to wait first, tool call + first?, next batch). With no step left it waits out the timeout and + raises ``TimeoutError``, like a runtime that has not produced output. - ``fail``: the next turn dies after its first output, like a runtime that is killed mid-turn (a restart). The engine does not retry a turn that has output. @@ -148,8 +151,14 @@ def try_receive_idle_events(self): async def receive_idle_events(self, timeout=None): if not self.in_flight: - return None - tool_call, batch = self.in_flight.pop(0) + await asyncio.sleep(timeout or 0) + raise asyncio.TimeoutError + delay, tool_call, batch = self.in_flight.pop(0) + if timeout is not None and delay > timeout: + await asyncio.sleep(timeout) + self.in_flight.insert(0, (delay - timeout, tool_call, batch)) + raise asyncio.TimeoutError + await asyncio.sleep(delay) if tool_call: await self.install.agent_tool_call(self.session_id) return batch @@ -434,46 +443,54 @@ async def test_rest_patch_with_content_records_one_change_for_the_person(install assert (install.config.workspace / task["file_path"]).is_file() -@pytest.mark.asyncio -async def test_rest_patch_content_keeps_a_concurrent_move(install, monkeypatch): - """A move that lands while PATCH writes the file is not undone. - - Alice saves new content while Bob moves the card. The content save must - not write back the status that it read before Bob's move. - """ - from nerve.gateway.routes import tasks as tasks_route - - alice, bob = install.actor_id("alice"), install.actor_id("bob") +def _bob_moves_during_the_file_write(install, monkeypatch, module, task_id: str) -> None: + """Bob moves the task to deferred while ``module`` writes the task file.""" loop = asyncio.get_running_loop() - real_write = tasks_route.write_task_file + real_write = module.write_task_file def write_while_bob_moves(path, content): real_write(path, content) asyncio.run_coroutine_threadsafe( install.db.move_task( task_id, status="deferred", actor="web", - attribution=attribution_for(bob), + attribution=attribution_for(install.actor_id("bob")), ), loop, ).result(timeout=5) + monkeypatch.setattr(module, "write_task_file", write_while_bob_moves) + + +async def _assert_bobs_move_stays(install, task_id: str) -> None: + alice, bob = install.actor_id("alice"), install.actor_id("bob") + assert await install.events(task_id) == [ + (None, "pending", "web", alice, alice), + ("pending", "deferred", "web", bob, bob), + ] + assert (await install.db.get_task(task_id))["status"] == "deferred" + + +@pytest.mark.asyncio +async def test_rest_patch_content_keeps_a_concurrent_move(install, monkeypatch): + """A move that lands while PATCH writes the file is not undone. + + Alice saves new content while Bob moves the card. The content save must + not write back the status that it read before Bob's move. + """ + from nerve.gateway.routes import tasks as tasks_route + async with _client(install.app()) as client: task_id = (await client.post( "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, )).json()["task"]["id"] - monkeypatch.setattr(tasks_route, "write_task_file", write_while_bob_moves) + _bob_moves_during_the_file_write(install, monkeypatch, tasks_route, task_id) response = await client.patch( f"/api/tasks/{task_id}", headers=install.headers("alice"), json={"content": "# Raced\n\nNew body.\n"}, ) assert response.status_code == 200 - - assert await install.events(task_id) == [ - (None, "pending", "web", alice, alice), - ("pending", "deferred", "web", bob, bob), - ] + await _assert_bobs_move_stays(install, task_id) task = await install.db.get_task(task_id) - assert task["status"] == "deferred" assert "New body." in (install.config.workspace / task["file_path"]).read_text() @@ -624,7 +641,7 @@ async def test_autonomous_work_keeps_the_cause_of_the_turn_that_started_it( SystemEvent(subtype="init"), TextDelta(text="The build passed."), ]] - runtime.in_flight = [(True, [TurnCompleted()])] + runtime.in_flight = [(0, True, [TurnCompleted()])] await _chat(client, install.headers("bob"), session_id, "and now this") hers, continuation, his = install.turns_in(session_id) @@ -663,7 +680,7 @@ async def test_an_autonomous_turn_after_a_later_turn_keeps_the_starters_cause( SystemEvent(subtype="init"), TextDelta(text="The long job finished."), ]] - runtime.in_flight = [(True, [TurnCompleted()])] + runtime.in_flight = [(0, True, [TurnCompleted()])] await install.engine._drain_pending_messages( session_id, runtime, "web", "web", manage_framing=True, ) @@ -674,6 +691,134 @@ async def test_an_autonomous_turn_after_a_later_turn_keeps_the_starters_cause( assert install.engine.session_cause(session_id) == alice +@pytest.mark.asyncio +async def test_late_autonomous_output_is_not_given_the_next_persons_cause( + install, monkeypatch, +): + """Alice's continuation has started but shows no output in time. + + Bob's turn cannot tell her late output from his own, so it runs with an + unknown cause, not Bob's. His next turn is his again. + """ + install.use_fake_runtime(monkeypatch) + monkeypatch.setattr(AgentEngine, "_PRE_QUERY_FIRST_CONTENT_SECONDS", 0.05) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runtime = install.runtime(session_id) + runtime.turn_events = [ + SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + ] + await _chat(client, install.headers("alice"), session_id, "run the build") + runtime.buffered = [[ + SystemEvent( + subtype="task_notification", + data={"task_id": "bg-1", "status": "completed"}, + ), + SystemEvent(subtype="init"), + ]] + await _chat(client, install.headers("bob"), session_id, "and now this") + assert await install.turn_cause_of(session_id) is None + await _chat(client, install.headers("bob"), session_id, "one more") + + hers, unknown, his = install.turns_in(session_id) + assert (hers["cause"], unknown["cause"], his["cause"]) == (alice, None, bob) + assert await install.events(unknown["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, None), + ] + # Bob's message still records him as the cause of the turn it started. + assert (await install.user_rows(session_id))[1] == ("and now this", bob, bob) + + +@pytest.mark.asyncio +async def test_the_next_turn_waits_for_a_slow_autonomous_turn(install, monkeypatch): + """Alice's continuation shows its first output after more than 3 s.""" + install.use_fake_runtime(monkeypatch) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runtime = install.runtime(session_id) + runtime.turn_events = [ + SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + ] + await _chat(client, install.headers("alice"), session_id, "run the build") + runtime.buffered = [[ + SystemEvent( + subtype="task_notification", + data={"task_id": "bg-1", "status": "completed"}, + ), + SystemEvent(subtype="init"), + ]] + runtime.in_flight = [ + (3.2, False, [TextDelta(text="The build passed.")]), + (0, True, [TurnCompleted()]), + ] + await _chat(client, install.headers("bob"), session_id, "and now this") + + hers, continuation, his = install.turns_in(session_id) + assert (hers["cause"], continuation["cause"], his["cause"]) == (alice, alice, bob) + + +@pytest.mark.asyncio +async def test_a_running_background_agent_of_another_person_leaves_the_cause_unknown( + install, monkeypatch, +): + install.use_fake_runtime(monkeypatch) + alice = install.actor_id("alice") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + install.runtime(session_id).turn_events = [ + SystemEvent( + subtype="task_started", + data={"task_id": "agent-1", "task_type": "local_agent"}, + ), + ] + await _chat(client, install.headers("alice"), session_id, "research this") + # Alice's background agent can still call tools during Bob's turn. + await _chat(client, install.headers("bob"), session_id, "meanwhile") + await _chat(client, install.headers("alice"), session_id, "and this") + + assert [turn["cause"] for turn in install.turns_in(session_id)] == [ + alice, None, alice, + ] + + +@pytest.mark.asyncio +async def test_a_turn_does_not_start_until_its_cause_is_stored( + install, monkeypatch, tmp_path, +): + install.use_fake_runtime(monkeypatch) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + await _chat(client, install.headers("alice"), session_id, "first") + + real_setter = install.db.set_session_turn_cause + failures = {"left": 2} + + async def flaky_setter(sid, cause_id): + if failures["left"]: + failures["left"] -= 1 + raise sqlite3.OperationalError("disk I/O error") + await real_setter(sid, cause_id) + + monkeypatch.setattr(install.db, "set_session_turn_cause", flaky_setter) + await _chat(client, install.headers("bob"), session_id, "second") + # Both writes failed: the turn did not reach the runtime, and nothing + # runs for an earlier cause. + assert [turn["cause"] for turn in install.turns_in(session_id)] == [alice] + assert install.engine.session_cause(session_id) is None + assert await install.turn_cause_of(session_id) == alice + + await _chat(client, install.headers("bob"), session_id, "third") + assert await install.turn_cause_of(session_id) == bob + + await _restart_and_resume(install, session_id, tmp_path) + assert [turn["cause"] for turn in install.turns_in(session_id)] == [ + alice, bob, bob, + ] + + @pytest.mark.asyncio async def test_input_with_no_person_has_no_cause(install, monkeypatch): install.use_fake_runtime(monkeypatch) From cc34461f0c46c6466a9aa8be0926db068c2703ca Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:05:12 +0000 Subject: [PATCH 09/17] Keep a concurrent move when task_update or task_write saves a file 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 --- nerve/agent/tools/handlers/tasks.py | 17 +++++++---- tests/test_cause_attribution.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/nerve/agent/tools/handlers/tasks.py b/nerve/agent/tools/handlers/tasks.py index 25203e79a..6fd16c722 100644 --- a/nerve/agent/tools/handlers/tasks.py +++ b/nerve/agent/tools/handlers/tasks.py @@ -31,6 +31,7 @@ TASK_WRITE_SCHEMA, ) from nerve.config import ensure_path_not_tracked_config +from nerve.db.tasks import KEEP from nerve.db.task_statuses import ( DEFAULT_STATUS, STATUS_NAME_RE, @@ -530,7 +531,6 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: content = re.sub(r"\*\*Tags:\*\* .*\n?", "", content, count=1) final_title = new_title or task["title"] - final_status = status or task["status"] # Presence carries through to the row the same way it does to # the file above: an omitted column keeps its stored value, a # present-but-empty one clears it. @@ -540,9 +540,13 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: if raw_tags is not None: edits["tags"] = new_tags_str write = lambda: write_task_file(file_path, content) + # KEEP for what this call does not change: the path belongs to + # task_done / task_reopen, and a status that another writer + # set after the read above must stay. A requested status is + # applied in the write's transaction, from the stored one. kw = dict( - task_id=task_id, file_path=task["file_path"], title=final_title, - status=final_status, content=content, actor=ctx.session_id, + task_id=task_id, file_path=KEEP, title=final_title, + status=status or KEEP, content=content, actor=ctx.session_id, attribution=ctx.attribution, **edits, ) # Without a token the order stays file-then-row, as before. With @@ -664,11 +668,14 @@ async def task_write_handler(ctx: ToolContext, args: dict) -> ToolResult: if "tags" in frontmatter: edits["tags"] = tags_to_string(parse_tags_string(frontmatter["tags"])) + # task_write changes neither the path nor the status. KEEP reads both in + # the write's transaction, so a move by another writer since the read + # above stays. await ctx.db.upsert_task( task_id=task_id, - file_path=task["file_path"], + file_path=KEEP, title=new_title, - status=task["status"], + status=KEEP, deadline=new_deadline or None, content=new_content, attribution=ctx.attribution, diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index a97b681a7..12c5a5666 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -470,6 +470,50 @@ async def _assert_bobs_move_stays(install, task_id: str) -> None: assert (await install.db.get_task(task_id))["status"] == "deferred" +@pytest.mark.asyncio +@pytest.mark.parametrize("edit", [ + {"title": "Raced, renamed"}, + {"note": "a note"}, + {"deadline": "2026-12-01"}, + {"tags": "alpha"}, +]) +async def test_rest_patch_fields_keep_a_concurrent_move(install, monkeypatch, edit): + from nerve.agent.tools.handlers import tasks as task_handlers + + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, + )).json()["task"]["id"] + _bob_moves_during_the_file_write(install, monkeypatch, task_handlers, task_id) + response = await client.patch( + f"/api/tasks/{task_id}", headers=install.headers("alice"), json=edit, + ) + assert response.status_code == 200 + await _assert_bobs_move_stays(install, task_id) + + +@pytest.mark.asyncio +async def test_task_write_keeps_a_concurrent_move(install, monkeypatch): + from nerve.agent.tools.handlers import tasks as task_handlers + + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, + )).json()["task"]["id"] + ctx = ToolContext( + session_id="alices-turn", workspace=install.config.workspace, + db=install.db, config=install.config, engine=install.engine, + actor=install.db.system_actor, cause=install.actor_id("alice"), + ) + await task_handlers.task_read_handler(ctx, {"task_id": task_id}) + _bob_moves_during_the_file_write(install, monkeypatch, task_handlers, task_id) + result = await task_handlers.task_write_handler( + ctx, {"task_id": task_id, "content": "# Raced\n\nWritten by the agent.\n"}, + ) + assert not result.is_error + await _assert_bobs_move_stays(install, task_id) + + @pytest.mark.asyncio async def test_rest_patch_content_keeps_a_concurrent_move(install, monkeypatch): """A move that lands while PATCH writes the file is not undone. From 505b640b0d6f234014d31846f69c049bbe278452 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:20:46 +0000 Subject: [PATCH 10/17] Leave a report or watcher turn unknown when causes mix 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 --- docs/accounts.md | 7 ++- nerve/agent/engine.py | 104 ++++++++++++++++++++------------ tests/test_cause_attribution.py | 89 +++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 44 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index 442437fd9..b89f8c6c9 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -207,9 +207,10 @@ started, and then to the end of that turn. One agent process serves a session, and its tool calls do not say which turn they belong to. So a turn runs with an unknown cause, not with its person's cause, when output of another cause can still arrive during it: a report turn -that showed no output within the 30 seconds, or a background agent that -another person's turn started and that still runs. Nerve records an unknown -cause rather than the wrong person. +that showed no output within the 30 seconds, or a running background task of +another cause that can act before it ends (an agent, a workflow or a +watcher). A report turn for background tasks of different causes also has an +unknown cause. Nerve records an unknown cause rather than the wrong person. Nerve stores the cause on the session before the turn reaches the agent. If that write fails, the turn does not start. diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 3f7e6b9dc..63d0878f3 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -96,6 +96,16 @@ def __init__(self, session_id: str) -> None: # ``_persisted_turn_causes`` value for a session whose row has no known write. _NOT_STORED = object() + +@dataclass(frozen=True, slots=True) +class _BackgroundTask: + """A live background task and the cause of the turn that started it.""" + + cause_id: str | None + # An agent, a workflow or a watcher: it can call tools or start a turn + # before it settles. A shell command cannot. + acts_while_running: bool + _SURROGATE_RE = re.compile(r"[\ud800-\udfff]") # Injected as an internal turn when a session is resumed after a @@ -271,13 +281,13 @@ def __init__(self, config: NerveConfig, db: Database): # turn after the pre-query drain, an autonomous turn when the drain # opens it. Read through session_cause(). self._turn_causes: dict[str, str | None] = {} - # Cause of each live background task (Bash/Agent run_in_background, - # Monitor): the cause of the turn that started it. session_id -> - # task_id -> cause. - self._bg_task_causes: dict[str, dict[str, str | None]] = {} + # Each live background task (Bash/Agent run_in_background, Monitor) + # with the cause of the turn that started it. session_id -> task_id + # -> _BackgroundTask. A task leaves the map when it settles. + self._bg_task_causes: dict[str, dict[str, _BackgroundTask]] = {} # Cause for a session's next autonomous turn: the cause of the - # background task that settled last. The CLI starts that turn to - # report the settled task. + # background tasks that settled since the last one (None when they + # differ). The CLI starts that turn to report the settled tasks. self._autonomous_causes: dict[str, str | None] = {} # The turn cause last written to each session row. It differs from # _turn_causes only after a failed write, which the next turn retries. @@ -465,8 +475,8 @@ def _cause_for_new_turn(self, session_id: str, cause_id: str | None) -> str | No - an autonomous turn that the pre-query drain gave up on can still emit; - - a background agent or workflow of another cause can still make tool - calls. + - a running background task of another cause can call tools or + start a turn before it settles (see :meth:`_acting_task_causes`). """ if session_id in self._unfinished_autonomous: self._unfinished_autonomous.discard(session_id) @@ -475,30 +485,43 @@ def _cause_for_new_turn(self, session_id: str, cause_id: str | None) -> str | No "the new turn runs with an unknown cause", session_id, ) return None - if self._live_agent_cause_differs(session_id, cause_id): + if self._acting_task_causes(session_id) - {cause_id}: logger.info( - "Session %s: a background agent of another cause is running; " + "Session %s: a background task of another cause is running; " "the new turn runs with an unknown cause", session_id, ) return None return cause_id - def _live_agent_cause_differs(self, session_id: str, cause_id: str | None) -> bool: - """Whether a running background agent or workflow has another cause. + def _cause_for_autonomous_turn(self, session_id: str) -> str | None: + """The cause of an autonomous turn that the runtime starts. - Background commands are left out: they do not call the session's - tools. + The turn reports the background tasks that settled since the last + one, so it takes their cause. Without one it continues the last turn + (late output of a turn that a drain gave up on, for example). A + running background task of another cause could also be the reason + for the turn, or call tools in it; then the cause is unknown (None). """ - registry = self._bg_task_registry.get(session_id) or {} - for task_id, task_cause in (self._bg_task_causes.get(session_id) or {}).items(): - entry = registry.get(task_id) or {} - if ( - entry.get("status") == "running" - and entry.get("tool") in ("Agent", "Workflow") - and task_cause != cause_id - ): - return True - return False + if session_id in self._autonomous_causes: + cause_id = self._autonomous_causes.pop(session_id) + else: + cause_id = self._turn_causes.get(session_id) + if self._acting_task_causes(session_id) - {cause_id}: + return None + return cause_id + + def _acting_task_causes(self, session_id: str) -> set[str | None]: + """Causes of the running background tasks that act before they settle. + + A background agent or workflow calls tools, and a watcher starts + turns, while it runs. A background shell command does neither until + it settles, so it is left out. A task of an unknown kind counts. + """ + return { + task.cause_id + for task in (self._bg_task_causes.get(session_id) or {}).values() + if task.acts_while_running + } def _forget_background_causes(self, session_id: str) -> None: """Drop the causes of runtime work that died with its client. @@ -2274,17 +2297,27 @@ def _track_background_cause( When the task settles, its cause becomes the cause of the session's next autonomous turn, which the runtime starts to report the result. """ - causes = self._bg_task_causes.setdefault(session_id, {}) + tasks = self._bg_task_causes.setdefault(session_id, {}) if subtype == "task_started": - causes.setdefault(task_id, self._turn_causes.get(session_id)) + if task_id not in tasks: + task_type = str(data.get("task_type") or "") + tasks[task_id] = _BackgroundTask( + cause_id=self._turn_causes.get(session_id), + acts_while_running="bash" not in task_type, + ) return settled = subtype == "task_notification" or ( subtype == "task_updated" and str((data.get("patch") or {}).get("status") or "") in self._BG_TERMINAL_STATUSES ) - if settled and task_id in causes: - self._autonomous_causes[session_id] = causes.pop(task_id) + if settled and task_id in tasks: + cause_id = tasks.pop(task_id).cause_id + pending = self._autonomous_causes + # Tasks of different causes settled for one report: unknown. + if session_id in pending and pending[session_id] != cause_id: + cause_id = None + pending[session_id] = cause_id async def _emit_workflow_progress( self, @@ -3276,15 +3309,12 @@ async def _open_turn() -> None: if st is not None: return st = _TurnState() - # The runtime reports a settled background task, so the turn - # continues the work of the turn that started the task. - if session_id in self._autonomous_causes: - cause_id = self._autonomous_causes.pop(session_id) - if self._live_agent_cause_differs(session_id, cause_id): - cause_id = None - await self._begin_runtime_turn( - session_id, cause_id, must_persist=False, - ) + # The turn continues the work of the turn that started the + # background tasks it reports; see _cause_for_autonomous_turn. + await self._begin_runtime_turn( + session_id, self._cause_for_autonomous_turn(session_id), + must_persist=False, + ) # Leading marker block → "background continuation" chip in the # UI, both live (auto_turn event) and after reload (persisted). st.ordered_blocks.append({"type": "auto"}) diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 12c5a5666..9d83fd20b 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -671,7 +671,10 @@ async def test_autonomous_work_keeps_the_cause_of_the_turn_that_started_it( runtime = install.runtime(session_id) # Alice's turn starts a background task. runtime.turn_events = [ - SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + SystemEvent( + subtype="task_started", + data={"task_id": "bg-1", "task_type": "local_bash"}, + ), ] await _chat(client, install.headers("alice"), session_id, "run the build") @@ -710,7 +713,10 @@ async def test_an_autonomous_turn_after_a_later_turn_keeps_the_starters_cause( session_id = await _new_session(client, install.headers("alice")) runtime = install.runtime(session_id) runtime.turn_events = [ - SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + SystemEvent( + subtype="task_started", + data={"task_id": "bg-1", "task_type": "local_bash"}, + ), ] await _chat(client, install.headers("alice"), session_id, "start a long job") await _chat(client, install.headers("bob"), session_id, "something else") @@ -751,7 +757,10 @@ async def test_late_autonomous_output_is_not_given_the_next_persons_cause( session_id = await _new_session(client, install.headers("alice")) runtime = install.runtime(session_id) runtime.turn_events = [ - SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + SystemEvent( + subtype="task_started", + data={"task_id": "bg-1", "task_type": "local_bash"}, + ), ] await _chat(client, install.headers("alice"), session_id, "run the build") runtime.buffered = [[ @@ -783,7 +792,10 @@ async def test_the_next_turn_waits_for_a_slow_autonomous_turn(install, monkeypat session_id = await _new_session(client, install.headers("alice")) runtime = install.runtime(session_id) runtime.turn_events = [ - SystemEvent(subtype="task_started", data={"task_id": "bg-1"}), + SystemEvent( + subtype="task_started", + data={"task_id": "bg-1", "task_type": "local_bash"}, + ), ] await _chat(client, install.headers("alice"), session_id, "run the build") runtime.buffered = [[ @@ -827,6 +839,75 @@ async def test_a_running_background_agent_of_another_person_leaves_the_cause_unk ] +def _report_turn(*events) -> list: + """Buffered output: settle events, then an autonomous turn that starts.""" + return [*events, SystemEvent(subtype="init"), TextDelta(text="Report.")] + + +def _settled(task_id: str) -> SystemEvent: + return SystemEvent( + subtype="task_notification", data={"task_id": task_id, "status": "completed"}, + ) + + +def _started(task_id: str, task_type: str) -> SystemEvent: + return SystemEvent( + subtype="task_started", data={"task_id": task_id, "task_type": task_type}, + ) + + +@pytest.mark.asyncio +async def test_one_report_of_tasks_of_two_people_has_no_cause(install, monkeypatch): + install.use_fake_runtime(monkeypatch) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runtime = install.runtime(session_id) + runtime.turn_events = [_started("bg-a", "local_bash")] + await _chat(client, install.headers("alice"), session_id, "build A") + runtime.turn_events = [_started("bg-b", "local_bash")] + await _chat(client, install.headers("bob"), session_id, "build B") + + # Both tasks settle and the runtime reports them in one turn. + runtime.buffered = [_report_turn(_settled("bg-a"), _settled("bg-b"))] + runtime.in_flight = [(0, True, [TurnCompleted()])] + await install.engine._drain_pending_messages( + session_id, runtime, "web", "web", manage_framing=True, + ) + assert [turn["cause"] for turn in install.turns_in(session_id)] == [ + alice, bob, None, + ] + + +@pytest.mark.asyncio +async def test_a_watcher_turn_takes_the_watchers_cause_or_none(install, monkeypatch): + """A watcher starts turns while it runs, without settling.""" + install.use_fake_runtime(monkeypatch) + alice = install.actor_id("alice") + + async def watcher_event_turn(): + runtime.buffered = [_report_turn()] + runtime.in_flight = [(0, True, [TurnCompleted()])] + await install.engine._drain_pending_messages( + session_id, runtime, "web", "web", manage_framing=True, + ) + + async with _client(install.app()) as client: + session_id = await _new_session(client, install.headers("alice")) + runtime = install.runtime(session_id) + runtime.turn_events = [_started("watch-1", "monitor")] + await _chat(client, install.headers("alice"), session_id, "watch the log") + await watcher_event_turn() + await _chat(client, install.headers("bob"), session_id, "something else") + await watcher_event_turn() + + # Alice's watcher turn is hers. Bob's turn and the watcher turn after it + # cannot tell Alice's watcher apart from Bob's work: unknown, never Bob. + assert [turn["cause"] for turn in install.turns_in(session_id)] == [ + alice, alice, None, None, + ] + + @pytest.mark.asyncio async def test_a_turn_does_not_start_until_its_cause_is_stored( install, monkeypatch, tmp_path, From 854deb3130713bffd5ecce27ba677b1de4ee67b6 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:21:46 +0000 Subject: [PATCH 11/17] Let the new-turn cause check read state only _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 --- nerve/agent/engine.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 63d0878f3..5f9ef6111 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -477,9 +477,10 @@ def _cause_for_new_turn(self, session_id: str, cause_id: str | None) -> str | No emit; - a running background task of another cause can call tools or start a turn before it settles (see :meth:`_acting_task_causes`). + + Reads state only, so it can be asked at any point of a turn. """ if session_id in self._unfinished_autonomous: - self._unfinished_autonomous.discard(session_id) logger.info( "Session %s: earlier autonomous output can still arrive; " "the new turn runs with an unknown cause", session_id, @@ -2946,10 +2947,12 @@ async def _run_inner( broadcaster.mark_turn_open(session_id) # This turn starts in the runtime now. The drain above ran under - # the cause of the work it drained. + # the cause of the work it drained. An unfinished autonomous turn + # makes only this turn unknown; its late output arrives in it. + runtime_cause = self._cause_for_new_turn(session_id, cause_id) + self._unfinished_autonomous.discard(session_id) await self._begin_runtime_turn( - session_id, self._cause_for_new_turn(session_id, cause_id), - must_persist=True, + session_id, runtime_cause, must_persist=True, ) # New user turn: settled background-task chips are stale now. From 30056283a95b32238395f9c6cd212fe8224b6e09 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:13:15 +0000 Subject: [PATCH 12/17] Run file-plus-row changes of one task one at a time 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 --- docs/tasks.md | 8 ++ nerve/agent/tools/handlers/tasks.py | 21 +++++ nerve/gateway/routes/tasks.py | 16 ++++ nerve/tasks/locks.py | 45 ++++++++++ nerve/tasks/manager.py | 6 ++ tests/test_cause_attribution.py | 126 ++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+) create mode 100644 nerve/tasks/locks.py diff --git a/docs/tasks.md b/docs/tasks.md index fb7a034d1..87a15a3f8 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -169,6 +169,14 @@ Every write to a task row advances `tasks.revision` by one, added in migration v052. The row routes return it, and `task_read` ends with a `` line that `task_write` strips again. +Changes of one task that write its file and its row run one at a time: the +`PATCH` and `move` routes, `task_update`, `task_write`, `task_done` and the +reopen path hold a lock per task from their first read of the row to their +last write (`nerve/tasks/locks.py`). So an edit always lands in the file that +the row names, also when another change moves the file into or out of +`done/` at the same time. Changes of different tasks do not wait for each +other. + Pass the value you read as `expect_revision` to `task_update` or `task_done` to make the write conditional: if the row has changed since, nothing is written and the tool reports a conflict, so re-read and retry. Without it, diff --git a/nerve/agent/tools/handlers/tasks.py b/nerve/agent/tools/handlers/tasks.py index 6fd16c722..d3338abcd 100644 --- a/nerve/agent/tools/handlers/tasks.py +++ b/nerve/agent/tools/handlers/tasks.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +import functools import logging import re from datetime import datetime, timezone @@ -40,10 +41,26 @@ random_status_color, ) from nerve.tasks.files import move_task_file, write_task_file +from nerve.tasks.locks import task_mutation logger = logging.getLogger(__name__) +def _one_change_per_task(handler): + """Run a handler that writes a task file and its row under the task's lock. + + See :mod:`nerve.tasks.locks`. The lock covers the handler's first read + of the row, so the path that it writes is the path the row names. + """ + + @functools.wraps(handler) + async def wrapper(ctx: ToolContext, args: dict) -> ToolResult: + async with task_mutation(args["task_id"]): + return await handler(ctx, args) + + return wrapper + + async def _format_status_reminder(ctx: ToolContext, header: str) -> str: """Build a reminder listing every configured status + its description.""" statuses = await ctx.db.list_task_statuses() if ctx.db else [] @@ -412,6 +429,7 @@ async def task_list_handler(ctx: ToolContext, args: dict) -> ToolResult: return ToolResult.text("\n".join(lines)) +@_one_change_per_task async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: from nerve.tasks.models import parse_tags_string, tags_to_string @@ -625,6 +643,7 @@ async def task_read_handler(ctx: ToolContext, args: dict) -> ToolResult: return ToolResult.text(f"Task file not found for: {task_id}") +@_one_change_per_task async def task_write_handler(ctx: ToolContext, args: dict) -> ToolResult: task_id = args["task_id"] new_content = _REVISION_TRAILER_RE.sub("", args.get("content", "")) @@ -686,6 +705,7 @@ async def task_write_handler(ctx: ToolContext, args: dict) -> ToolResult: return ToolResult.text(f"Task {task_id} written ({len(new_content)} chars).") +@_one_change_per_task async def task_done_handler(ctx: ToolContext, args: dict) -> ToolResult: task_id = args["task_id"] note = args.get("note", "") @@ -752,6 +772,7 @@ async def task_done_handler(ctx: ToolContext, args: dict) -> ToolResult: return ToolResult.text(f"Task {task_id} marked as done.") +@_one_change_per_task async def task_reopen_handler(ctx: ToolContext, args: dict) -> ToolResult: """Mirror of ``task_done``: bring a completed task back to life. diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index 0cad7f44c..f6780f380 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -19,6 +19,7 @@ ) from nerve.identity import Actor, attribution_for from nerve.tasks.files import write_task_file +from nerve.tasks.locks import task_mutation router = APIRouter(dependencies=[Depends(require_auth)]) @@ -260,6 +261,13 @@ async def move_task( task_id: str, req: TaskMoveRequest, actor: Actor = Depends(require_auth), ): """Reorder a task within a lane, or move it to another one.""" + # One lock for the status change and the rank: a change that lands + # between them could move the file while the rank write keeps a status. + async with task_mutation(task_id): + return await _move_task(task_id, req, actor) + + +async def _move_task(task_id: str, req: TaskMoveRequest, actor: Actor) -> dict: deps = get_deps() task = await deps.db.get_task(task_id) if not task: @@ -323,6 +331,14 @@ async def get_task(task_id: str): async def update_task( task_id: str, req: TaskUpdateRequest, actor: Actor = Depends(require_auth), ): + # One lock for the content save and the field and status changes, from + # the first read of the row: the file this request writes is the file + # that the row names when the request ends. + async with task_mutation(task_id): + return await _update_task(task_id, req, actor) + + +async def _update_task(task_id: str, req: TaskUpdateRequest, actor: Actor) -> dict: deps = get_deps() task = await deps.db.get_task(task_id) if not task: diff --git a/nerve/tasks/locks.py b/nerve/tasks/locks.py new file mode 100644 index 000000000..b64a90214 --- /dev/null +++ b/nerve/tasks/locks.py @@ -0,0 +1,45 @@ +"""One lock per task for the changes that write a task file and its row. + +A task change reads the row, writes or moves the markdown file, and writes +the row. Two changes of one task that interleave can strand an edit: one of +them writes the path it read after the other has moved the file into or out +of ``done/``, so the edit goes to a file that the row no longer names. Every +such change runs inside :func:`task_mutation`, so the changes of one task +run one at a time. Changes of different tasks do not wait for each other. + +The lock is re-entrant for the asyncio task that holds it: a handler that +calls another handler for the same task (``task_update`` calls +``task_done``) does not wait for itself. Another asyncio task waits. The +locks live in this process; Nerve runs one gateway process. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import weakref +from collections.abc import AsyncIterator + +# A lock exists while some caller holds or waits for it. +_locks: "weakref.WeakValueDictionary[str, asyncio.Lock]" = weakref.WeakValueDictionary() +# The asyncio task that holds each task's lock. +_owners: dict[str, asyncio.Task] = {} + + +@contextlib.asynccontextmanager +async def task_mutation(task_id: str) -> AsyncIterator[None]: + """Hold the lock of ``task_id`` for one file-plus-row change.""" + me = asyncio.current_task() + if me is not None and _owners.get(task_id) is me: + yield + return + lock = _locks.get(task_id) + if lock is None: + lock = asyncio.Lock() + _locks[task_id] = lock + async with lock: + _owners[task_id] = me + try: + yield + finally: + _owners.pop(task_id, None) diff --git a/nerve/tasks/manager.py b/nerve/tasks/manager.py index 7adee2fc5..b1350e887 100644 --- a/nerve/tasks/manager.py +++ b/nerve/tasks/manager.py @@ -11,6 +11,7 @@ from nerve.db import Database from nerve.identity import Attribution, attribution_for from nerve.tasks.files import move_task_file +from nerve.tasks.locks import task_mutation from nerve.tasks.models import ( Task, TaskStatus, @@ -129,6 +130,11 @@ async def get_task(self, task_id: str) -> Task | None: return task async def mark_done(self, task_id: str, expect_revision: int | None = None) -> bool: + """Mark a task as done and move its file, under the task's lock.""" + async with task_mutation(task_id): + return await self._mark_done(task_id, expect_revision) + + async def _mark_done(self, task_id: str, expect_revision: int | None) -> bool: """Mark a task as done and move its file. Returns ``False`` when the task is missing, or when ``expect_revision`` diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 9d83fd20b..0fb4a590b 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -14,6 +14,7 @@ import base64 import json import sqlite3 +import threading from types import SimpleNamespace from unittest.mock import patch @@ -538,6 +539,131 @@ async def test_rest_patch_content_keeps_a_concurrent_move(install, monkeypatch): assert "New body." in (install.config.workspace / task["file_path"]).read_text() +def _pause_file_writes(monkeypatch, module) -> tuple[threading.Event, threading.Event]: + """Make ``module``'s task file write wait until the test releases it.""" + entered, release = threading.Event(), threading.Event() + real_write = module.write_task_file + + def paused_write(path, content): + entered.set() + assert release.wait(5), "the test never released the write" + real_write(path, content) + + monkeypatch.setattr(module, "write_task_file", paused_write) + return entered, release + + +async def _move_while_the_edit_writes(client, install, task_id, status, edit, entered, release): + """Start ``edit``, move the task while its file write waits, then finish.""" + editing = asyncio.create_task(edit) + assert await asyncio.to_thread(entered.wait, 5) + moving = asyncio.create_task(client.post( + f"/api/tasks/{task_id}/move", headers=install.headers("bob"), + json={"status": status}, + )) + await asyncio.sleep(0.2) + # The move waits for the edit: both change the file and the row. + assert not moving.done() + release.set() + return await editing, await moving + + +def _task_file(install, lane: str, task_id: str): + return install.config.workspace / "memory" / "tasks" / lane / f"{task_id}.md" + + +@pytest.mark.asyncio +async def test_an_agent_edit_and_a_move_into_done_do_not_lose_the_edit( + install, monkeypatch, +): + from nerve.agent.tools.handlers import tasks as task_handlers + + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, + )).json()["task"]["id"] + ctx = ToolContext( + session_id="alices-turn", workspace=install.config.workspace, + db=install.db, config=install.config, engine=install.engine, + actor=install.db.system_actor, cause=install.actor_id("alice"), + ) + await task_handlers.task_read_handler(ctx, {"task_id": task_id}) + entered, release = _pause_file_writes(monkeypatch, task_handlers) + written, moved = await _move_while_the_edit_writes( + client, install, task_id, "done", + task_handlers.task_write_handler( + ctx, {"task_id": task_id, "content": "# Raced\n\nAgent edit.\n"}, + ), + entered, release, + ) + + assert not written.is_error and moved.status_code == 200 + task = await install.db.get_task(task_id) + assert task["status"] == "done" + assert task["file_path"] == f"memory/tasks/done/{task_id}.md" + assert "Agent edit." in _task_file(install, "done", task_id).read_text() + assert not _task_file(install, "active", task_id).exists() + + +@pytest.mark.asyncio +async def test_a_rest_title_edit_and_a_move_into_done_do_not_lose_the_edit( + install, monkeypatch, +): + from nerve.agent.tools.handlers import tasks as task_handlers + + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, + )).json()["task"]["id"] + entered, release = _pause_file_writes(monkeypatch, task_handlers) + patched, moved = await _move_while_the_edit_writes( + client, install, task_id, "done", + client.patch( + f"/api/tasks/{task_id}", headers=install.headers("alice"), + json={"title": "Raced and renamed"}, + ), + entered, release, + ) + + assert patched.status_code == 200 and moved.status_code == 200 + task = await install.db.get_task(task_id) + assert (task["status"], task["title"]) == ("done", "Raced and renamed") + assert "# Raced and renamed" in _task_file(install, "done", task_id).read_text() + assert not _task_file(install, "active", task_id).exists() + + +@pytest.mark.asyncio +async def test_a_rest_content_edit_and_a_move_out_of_done_do_not_lose_the_edit( + install, monkeypatch, +): + from nerve.gateway.routes import tasks as tasks_route + + async with _client(install.app()) as client: + task_id = (await client.post( + "/api/tasks", headers=install.headers("alice"), json={"title": "Raced"}, + )).json()["task"]["id"] + await client.post( + f"/api/tasks/{task_id}/move", headers=install.headers("alice"), + json={"status": "done"}, + ) + entered, release = _pause_file_writes(monkeypatch, tasks_route) + patched, moved = await _move_while_the_edit_writes( + client, install, task_id, "pending", + client.patch( + f"/api/tasks/{task_id}", headers=install.headers("alice"), + json={"content": "# Raced\n\nEdited while done.\n"}, + ), + entered, release, + ) + + assert patched.status_code == 200 and moved.status_code == 200 + task = await install.db.get_task(task_id) + assert task["status"] == "pending" + assert task["file_path"] == f"memory/tasks/active/{task_id}.md" + assert "Edited while done." in _task_file(install, "active", task_id).read_text() + assert not _task_file(install, "done", task_id).exists() + + @pytest.mark.asyncio async def test_task_status_definition_changes_record_actor_and_cause(install): from nerve.agent.tools.handlers.tasks import task_status_create_handler From 60d9e94dae45886574d339af57ed9c4c9a623f6c Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:13:15 +0000 Subject: [PATCH 13/17] Document who is billed while background work shares a process 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 --- docs/accounts.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index b89f8c6c9..a58dff0fc 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -212,8 +212,28 @@ another cause that can act before it ends (an agent, a workflow or a watcher). A report turn for background tasks of different causes also has an unknown cause. Nerve records an unknown cause rather than the wrong person. -Nerve stores the cause on the session before the turn reaches the agent. If -that write fails, the turn does not start. +Nerve stores a turn's cause on the session (`sessions.turn_caused_by_actor_id`) +before the turn reaches the agent, and tries the write twice. If both writes +fail, a turn that a message, a schedule or another Nerve caller started does +not start. A turn that the agent starts on its own, to report background work, +is already running in the agent: it continues with an unknown cause, and the +session row keeps the previous cause until a later write succeeds. A restart +in that window resumes the session for the previous cause. + +When Nerve sends the cause with model requests (external mode), the agent +process gets one principal when it starts, and that process serves the whole +session. Nerve does not stop background work (a background command, agent, +workflow or watcher) to start the process again. So when a turn starts while +background work that a turn of another principal started still runs, Nerve +keeps the process: + +- The model requests of the new turn, and of the background work, carry the + principal that the process was started for. That principal is billed. +- The new turn records an unknown cause if the background work can act while + it runs (an agent, a workflow or a watcher), and its own cause if the + background work is a command. +- This lasts until a turn starts while no background work runs. The process + then starts again for that turn's cause. | Work | Actor | Cause | |---|---|---| From 756b530672eca33b94134f4356220502b8e831d6 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:54:07 +0000 Subject: [PATCH 14/17] Give each session one fixed cause for all agent work 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 --- nerve/agent/engine.py | 283 ++------- nerve/agent/plan_service.py | 8 +- nerve/agent/sessions.py | 21 +- nerve/agent/tools/handlers/plans.py | 8 +- nerve/agent/tools/registry.py | 10 +- nerve/db/actor_columns.py | 3 +- nerve/db/messages.py | 39 +- nerve/db/migrations/v053_cause_attribution.py | 30 +- nerve/db/sessions.py | 32 +- nerve/gateway/routes/plans.py | 3 +- nerve/gateway/routes/sessions.py | 6 +- nerve/gateway/server.py | 3 - nerve/identity.py | 7 +- nerve/mcp_server/http.py | 14 +- nerve/mcp_server/session.py | 4 + nerve/sources/codex_threads/ingester.py | 3 +- tests/test_attribution.py | 8 + tests/test_autonomous_turns.py | 5 - tests/test_cause_attribution.py | 574 ++++-------------- tests/test_plan_revise.py | 2 + tests/test_resume_after_restart.py | 28 - 21 files changed, 249 insertions(+), 842 deletions(-) diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 5f9ef6111..55bbaa4a0 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -86,26 +86,6 @@ logger = logging.getLogger(__name__) -class TurnCauseNotStored(RuntimeError): - """The cause of a turn could not be written to the session row.""" - - def __init__(self, session_id: str) -> None: - super().__init__(f"could not store the turn cause of session {session_id}") - - -# ``_persisted_turn_causes`` value for a session whose row has no known write. -_NOT_STORED = object() - - -@dataclass(frozen=True, slots=True) -class _BackgroundTask: - """A live background task and the cause of the turn that started it.""" - - cause_id: str | None - # An agent, a workflow or a watcher: it can call tools or start a turn - # before it settles. A shell command cannot. - acts_while_running: bool - _SURROGATE_RE = re.compile(r"[\ud800-\udfff]") # Injected as an internal turn when a session is resumed after a @@ -275,27 +255,11 @@ def __init__(self, config: NerveConfig, db: Database): # Read by session-scoped tools (send_file) to avoid dispatching via # stale router context from a prior inbound channel. self._active_channel: dict[str, str] = {} - # Cause actor ID of the agent turn each session's runtime runs now, or - # ran last: the principal on whose behalf the turn runs (None = - # unknown). Set by _begin_runtime_turn when a turn starts: a run() - # turn after the pre-query drain, an autonomous turn when the drain - # opens it. Read through session_cause(). - self._turn_causes: dict[str, str | None] = {} - # Each live background task (Bash/Agent run_in_background, Monitor) - # with the cause of the turn that started it. session_id -> task_id - # -> _BackgroundTask. A task leaves the map when it settles. - self._bg_task_causes: dict[str, dict[str, _BackgroundTask]] = {} - # Cause for a session's next autonomous turn: the cause of the - # background tasks that settled since the last one (None when they - # differ). The CLI starts that turn to report the settled tasks. - self._autonomous_causes: dict[str, str | None] = {} - # The turn cause last written to each session row. It differs from - # _turn_causes only after a failed write, which the next turn retries. - self._persisted_turn_causes: dict[str, str | None] = {} - # Sessions whose runtime can still emit output of an autonomous turn - # that a drain gave up on (no content in time, or no result in time). - # The next run() turn of the session runs with an unknown cause. - self._unfinished_autonomous: set[str] = set() + # Each session's stored cause (sessions.caused_by_actor_id), read + # when this process first loads the session row for a turn or a + # client. The value never changes, so it is safe to keep. Read + # through session_cause(). + self._session_causes: dict[str, str | None] = {} # Resolved model bound to each session's live SDK client. Used to # detect mid-session model switches (the CLI fixes its model at # connect time, so a change requires recreating the client). @@ -418,122 +382,21 @@ def get_active_channel(self, session_id: str) -> str | None: return self._active_channel.get(session_id) def session_cause(self, session_id: str) -> str | None: - """The cause actor ID of the agent turn that the session's runtime runs. - - A run() turn takes this value when its query starts, after the - autonomous output that buffered before it has drained under the cause - of the turn that produced it. An autonomous turn takes the cause of - the background task whose settlement started it. While no turn runs, - the value of the last turn stays. ``None`` when the cause is unknown, - including a session with no turn in this process. - """ - return self._turn_causes.get(session_id) - - async def _begin_runtime_turn( - self, session_id: str, cause_id: str | None, *, must_persist: bool, - ) -> None: - """Make ``cause_id`` the cause of the turn that the runtime starts now. - - The value is stored on the session row first, so that a restart - resumes the interrupted turn for the same cause. A write that fails - twice gives the session an unknown cause in memory: tool calls then - record no cause, not the cause of an earlier turn. With - ``must_persist`` (a run() turn, before its query) the failure also - raises :class:`TurnCauseNotStored`, so the turn does not start. The - next turn retries the write. - """ - if self._persisted_turn_causes.get(session_id, _NOT_STORED) != cause_id: - error: Exception | None = None - for _ in range(2): - try: - await self.db.set_session_turn_cause(session_id, cause_id) - except Exception as e: # noqa: BLE001 - retried, then reported - error = e - continue - self._persisted_turn_causes[session_id] = cause_id - error = None - break - if error is not None: - self._persisted_turn_causes.pop(session_id, None) - self._turn_causes[session_id] = None - logger.error( - "Could not store the turn cause of session %s: %s", - session_id, error, - ) - if must_persist: - raise TurnCauseNotStored(session_id) from error - return - self._turn_causes[session_id] = cause_id - - def _cause_for_new_turn(self, session_id: str, cause_id: str | None) -> str | None: - """The cause that a run() turn can claim when its query starts. - - One runtime process serves the session, and its tool calls carry no - turn. While output of another cause can still arrive, a tool call - cannot be told apart. The turn then runs with an unknown cause (None) - and does not claim that output for this turn's person: - - - an autonomous turn that the pre-query drain gave up on can still - emit; - - a running background task of another cause can call tools or - start a turn before it settles (see :meth:`_acting_task_causes`). - - Reads state only, so it can be asked at any point of a turn. - """ - if session_id in self._unfinished_autonomous: - logger.info( - "Session %s: earlier autonomous output can still arrive; " - "the new turn runs with an unknown cause", session_id, - ) - return None - if self._acting_task_causes(session_id) - {cause_id}: - logger.info( - "Session %s: a background task of another cause is running; " - "the new turn runs with an unknown cause", session_id, - ) - return None - return cause_id - - def _cause_for_autonomous_turn(self, session_id: str) -> str | None: - """The cause of an autonomous turn that the runtime starts. - - The turn reports the background tasks that settled since the last - one, so it takes their cause. Without one it continues the last turn - (late output of a turn that a drain gave up on, for example). A - running background task of another cause could also be the reason - for the turn, or call tools in it; then the cause is unknown (None). + """The session's stored cause: who all agent work in it runs for. + + That is the person who started the session, the system actor for a + session the instance started for itself, or ``None`` when unknown + (channel input with no known person, an imported or MCP satellite + session). The value is set once when the session is created and never + changes. It is read from the session row when this process first + loads the session for a turn or a client; before that it is ``None``. """ - if session_id in self._autonomous_causes: - cause_id = self._autonomous_causes.pop(session_id) - else: - cause_id = self._turn_causes.get(session_id) - if self._acting_task_causes(session_id) - {cause_id}: - return None - return cause_id + return self._session_causes.get(session_id) - def _acting_task_causes(self, session_id: str) -> set[str | None]: - """Causes of the running background tasks that act before they settle. - - A background agent or workflow calls tools, and a watcher starts - turns, while it runs. A background shell command does neither until - it settles, so it is left out. A task of an unknown kind counts. - """ - return { - task.cause_id - for task in (self._bg_task_causes.get(session_id) or {}).values() - if task.acts_while_running - } - - def _forget_background_causes(self, session_id: str) -> None: - """Drop the causes of runtime work that died with its client. - - Every path that tears down a session's client calls this: discard, - a dead client, a model change, a crash retry, a stop and a turn - error. - """ - self._bg_task_causes.pop(session_id, None) - self._autonomous_causes.pop(session_id, None) - self._unfinished_autonomous.discard(session_id) + def _remember_session_cause(self, session: dict | None) -> None: + """Cache the stored cause of a session row that was just read.""" + if session and session.get("id"): + self._session_causes[session["id"]] = session.get("caused_by_actor_id") # ------------------------------------------------------------------ # # Backend resolution (sticky per session) # @@ -542,9 +405,8 @@ def _forget_background_causes(self, session_id: str) -> None: def _build_tool_context(self, session_id: str) -> ToolContext: """Fresh per-session ToolContext for backend MCP servers. - The agent's tool calls are the system actor's changes. The context - lives as long as the client, across turns, so the cause is read from - the session's current turn at the time of each call. + The agent's tool calls are the system actor's changes, made for the + session's stored cause. """ return ToolContext( session_id=session_id, @@ -1260,6 +1122,7 @@ async def _get_or_create_client( # Session row first — backend resolution is sticky on it. session = await self.db.get_session(session_id) + self._remember_session_cause(session) backend = self._backend_for(session, source) requested_model = ( model or (session or {}).get("model") @@ -1278,7 +1141,6 @@ async def _get_or_create_client( session_id, ) self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) self.sessions.remove_client(session_id) self._session_backends.pop(session_id, None) self._session_models.pop(session_id, None) @@ -1294,7 +1156,6 @@ async def _get_or_create_client( session_id, bound_model, requested_model, ) self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) self.sessions.remove_client(session_id) self._session_backends.pop(session_id, None) self._session_models.pop(session_id, None) @@ -1617,7 +1478,6 @@ async def _discard_client( subsequent fires) long after the agent turn had finished. """ self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) if background_memorize: await self.schedule_memorize(session_id) else: @@ -1840,11 +1700,6 @@ async def resume_enrolled_sessions(self) -> int: continue try: logger.info("Resume-after-restart: continuing session %s", sid) - # The continuation finishes the interrupted turn, so it runs - # for the cause stored when that turn started. Without one - # (a row older than v053, input with no known person) it is - # the instance's own work. - cause = session.get("turn_caused_by_actor_id") or self.db.system_actor await self.run( session_id=sid, user_message=_RESUME_AFTER_RESTART_PROMPT, @@ -1855,7 +1710,6 @@ async def resume_enrolled_sessions(self) -> int: # the session keeps their messages; the continuation is # the assistant's. actor=self.db.system_actor, - cause=cause, ) resumed += 1 except Exception as e: @@ -2215,8 +2069,6 @@ async def _handle_system_event( if not task_id: return - self._track_background_cause(session_id, subtype, task_id, data) - registry = self._bg_task_registry.setdefault(session_id, {}) entry = registry.get(task_id) if entry is None: @@ -2290,36 +2142,6 @@ async def _handle_system_event( "tasks": list(registry.values()), }) - def _track_background_cause( - self, session_id: str, subtype: str, task_id: str, data: dict, - ) -> None: - """Bind a background task to the cause of the turn that started it. - - When the task settles, its cause becomes the cause of the session's - next autonomous turn, which the runtime starts to report the result. - """ - tasks = self._bg_task_causes.setdefault(session_id, {}) - if subtype == "task_started": - if task_id not in tasks: - task_type = str(data.get("task_type") or "") - tasks[task_id] = _BackgroundTask( - cause_id=self._turn_causes.get(session_id), - acts_while_running="bash" not in task_type, - ) - return - settled = subtype == "task_notification" or ( - subtype == "task_updated" - and str((data.get("patch") or {}).get("status") or "") - in self._BG_TERMINAL_STATUSES - ) - if settled and task_id in tasks: - cause_id = tasks.pop(task_id).cause_id - pending = self._autonomous_causes - # Tasks of different causes settled for one report: unknown. - if session_id in pending and pending[session_id] != cause_id: - cause_id = None - pending[session_id] = cause_id - async def _emit_workflow_progress( self, session_id: str, @@ -2716,15 +2538,11 @@ async def run( reply, whose authorship is its ``role``. Required keyword-only: an autonomous caller must say so rather than inherit a person's identity by omission. - cause: The principal on whose behalf the turn runs: an - :class:`Actor`, an actor ID, or ``None`` for unknown. - The default is the actor: the person for a person, the - system actor for the system actor, unknown for no - actor. Pass the person whose request started work that - the system actor does. The cause is fixed for the - turn: it is stored on the turn's user row, becomes - :meth:`session_cause` when the turn's query starts, and - goes to the background tasks that the turn starts. + cause: The session's cause, used only when this run creates the + session: an :class:`Actor`, an actor ID, or ``None`` + for unknown. The default is the actor. An existing + session keeps its stored cause (see + :meth:`session_cause`), whoever sends the message. internal: If True, the user_message is a system-generated trigger (e.g., background task completion) and won't be stored in DB or shown in the UI. @@ -2824,10 +2642,14 @@ async def _run_inner( actor: Actor | None, cause_id: str | None, ) -> str: - # Ensure session exists in DB - await self.sessions.get_or_create(session_id, source=source, actor=actor) + # Ensure session exists in DB. ``cause_id`` applies only if the row + # is created here. + await self.sessions.get_or_create( + session_id, source=source, actor=actor, cause=cause_id, + ) session = await self.db.get_session(session_id) + self._remember_session_cause(session) if not internal and session: current_title = session.get("title") @@ -2861,7 +2683,6 @@ async def _run_inner( session_id, "user", db_text, channel=channel, blocks=image_refs, actor=actor, - caused_by=cause_id, ) # Turn accumulator — shared shape with the autonomous-turn drain. @@ -2922,17 +2743,15 @@ async def _run_inner( # idle watcher claimed it). Without this, receive_response() # below would consume the stale turn and terminate on ITS # ResultMessage — answering this message with the previous - # turn's output (off-by-one desync). A started autonomous turn - # is drained to its result: the runtime answers this message - # only after that turn anyway, and its output belongs to its own - # cause. If no content comes within the first-content timeout, - # the drain gives up; late content then interleaves into this - # turn's stream (still rendered), the idle watcher self-heals - # the remainder, and this turn runs with an unknown cause. + # turn's output (off-by-one desync). The short first-content + # timeout keeps a just-started autonomous turn from delaying + # the user's message for long; if its content arrives later it + # interleaves into this turn's stream (still rendered) and the + # idle watcher self-heals the remainder. try: await self._drain_pending_messages( session_id, client, source, channel, - first_content_timeout=self._PRE_QUERY_FIRST_CONTENT_SECONDS, + first_content_timeout=3.0, ) except asyncio.CancelledError: raise @@ -2946,15 +2765,6 @@ async def _run_inner( # done backstop still covers THIS turn. broadcaster.mark_turn_open(session_id) - # This turn starts in the runtime now. The drain above ran under - # the cause of the work it drained. An unfinished autonomous turn - # makes only this turn unknown; its late output arrives in it. - runtime_cause = self._cause_for_new_turn(session_id, cause_id) - self._unfinished_autonomous.discard(session_id) - await self._begin_runtime_turn( - session_id, runtime_cause, must_persist=True, - ) - # New user turn: settled background-task chips are stale now. self._prune_bg_tasks(session_id) @@ -3045,7 +2855,6 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: session_id, _qerr, ) self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) self.sessions.remove_client(session_id) unregister_handler(session_id) await self._safe_disconnect(client) @@ -3083,7 +2892,6 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: session_id, _recv_err, ) self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) self.sessions.remove_client(session_id) unregister_handler(session_id) await self._safe_disconnect(client) @@ -3120,7 +2928,6 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: ) await self.sessions.mark_stopped(session_id) self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) unregister_handler(session_id) client = self.sessions.remove_client(session_id) if client: @@ -3217,7 +3024,6 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: await self.schedule_memorize(session_id) # Clear resume — CLI state may be corrupted after error self._stop_idle_watcher(session_id) - self._forget_background_causes(session_id) unregister_handler(session_id) client = self.sessions.remove_client(session_id) await self.sessions.mark_error(session_id, error_msg) @@ -3258,9 +3064,6 @@ def _with_session_preamble(base: TurnInput) -> TurnInput: # How often the idle watcher probes the SDK buffer (seconds). _IDLE_STREAM_POLL_SECONDS = 0.5 - # How long the pre-query drain waits for the first content of an - # autonomous turn that has started. The same as the idle watcher's wait. - _PRE_QUERY_FIRST_CONTENT_SECONDS = 30.0 async def _drain_pending_messages( self, @@ -3312,12 +3115,6 @@ async def _open_turn() -> None: if st is not None: return st = _TurnState() - # The turn continues the work of the turn that started the - # background tasks it reports; see _cause_for_autonomous_turn. - await self._begin_runtime_turn( - session_id, self._cause_for_autonomous_turn(session_id), - must_persist=False, - ) # Leading marker block → "background continuation" chip in the # UI, both live (auto_turn event) and after reload (persisted). st.ordered_blocks.append({"type": "auto"}) @@ -3389,8 +3186,6 @@ async def _close_turn() -> None: session_id, first_content_timeout, ) await _close_turn() # empty — dropped - # The runtime can still emit this turn. - self._unfinished_autonomous.add(session_id) break logger.warning( "Autonomous turn idle timeout (%ss) for session %s " @@ -3403,7 +3198,6 @@ async def _close_turn() -> None: else "[Background turn interrupted: runtime went silent]" ) await _close_turn() - self._unfinished_autonomous.add(session_id) raise if batch is None: logger.warning( @@ -3466,7 +3260,6 @@ async def _close_turn() -> None: ) if turn_done: await _close_turn() - self._unfinished_autonomous.discard(session_id) # A ModelObserved whose batch never produced content is # dropped with it — an empty assistant message carries # nothing worth persisting. diff --git a/nerve/agent/plan_service.py b/nerve/agent/plan_service.py index 91cb9b8d9..520b3083e 100644 --- a/nerve/agent/plan_service.py +++ b/nerve/agent/plan_service.py @@ -69,8 +69,9 @@ async def request_plan_revision( plan_id: The pending plan to revise. feedback: Free-text feedback explaining what should change. cause: Who asked for the revision: the person for the HTTP route, - the cause of the calling turn for the tool. The revision turn - runs as the system actor on behalf of this principal. + the calling session's cause for the tool. It becomes the cause of + the planner session if this call creates it; an existing planner + session keeps its own cause. Returns: ``{"plan_id", "task_id", "session_id", "status"}`` on success. @@ -152,7 +153,7 @@ async def request_plan_revision( ) await engine.sessions.get_or_create( session_id, title=session_title, source="cron", - actor=engine.db.system_actor, + actor=engine.db.system_actor, cause=cause, ) asyncio.create_task( engine.run( @@ -160,7 +161,6 @@ async def request_plan_revision( # The revision prompt is built from a template here, not typed by # the person who asked for the revision. actor=engine.db.system_actor, - cause=cause, ) ) diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index b1ad61312..e720b4953 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -19,7 +19,7 @@ from typing import Any from nerve.db import Database -from nerve.identity import Actor, ActorOrId +from nerve.identity import CAUSE_FROM_ACTOR, Actor, CauseArg logger = logging.getLogger(__name__) @@ -101,19 +101,21 @@ async def get_or_create( cwd: str | None = None, *, actor: Actor | None, + cause: CauseArg = CAUSE_FROM_ACTOR, ) -> dict: """Get an existing session or create a new one. - ``actor`` is only used when a row is actually created — an existing - session keeps the creator it was stamped with, so re-resolving a - session someone else started never re-attributes it. Required - keyword-only, like the store method underneath it. + ``actor`` and ``cause`` are only used when a row is actually created + — an existing session keeps the creator and the cause it was stamped + with, so re-resolving a session someone else started never + re-attributes it. ``actor`` is required keyword-only, like the store + method underneath it; ``cause`` defaults to the actor. """ session = await self.db.get_session(session_id) if not session: session = await self._create_session( session_id, title=title, source=source, metadata=metadata, - backend=backend, model=model, cwd=cwd, actor=actor, + backend=backend, model=model, cwd=cwd, actor=actor, cause=cause, ) return session @@ -130,6 +132,7 @@ async def _create_session( cwd: str | None = None, *, actor: Actor | None, + cause: CauseArg = CAUSE_FROM_ACTOR, ) -> dict: """Create a new session with status=created and log the event.""" if backend is None: @@ -154,6 +157,7 @@ async def _create_session( model=model, cwd=cwd, actor=actor, + cause=cause, ) await self.db.log_session_event(session_id, "created", { "source": source, @@ -645,13 +649,11 @@ async def add_message( bump_updated_at: bool = True, *, actor: Actor | None, - caused_by: ActorOrId | None = None, ) -> int: """Add a message to a session's history. ``actor`` is the principal whose input this row records, or ``None`` - for assistant and tool output, and ``caused_by`` is the cause of the - turn that a user row starts. See + for assistant and tool output — see :meth:`nerve.db.messages.MessageStore.add_message`. ``bump_updated_at=False`` records the message without moving the @@ -664,7 +666,6 @@ async def add_message( native_turn_id=native_turn_id, bump_updated_at=bump_updated_at, actor=actor, - caused_by=caused_by, ) async def get_conversation_history( diff --git a/nerve/agent/tools/handlers/plans.py b/nerve/agent/tools/handlers/plans.py index 61f86b45c..f151ba06c 100644 --- a/nerve/agent/tools/handlers/plans.py +++ b/nerve/agent/tools/handlers/plans.py @@ -227,8 +227,8 @@ async def plan_approve_handler(ctx: ToolContext, args: dict) -> ToolResult: plan_type = plan.get("plan_type", "generic") # The agent approved this plan through its own tool, so both the session - # and the run it dispatches are the instance's own work. The run is on - # behalf of the cause of the turn that approved it. + # and the run it dispatches are the instance's own work. The session's + # cause is the cause of the session in which the agent approved it. impl_actor = ctx.db.system_actor impl_cause = ctx.cause_id @@ -238,7 +238,7 @@ async def plan_approve_handler(ctx: ToolContext, args: dict) -> ToolResult: impl_session_id = f"impl-{str(uuid.uuid4())[:8]}" await ctx.engine.sessions.get_or_create( impl_session_id, title=f"Implement: {task['title']}", source="web", - actor=impl_actor, + actor=impl_actor, cause=impl_cause, ) await ctx.db.update_plan(plan_id, impl_session_id=impl_session_id) @@ -303,7 +303,7 @@ async def _run_impl(): try: await engine.run( session_id=impl_session_id, user_message=prompt, source="web", - actor=impl_actor, cause=impl_cause, + actor=impl_actor, ) except Exception: logger.exception("Implementation session %s failed", impl_session_id) diff --git a/nerve/agent/tools/registry.py b/nerve/agent/tools/registry.py index bb6a68070..06535e04d 100644 --- a/nerve/agent/tools/registry.py +++ b/nerve/agent/tools/registry.py @@ -44,11 +44,11 @@ class ToolContext: ``actor`` performs the tool call: the system actor when the agent calls a tool, the person when an HTTP route invokes a handler. ``cause`` is the - principal on whose behalf the call happens. It is a fixed value, or a - callable that returns the cause when a handler reads it: a context that - the engine builds lives as long as the session's client, across turns, so - it reads the cause of the turn that runs at the time of the call. Read it - through :attr:`cause_id` or :attr:`attribution`, never directly. + principal on whose behalf the call happens: for an agent tool call, the + session's stored cause (``sessions.caused_by_actor_id``); for a route, the + person. It is a fixed value, or a callable that returns the cause when a + handler reads it (the engine reads its cache of the session's cause). Read + it through :attr:`cause_id` or :attr:`attribution`, never directly. """ session_id: str diff --git a/nerve/db/actor_columns.py b/nerve/db/actor_columns.py index 62224621f..99a18eda8 100644 --- a/nerve/db/actor_columns.py +++ b/nerve/db/actor_columns.py @@ -14,10 +14,9 @@ ACTOR_COLUMNS: tuple[tuple[str, str], ...] = ( ("sessions", "created_by_actor_id"), ("messages", "actor_id"), - ("messages", "caused_by_actor_id"), ("task_events", "actor_id"), ("task_events", "caused_by_actor_id"), - ("sessions", "turn_caused_by_actor_id"), + ("sessions", "caused_by_actor_id"), ("task_status_events", "actor_id"), ("task_status_events", "caused_by_actor_id"), ) diff --git a/nerve/db/messages.py b/nerve/db/messages.py index 32a56536e..0abbc277f 100644 --- a/nerve/db/messages.py +++ b/nerve/db/messages.py @@ -5,7 +5,7 @@ import json from datetime import datetime, timezone -from nerve.identity import Actor, ActorOrId, actor_id_of +from nerve.identity import Actor class MessageStore: @@ -25,7 +25,6 @@ async def add_message( bump_updated_at: bool = True, *, actor: Actor | None, - caused_by: ActorOrId | None = None, ) -> int: """Insert a message row. ``external_id`` enables idempotent ingest from external sources (Codex thread sync, MCP server). @@ -39,10 +38,6 @@ async def add_message( the principal that prompted output is not stored. ``channel`` is transport provenance, never identity. - ``caused_by`` is the cause of the turn that a user row starts: the - principal on whose behalf the turn runs. Pass it on user rows that - start a turn; every other row keeps ``None``. - ``created_at`` lets external ingesters preserve original Codex timestamps. Defaults to ``CURRENT_TIMESTAMP`` for native callers. @@ -54,32 +49,27 @@ async def add_message( left alone. """ actor_id = actor.actor_id if actor else None - caused_by_actor_id = actor_id_of(caused_by) async with self._atomic(): if created_at is not None: async with self.db.execute( """INSERT INTO messages (session_id, role, content, thinking, blocks, channel, - external_id, native_turn_id, actor_id, - caused_by_actor_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + external_id, native_turn_id, actor_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (session_id, role, content, thinking, json.dumps(blocks) if blocks else None, - channel, external_id, native_turn_id, actor_id, - caused_by_actor_id, created_at), + channel, external_id, native_turn_id, actor_id, created_at), ) as cursor: msg_id = cursor.lastrowid else: async with self.db.execute( """INSERT INTO messages (session_id, role, content, thinking, blocks, channel, - external_id, native_turn_id, actor_id, - caused_by_actor_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + external_id, native_turn_id, actor_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (session_id, role, content, thinking, json.dumps(blocks) if blocks else None, - channel, external_id, native_turn_id, actor_id, - caused_by_actor_id), + channel, external_id, native_turn_id, actor_id), ) as cursor: msg_id = cursor.lastrowid # Update session timestamp and message counter @@ -152,11 +142,10 @@ async def copy_messages_to_session( the native context at, so display and context never disagree. Returns the number of rows copied. - ``actor_id`` and ``caused_by_actor_id`` are copied with the rest: a - fork displays the same conversation, so its copied turns keep the - people who actually sent them rather than being re-attributed to - whoever forked. Who made the *fork* is recorded on the new session's - ``created_by_actor_id``. + ``actor_id`` is copied with the rest: a fork displays the same + conversation, so its copied turns keep the people who actually sent + them rather than being re-attributed to whoever forked. Who made the + *fork* is recorded on the new session's ``created_by_actor_id``. """ boundary_id: int | None = None if up_to_native_turn_id is not None: @@ -184,11 +173,9 @@ async def copy_messages_to_session( async with self.db.execute( f"""INSERT INTO messages (session_id, role, content, thinking, blocks, channel, - external_id, native_turn_id, actor_id, - caused_by_actor_id, created_at) + external_id, native_turn_id, actor_id, created_at) SELECT ?, role, content, thinking, blocks, channel, - 'forkcopy:' || id, native_turn_id, actor_id, - caused_by_actor_id, created_at + 'forkcopy:' || id, native_turn_id, actor_id, created_at FROM messages WHERE {where} ORDER BY id ASC""", params, ) as cursor: diff --git a/nerve/db/migrations/v053_cause_attribution.py b/nerve/db/migrations/v053_cause_attribution.py index 9b3ef542b..c2c8bb0cf 100644 --- a/nerve/db/migrations/v053_cause_attribution.py +++ b/nerve/db/migrations/v053_cause_attribution.py @@ -1,16 +1,13 @@ -"""V53: record the cause of a turn and who changes tasks and task statuses. - -Adds four nullable columns that hold an ``actor_refs.id``: - -- ``messages.caused_by_actor_id``: on a user row that starts a turn, the - principal on whose behalf the turn runs. That is the person who sent the - message, or the person whose request started work by the system actor, or - the system actor for autonomous work. Assistant and tool output rows keep - it ``NULL``. -- ``sessions.turn_caused_by_actor_id``: the cause of the agent turn that the - session runs now, or ran last. It is written when the turn starts, so after - a restart it names the cause of the interrupted turn, also of an internal - turn that has no user row. +"""V53: record the cause of a session and who changes tasks and task statuses. + +Adds three nullable columns that hold an ``actor_refs.id``: + +- ``sessions.caused_by_actor_id``: the session's cause, the principal on + whose behalf all agent work in the session runs. It is set once when the + session is created and never changes: the person who started the session, + the system actor for a session that the instance starts for itself, or + ``NULL`` when unknown (channel input with no known person, an imported or + MCP satellite session). - ``task_events.actor_id``: who changed the status. A person through the UI or REST, or the system actor for an agent tool call or another internal process. @@ -25,8 +22,8 @@ deleted, so its history cannot live on the row. **Existing rows stay NULL and nothing is backfilled.** NULL reads as "not -recorded". The free-text origin of an old task event does not name a person, -so a backfill would invent attribution. +recorded". The free-text origin of an old task event or the creator of an old +session does not prove a cause, so a backfill would invent attribution. The foreign keys follow v048: an ID that resolves to no actor can never be stored, and NULL is exempt from the check. Each column is added only when the @@ -43,8 +40,7 @@ # (table, column, DDL type + constraints) _COLUMNS = ( - ("messages", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), - ("sessions", "turn_caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), + ("sessions", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), ("task_events", "actor_id", "TEXT REFERENCES actor_refs(id)"), ("task_events", "caused_by_actor_id", "TEXT REFERENCES actor_refs(id)"), ) diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index e18bda875..e63766214 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -5,7 +5,7 @@ import json from datetime import datetime, timezone -from nerve.identity import Actor +from nerve.identity import CAUSE_FROM_ACTOR, Actor, CauseArg, resolve_cause # Sources the sidebar treats as "system" (machine-driven); everything else is a conversation by exclusion, so a new source shows up in the feed by default. SYSTEM_SOURCES = ("cron", "hook") @@ -29,6 +29,7 @@ async def create_session( cwd: str | None = None, *, actor: Actor | None, + cause: CauseArg = CAUSE_FROM_ACTOR, ) -> dict: """Insert a session row, recording who caused it to exist. @@ -39,8 +40,14 @@ async def create_session( sessions the instance mints for itself, and ``None`` where attribution is unavailable. ``source`` is transport provenance, never identity. - The ``OR IGNORE`` insert makes creator attribution write-once; - re-resolving an existing session cannot replace it. + ``cause`` is the session's cause (``caused_by_actor_id``): the + principal on whose behalf all agent work in the session runs. The + default is the actor. Pass it when they differ, for example ``None`` + for a session that the system actor creates for input from an unknown + person. + + The ``OR IGNORE`` insert makes creator and cause attribution + write-once; re-resolving an existing session cannot replace them. Returns the **stored** row, not the arguments. The distinction only shows up when the insert was ignored — a same-id race, or a short-id @@ -50,16 +57,17 @@ async def create_session( """ now = datetime.now(timezone.utc).isoformat() created_by = actor.actor_id if actor else None + caused_by = resolve_cause(actor, cause) await self._write( """INSERT OR IGNORE INTO sessions (id, title, source, metadata, status, parent_session_id, forked_from_message, backend, model, cwd, created_by_actor_id, - created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + caused_by_actor_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (session_id, title or session_id, source, json.dumps(metadata or {}), status, parent_session_id, forked_from_message, backend, model, cwd, - created_by, now, now), + created_by, caused_by, now, now), ) stored = await self.get_session(session_id) if stored is not None: @@ -72,6 +80,7 @@ async def create_session( "parent_session_id": parent_session_id, "backend": backend, "model": model, "cwd": cwd, "created_by_actor_id": created_by, + "caused_by_actor_id": caused_by, } async def get_session(self, session_id: str) -> dict | None: @@ -322,17 +331,6 @@ async def update_session_fields(self, session_id: str, fields: dict) -> None: return await self._write(*built) - async def set_session_turn_cause(self, session_id: str, cause_id: str | None) -> None: - """Store the cause of the agent turn that the session starts now. - - Written when the turn starts, so after a restart the row names the - cause of the interrupted turn. ``None`` stores an unknown cause. - """ - await self._write( - "UPDATE sessions SET turn_caused_by_actor_id = ? WHERE id = ?", - (cause_id, session_id), - ) - async def bind_native_thread( self, backend: str, native_thread_id: str, session_id: str, ) -> None: diff --git a/nerve/gateway/routes/plans.py b/nerve/gateway/routes/plans.py index e0ef23df5..0cfcaf383 100644 --- a/nerve/gateway/routes/plans.py +++ b/nerve/gateway/routes/plans.py @@ -232,9 +232,8 @@ async def _run_impl(): # Nerve assembles the prompt from the task file and the # approved plan, so the turn is the instance's own work even # though a person's approval started it. The session above - # carries who approved, and the turn runs on their behalf. + # carries who approved, and its cause is the approver. actor=deps.db.system_actor, - cause=actor, ) except Exception: logger.exception("Implementation session %s failed", impl_session_id) diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index 9a98947c7..bfd355d5e 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -509,12 +509,11 @@ async def update_session(session_id: str, req: dict, actor: Actor = Depends(requ try: # Nerve generates this trigger, so it uses the system actor. The # person's star action is separate, and ``internal`` persists no - # user message. The turn runs on behalf of the person who starred. + # user message. asyncio.create_task( deps.engine.run( session_id=session_id, user_message=trigger, source="web", internal=True, actor=deps.db.system_actor, - cause=actor, ) ) except Exception as e: # a hook failure must never break the PATCH @@ -690,9 +689,8 @@ async def run_later(req: RunLaterRequest, actor: Actor = Depends(require_auth)): req.session_id, role="user", content=message, blocks=blocks or None, channel="web", # The person composed this message; deferring when it runs does not - # change who wrote it. The deferred turn runs on their behalf. + # change who wrote it. actor=actor, - caused_by=actor, ) scheduled = delay != "none" diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 713951818..22db88c6e 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -1196,9 +1196,6 @@ async def ws_broadcast(session_id: str, message: dict): "content": user_text, "blocks": image_refs or None, "actor_id": connection.actor.actor_id, - # The person's own message starts a turn on their - # behalf; see engine.run's default cause. - "caused_by_actor_id": connection.actor.actor_id, }, exclude=client_id) # Run agent in background, store task for stop support. diff --git a/nerve/identity.py b/nerve/identity.py index a1b85aa73..abfeddcb2 100644 --- a/nerve/identity.py +++ b/nerve/identity.py @@ -21,9 +21,10 @@ A write records two IDs. The *actor* made the change: a person through the UI, REST or the browser WebSocket, or the system actor for a tool call, a cron job or any other internal process. The *cause* is the principal on whose behalf -the work happens: the person for a change that the person made, and for work -by the system actor the person whose request started it, or the system actor -for autonomous work. ``None`` in either position means unknown. +the work happens: the person for a change that the person made, and for agent +work the cause of its session (``sessions.caused_by_actor_id``), which is the +person who started the session or the system actor for a session that the +instance started for itself. ``None`` in either position means unknown. :func:`attribution_for` builds the pair for one write. """ diff --git a/nerve/mcp_server/http.py b/nerve/mcp_server/http.py index e1a7ce651..bc862de7d 100644 --- a/nerve/mcp_server/http.py +++ b/nerve/mcp_server/http.py @@ -25,7 +25,6 @@ from __future__ import annotations -import functools import json import logging from typing import TYPE_CHECKING, Callable @@ -189,14 +188,6 @@ async def _resolve(rctx: ServerRequestContext | None = None) -> ToolContext: session_id, runtime_metadata = _bound_identity_from_request( engine.config, rctx ) - # A bound call is the agent's tool call inside its engine session's - # turn, so it carries that turn's cause. A satellite call comes from - # an agent that this instance does not run, so its cause is unknown. - cause = ( - None if session_id is None - else functools.partial(engine.session_cause, session_id) - ) - if session_id is None: client_name, mcp_session_id, _ = _resolve_client_info(rctx) @@ -214,6 +205,9 @@ async def _resolve(rctx: ServerRequestContext | None = None) -> ToolContext: "mcp_session_id": mcp_session_id, } + # The call is the agent's change for the session's stored cause. A + # satellite session stores none: its agent runs outside this instance. + session = await engine.db.get_session(session_id) return ToolContext( session_id=session_id, workspace=engine.config.workspace, @@ -226,7 +220,7 @@ async def _resolve(rctx: ServerRequestContext | None = None) -> ToolContext: notification_service=engine.notification_service, runtime_metadata=runtime_metadata, actor=engine.db.system_actor, - cause=cause, + cause=(session or {}).get("caused_by_actor_id"), ) return _resolve diff --git a/nerve/mcp_server/session.py b/nerve/mcp_server/session.py index fbecf9405..0b2cfbd27 100644 --- a/nerve/mcp_server/session.py +++ b/nerve/mcp_server/session.py @@ -152,6 +152,8 @@ async def resolve( metadata=metadata, status="active", actor=self.db.system_actor, + # The agent behind it runs outside this instance. + cause=None, ) logger.info( "Created Codex satellite session %s via MCP (mcp=%s)", @@ -183,6 +185,8 @@ async def resolve( metadata=metadata, status="active", actor=self.db.system_actor, + # The agent behind it runs outside this instance. + cause=None, ) logger.info( "Created satellite session %s (client=%s, mcp=%s)", diff --git a/nerve/sources/codex_threads/ingester.py b/nerve/sources/codex_threads/ingester.py index 0823f4662..17d2fdad4 100644 --- a/nerve/sources/codex_threads/ingester.py +++ b/nerve/sources/codex_threads/ingester.py @@ -203,8 +203,9 @@ async def _ensure_session(self, event: ThreadEvent) -> None: backend="codex", cwd=meta.cwd, # The sync created this row, not a person — the Codex thread it - # mirrors was started outside Nerve entirely. + # mirrors was started outside Nerve entirely, by nobody known. actor=self.db.system_actor, + cause=None, ) await self.db.bind_native_thread("codex", event.thread_id, session_id) self.stats["threads_in_scope"] += 1 diff --git a/tests/test_attribution.py b/tests/test_attribution.py index dee81c07a..35b61c928 100644 --- a/tests/test_attribution.py +++ b/tests/test_attribution.py @@ -76,6 +76,9 @@ def app(self) -> FastAPI: async def creator_of(self, session_id: str) -> str | None: return (await self.db.get_session(session_id))["created_by_actor_id"] + async def cause_of(self, session_id: str) -> str | None: + return (await self.db.get_session(session_id))["caused_by_actor_id"] + async def messages_in(self, session_id: str) -> list[dict]: return await self.db.get_messages(session_id) @@ -325,6 +328,7 @@ async def test_websocket_created_session_belongs_to_the_connection( await _ws_endpoint()(socket) switched = next(msg for msg in socket.sent if msg["type"] == "session_switched") assert await install.creator_of(switched["session_id"]) == install.bob_actor + assert await install.cause_of(switched["session_id"]) == install.bob_actor async def _wait_for_user_messages(install, session_id: str, count: int) -> None: @@ -345,6 +349,7 @@ async def test_autonomous_cron_uses_the_system_actor(install, monkeypatch): if row["source"] == "cron" ) assert session["created_by_actor_id"] == install.system_actor_id + assert session["caused_by_actor_id"] == install.system_actor_id assert [(row["role"], row["actor_id"]) for row in await install.messages_in(session["id"])] == [ ("user", install.system_actor_id), ("assistant", None), @@ -379,6 +384,7 @@ async def test_unidentified_channel_human_stays_unattributed( )) session_id = await install.engine.sessions.get_last_session(key) assert await install.creator_of(session_id) is None + assert await install.cause_of(session_id) is None assert await install.said_in(session_id) == [("hello from a chat app", None)] @@ -440,6 +446,8 @@ async def test_codex_sync_session_is_system_but_imported_human_is_unknown(instal session_id = "codex:thread-aaa" assert await install.creator_of(session_id) == install.system_actor_id + # Nobody known started the thread that the session imports. + assert await install.cause_of(session_id) is None assert [(row["role"], row["actor_id"]) for row in await install.messages_in(session_id)] == [ ("user", None), ("assistant", None), diff --git a/tests/test_autonomous_turns.py b/tests/test_autonomous_turns.py index 1c71dced0..9033f19b0 100644 --- a/tests/test_autonomous_turns.py +++ b/tests/test_autonomous_turns.py @@ -41,11 +41,6 @@ def _make_engine() -> AgentEngine: ), ) engine._bg_task_registry = {} - engine._bg_task_causes = {} - engine._autonomous_causes = {} - engine._turn_causes = {} - engine._persisted_turn_causes = {} - engine._unfinished_autonomous = set() engine._workflows = {} engine._idle_watchers = {} engine._session_locks = {} diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index 0fb4a590b..ef671e0f6 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -1,11 +1,12 @@ -"""Actor and cause attribution: the turn cause plumbing and task attribution. +"""Actor and cause attribution: the session cause and task attribution. A write records who made it (``actor_id``) and on whose behalf (``caused_by_actor_id``). A person who changes something directly is both. A -tool call by the agent is the system actor's change, made for the cause of the -turn it runs in. These tests drive the real routes, engine, tool registry and +tool call by the agent is the system actor's change, made for the cause of its +session (``sessions.caused_by_actor_id``), which is fixed when the session is +created. These tests drive the real routes, engine, tool registry and database; only the model runtime is replaced, by a client factory that makes -tool calls the way the agent would inside the turn. +tool calls the way the agent would inside a turn. """ from __future__ import annotations @@ -13,7 +14,6 @@ import asyncio import base64 import json -import sqlite3 import threading from types import SimpleNamespace from unittest.mock import patch @@ -23,7 +23,7 @@ import pytest_asyncio from fastapi import FastAPI -from nerve.agent.backends import SystemEvent, TextDelta, TurnCompleted +from nerve.agent.backends import TurnCompleted from nerve.agent.engine import AgentEngine from nerve.agent.tools.handlers.plans import plan_approve_handler from nerve.agent.tools.registry import ToolContext @@ -121,19 +121,8 @@ class _FakeRuntime: The engine uses it through the client API (``start_turn``, ``receive_turn``, the idle-event calls). Each turn calls ``task_create`` through the session's own tool context, as the Claude CLI calls the - in-process MCP server, and then completes. - - - ``turn_events``: events that the next turn emits before its tool call, - for example a background task that it starts. - - ``buffered``: autonomous output that arrived between runs; the engine - drains it before the next turn. - - ``in_flight``: for an autonomous turn in flight, the steps that - ``receive_idle_events`` takes: (seconds to wait first, tool call - first?, next batch). With no step left it waits out the timeout and - raises ``TimeoutError``, like a runtime that has not produced output. - - ``fail``: the next turn dies after its first output, like a runtime - that is killed mid-turn (a restart). The engine does not retry a turn - that has output. + in-process MCP server, and then completes. ``before_tool_call`` runs + first when it is set. """ native_session_id = None @@ -141,28 +130,10 @@ class _FakeRuntime: def __init__(self, install: "_Install", session_id: str): self.install = install self.session_id = session_id - self.turn_events: list = [] - self.buffered: list[list] = [] - self.in_flight: list[tuple[bool, list]] = [] self.before_tool_call = None - self.fail = False def try_receive_idle_events(self): - return self.buffered.pop(0) if self.buffered else None - - async def receive_idle_events(self, timeout=None): - if not self.in_flight: - await asyncio.sleep(timeout or 0) - raise asyncio.TimeoutError - delay, tool_call, batch = self.in_flight.pop(0) - if timeout is not None and delay > timeout: - await asyncio.sleep(timeout) - self.in_flight.insert(0, (delay - timeout, tool_call, batch)) - raise asyncio.TimeoutError - await asyncio.sleep(delay) - if tool_call: - await self.install.agent_tool_call(self.session_id) - return batch + return None async def start_turn(self, turn_input) -> None: return None @@ -171,13 +142,6 @@ async def disconnect(self) -> None: return None async def receive_turn(self): - events, self.turn_events = self.turn_events, [] - for event in events: - yield event - if self.fail: - self.fail = False - yield TextDelta(text="Working on it") - raise RuntimeError("the runtime died mid-turn") if self.before_tool_call is not None: await self.before_tool_call() await self.install.agent_tool_call(self.session_id) @@ -219,13 +183,13 @@ async def events(self, task_id: str) -> list[tuple]: async def user_rows(self, session_id: str) -> list[tuple]: return [ - (row["content"], row["actor_id"], row["caused_by_actor_id"]) + (row["content"], row["actor_id"]) for row in await self.db.get_messages(session_id) if row["role"] == "user" ] async def agent_tool_call(self, session_id: str) -> None: - """The agent calls ``task_create`` in the session's current turn.""" + """The agent calls ``task_create`` in a turn of the session.""" engine = self.engine cause = engine.session_cause(session_id) result = await engine.registry.invoke( @@ -261,8 +225,8 @@ def turn_in(self, session_id: str) -> dict: (turn,) = self.turns_in(session_id) return turn - async def turn_cause_of(self, session_id: str) -> str | None: - return (await self.db.get_session(session_id))["turn_caused_by_actor_id"] + async def session_cause_of(self, session_id: str) -> str | None: + return (await self.db.get_session(session_id))["caused_by_actor_id"] @pytest_asyncio.fixture @@ -271,7 +235,6 @@ async def install(tmp_path, open_identity_db, wire_identity_store): "workspace": str(tmp_path / "ws"), "codex": {"home_dir": str(tmp_path / "codex-home")}, "auth": {"jwt_secret": _SECRET}, - "sessions": {"star_project_hook": True}, }) (config.workspace / "memory" / "tasks" / "active").mkdir(parents=True) set_config(config) @@ -346,7 +309,7 @@ async def test_the_registry_lists_every_attribution_column(install): @pytest.mark.asyncio async def test_v053_keeps_old_rows_null_and_is_idempotent(install): raw = install.db.db - await raw.execute("ALTER TABLE messages DROP COLUMN caused_by_actor_id") + await raw.execute("ALTER TABLE sessions DROP COLUMN caused_by_actor_id") await raw.execute("ALTER TABLE task_events DROP COLUMN actor_id") await raw.execute("ALTER TABLE task_events DROP COLUMN caused_by_actor_id") await raw.execute( @@ -368,7 +331,8 @@ async def test_v053_keeps_old_rows_null_and_is_idempotent(install): await up(raw) await up(raw) await raw.commit() - assert await install.user_rows("old") == [("from before", None, None)] + assert await install.session_cause_of("old") is None + assert await install.user_rows("old") == [("from before", None)] assert await install.events("old-task") == [(None, "pending", "web", None, None)] @@ -713,49 +677,71 @@ async def test_task_status_definition_changes_record_actor_and_cause(install): # --------------------------------------------------------------------------- # -# Turns: the cause on the user row, in the engine and on tool changes # +# The session cause: stored once, used by every agent action in the session # # --------------------------------------------------------------------------- # @pytest.mark.asyncio -async def test_a_tool_change_inside_a_persons_turn_is_system_for_that_person( +async def test_a_person_creates_and_forks_sessions_for_themselves(install): + alice, bob = install.actor_id("alice"), install.actor_id("bob") + async with _client(install.app()) as client: + created = ( + await client.post("/api/sessions", headers=install.headers("alice"), json={}) + ).json() + assert created["caused_by_actor_id"] == alice + # REST chat without a session opens the API channel's session. + await client.post( + "/api/chat", headers=install.headers("bob"), json={"message": "hi"}, + ) + await install.db.update_session_fields(created["id"], {"sdk_session_id": "native-1"}) + fork = (await client.post( + "/api/sessions/fork", headers=install.headers("bob"), + json={"source_session_id": created["id"]}, + )).json() + + api_session = await install.engine.sessions.get_last_session("api:default") + assert await install.session_cause_of(api_session) == bob + assert fork["caused_by_actor_id"] == bob + + +@pytest.mark.asyncio +async def test_a_tool_change_in_alices_session_is_for_alice_whoever_sends_the_turn( install, monkeypatch, ): + """A shared session: every turn's agent work is for the session's starter.""" install.use_fake_runtime(monkeypatch) - bob = install.actor_id("bob") + alice, bob = install.actor_id("alice"), install.actor_id("bob") async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("bob")) - await _chat(client, install.headers("bob"), session_id, "make a task") + session_id = await _new_session(client, install.headers("alice")) + await _chat(client, install.headers("alice"), session_id, "first") + await _chat(client, install.headers("bob"), session_id, "second") - turn = install.turn_in(session_id) - assert turn["cause"] == bob - assert await install.user_rows(session_id) == [("make a task", bob, bob)] - assert await install.events(turn["task_id"]) == [ - (None, "pending", session_id, install.system_actor_id, bob), - ] - assert await install.turn_cause_of(session_id) == bob - # The value stays after the turn, for autonomous turns between runs. - assert install.engine.session_cause(session_id) == bob + assert [turn["cause"] for turn in install.turns_in(session_id)] == [alice, alice] + for turn in install.turns_in(session_id): + assert await install.events(turn["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, alice), + ] + # Each message still names who wrote it. + assert await install.user_rows(session_id) == [("first", alice), ("second", bob)] + assert install.engine.session_cause(session_id) == alice @pytest.mark.asyncio -async def test_a_cron_turns_tool_change_is_system_for_system(install, monkeypatch): +async def test_a_cron_sessions_tool_change_is_system_for_system(install, monkeypatch): install.use_fake_runtime(monkeypatch) await install.engine.run_cron(job_id="nightly", prompt="tidy the board") turn = install.turns[0] system = install.system_actor_id + assert await install.session_cause_of(turn["session_id"]) == system assert turn["cause"] == system - assert await install.user_rows(turn["session_id"]) == [ - ("tidy the board", system, system), - ] assert await install.events(turn["task_id"]) == [ (None, "pending", turn["session_id"], system, system), ] @pytest.mark.asyncio -async def test_concurrent_turns_of_two_people_keep_their_causes(install, monkeypatch): +async def test_concurrent_sessions_of_two_people_keep_their_causes(install, monkeypatch): install.use_fake_runtime(monkeypatch) alice, bob = install.actor_id("alice"), install.actor_id("bob") both_in_flight = asyncio.Barrier(2) @@ -782,388 +768,65 @@ async def _wait_for_the_other_turn(): @pytest.mark.asyncio -async def test_autonomous_work_keeps_the_cause_of_the_turn_that_started_it( - install, monkeypatch, -): - """Alice's background task settles while Bob starts a turn in her session. - - The continuation that reports Alice's task drains before Bob's turn. It - runs for Alice; Bob's turn runs for Bob. - """ +async def test_channel_input_with_no_person_has_no_cause(install, monkeypatch): install.use_fake_runtime(monkeypatch) - alice, bob = install.actor_id("alice"), install.actor_id("bob") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runtime = install.runtime(session_id) - # Alice's turn starts a background task. - runtime.turn_events = [ - SystemEvent( - subtype="task_started", - data={"task_id": "bg-1", "task_type": "local_bash"}, - ), - ] - await _chat(client, install.headers("alice"), session_id, "run the build") - - # The task settles and the CLI starts a turn to report it, which - # buffers while no run is active. Its tool call comes mid-turn. - runtime.buffered = [[ - SystemEvent( - subtype="task_notification", - data={"task_id": "bg-1", "status": "completed"}, - ), - SystemEvent(subtype="init"), - TextDelta(text="The build passed."), - ]] - runtime.in_flight = [(0, True, [TurnCompleted()])] - await _chat(client, install.headers("bob"), session_id, "and now this") - - hers, continuation, his = install.turns_in(session_id) - assert (hers["cause"], continuation["cause"], his["cause"]) == (alice, alice, bob) - assert await install.events(continuation["task_id"]) == [ - (None, "pending", session_id, install.system_actor_id, alice), - ] - assert await install.events(his["task_id"]) == [ - (None, "pending", session_id, install.system_actor_id, bob), - ] - assert await install.turn_cause_of(session_id) == bob - - -@pytest.mark.asyncio -async def test_an_autonomous_turn_after_a_later_turn_keeps_the_starters_cause( - install, monkeypatch, -): - """A background task that Alice started settles after Bob's turn ended.""" - install.use_fake_runtime(monkeypatch) - alice, bob = install.actor_id("alice"), install.actor_id("bob") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runtime = install.runtime(session_id) - runtime.turn_events = [ - SystemEvent( - subtype="task_started", - data={"task_id": "bg-1", "task_type": "local_bash"}, - ), - ] - await _chat(client, install.headers("alice"), session_id, "start a long job") - await _chat(client, install.headers("bob"), session_id, "something else") - - # The idle watcher drains the continuation between runs. - runtime.buffered = [[ - SystemEvent( - subtype="task_updated", - data={"task_id": "bg-1", "patch": {"status": "completed"}}, - ), - SystemEvent(subtype="init"), - TextDelta(text="The long job finished."), - ]] - runtime.in_flight = [(0, True, [TurnCompleted()])] - await install.engine._drain_pending_messages( - session_id, runtime, "web", "web", manage_framing=True, - ) - - assert [turn["cause"] for turn in install.turns_in(session_id)] == [ - alice, bob, alice, - ] - assert install.engine.session_cause(session_id) == alice - - -@pytest.mark.asyncio -async def test_late_autonomous_output_is_not_given_the_next_persons_cause( - install, monkeypatch, -): - """Alice's continuation has started but shows no output in time. - - Bob's turn cannot tell her late output from his own, so it runs with an - unknown cause, not Bob's. His next turn is his again. - """ - install.use_fake_runtime(monkeypatch) - monkeypatch.setattr(AgentEngine, "_PRE_QUERY_FIRST_CONTENT_SECONDS", 0.05) - alice, bob = install.actor_id("alice"), install.actor_id("bob") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runtime = install.runtime(session_id) - runtime.turn_events = [ - SystemEvent( - subtype="task_started", - data={"task_id": "bg-1", "task_type": "local_bash"}, - ), - ] - await _chat(client, install.headers("alice"), session_id, "run the build") - runtime.buffered = [[ - SystemEvent( - subtype="task_notification", - data={"task_id": "bg-1", "status": "completed"}, - ), - SystemEvent(subtype="init"), - ]] - await _chat(client, install.headers("bob"), session_id, "and now this") - assert await install.turn_cause_of(session_id) is None - await _chat(client, install.headers("bob"), session_id, "one more") - - hers, unknown, his = install.turns_in(session_id) - assert (hers["cause"], unknown["cause"], his["cause"]) == (alice, None, bob) - assert await install.events(unknown["task_id"]) == [ - (None, "pending", session_id, install.system_actor_id, None), - ] - # Bob's message still records him as the cause of the turn it started. - assert (await install.user_rows(session_id))[1] == ("and now this", bob, bob) - - -@pytest.mark.asyncio -async def test_the_next_turn_waits_for_a_slow_autonomous_turn(install, monkeypatch): - """Alice's continuation shows its first output after more than 3 s.""" - install.use_fake_runtime(monkeypatch) - alice, bob = install.actor_id("alice"), install.actor_id("bob") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runtime = install.runtime(session_id) - runtime.turn_events = [ - SystemEvent( - subtype="task_started", - data={"task_id": "bg-1", "task_type": "local_bash"}, - ), - ] - await _chat(client, install.headers("alice"), session_id, "run the build") - runtime.buffered = [[ - SystemEvent( - subtype="task_notification", - data={"task_id": "bg-1", "status": "completed"}, - ), - SystemEvent(subtype="init"), - ]] - runtime.in_flight = [ - (3.2, False, [TextDelta(text="The build passed.")]), - (0, True, [TurnCompleted()]), - ] - await _chat(client, install.headers("bob"), session_id, "and now this") - - hers, continuation, his = install.turns_in(session_id) - assert (hers["cause"], continuation["cause"], his["cause"]) == (alice, alice, bob) - - -@pytest.mark.asyncio -async def test_a_running_background_agent_of_another_person_leaves_the_cause_unknown( - install, monkeypatch, -): - install.use_fake_runtime(monkeypatch) - alice = install.actor_id("alice") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - install.runtime(session_id).turn_events = [ - SystemEvent( - subtype="task_started", - data={"task_id": "agent-1", "task_type": "local_agent"}, - ), - ] - await _chat(client, install.headers("alice"), session_id, "research this") - # Alice's background agent can still call tools during Bob's turn. - await _chat(client, install.headers("bob"), session_id, "meanwhile") - await _chat(client, install.headers("alice"), session_id, "and this") - - assert [turn["cause"] for turn in install.turns_in(session_id)] == [ - alice, None, alice, - ] - - -def _report_turn(*events) -> list: - """Buffered output: settle events, then an autonomous turn that starts.""" - return [*events, SystemEvent(subtype="init"), TextDelta(text="Report.")] - - -def _settled(task_id: str) -> SystemEvent: - return SystemEvent( - subtype="task_notification", data={"task_id": task_id, "status": "completed"}, - ) - - -def _started(task_id: str, task_type: str) -> SystemEvent: - return SystemEvent( - subtype="task_started", data={"task_id": task_id, "task_type": task_type}, - ) - - -@pytest.mark.asyncio -async def test_one_report_of_tasks_of_two_people_has_no_cause(install, monkeypatch): - install.use_fake_runtime(monkeypatch) - alice, bob = install.actor_id("alice"), install.actor_id("bob") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runtime = install.runtime(session_id) - runtime.turn_events = [_started("bg-a", "local_bash")] - await _chat(client, install.headers("alice"), session_id, "build A") - runtime.turn_events = [_started("bg-b", "local_bash")] - await _chat(client, install.headers("bob"), session_id, "build B") - - # Both tasks settle and the runtime reports them in one turn. - runtime.buffered = [_report_turn(_settled("bg-a"), _settled("bg-b"))] - runtime.in_flight = [(0, True, [TurnCompleted()])] - await install.engine._drain_pending_messages( - session_id, runtime, "web", "web", manage_framing=True, - ) - assert [turn["cause"] for turn in install.turns_in(session_id)] == [ - alice, bob, None, - ] - - -@pytest.mark.asyncio -async def test_a_watcher_turn_takes_the_watchers_cause_or_none(install, monkeypatch): - """A watcher starts turns while it runs, without settling.""" - install.use_fake_runtime(monkeypatch) - alice = install.actor_id("alice") - - async def watcher_event_turn(): - runtime.buffered = [_report_turn()] - runtime.in_flight = [(0, True, [TurnCompleted()])] - await install.engine._drain_pending_messages( - session_id, runtime, "web", "web", manage_framing=True, - ) - - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runtime = install.runtime(session_id) - runtime.turn_events = [_started("watch-1", "monitor")] - await _chat(client, install.headers("alice"), session_id, "watch the log") - await watcher_event_turn() - await _chat(client, install.headers("bob"), session_id, "something else") - await watcher_event_turn() - - # Alice's watcher turn is hers. Bob's turn and the watcher turn after it - # cannot tell Alice's watcher apart from Bob's work: unknown, never Bob. - assert [turn["cause"] for turn in install.turns_in(session_id)] == [ - alice, alice, None, None, - ] - - -@pytest.mark.asyncio -async def test_a_turn_does_not_start_until_its_cause_is_stored( - install, monkeypatch, tmp_path, -): - install.use_fake_runtime(monkeypatch) - alice, bob = install.actor_id("alice"), install.actor_id("bob") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - await _chat(client, install.headers("alice"), session_id, "first") - - real_setter = install.db.set_session_turn_cause - failures = {"left": 2} - - async def flaky_setter(sid, cause_id): - if failures["left"]: - failures["left"] -= 1 - raise sqlite3.OperationalError("disk I/O error") - await real_setter(sid, cause_id) - - monkeypatch.setattr(install.db, "set_session_turn_cause", flaky_setter) - await _chat(client, install.headers("bob"), session_id, "second") - # Both writes failed: the turn did not reach the runtime, and nothing - # runs for an earlier cause. - assert [turn["cause"] for turn in install.turns_in(session_id)] == [alice] - assert install.engine.session_cause(session_id) is None - assert await install.turn_cause_of(session_id) == alice - - await _chat(client, install.headers("bob"), session_id, "third") - assert await install.turn_cause_of(session_id) == bob - - await _restart_and_resume(install, session_id, tmp_path) - assert [turn["cause"] for turn in install.turns_in(session_id)] == [ - alice, bob, bob, - ] - - -@pytest.mark.asyncio -async def test_input_with_no_person_has_no_cause(install, monkeypatch): - install.use_fake_runtime(monkeypatch) - await install.db.create_session("chat-app", source="telegram", actor=None) await install.engine.run( "chat-app", "hello from a chat app", source="telegram", actor=None, ) turn = install.turn_in("chat-app") + assert await install.session_cause_of("chat-app") is None assert turn["cause"] is None - assert await install.user_rows("chat-app") == [("hello from a chat app", None, None)] assert await install.events(turn["task_id"]) == [ (None, "pending", "chat-app", install.system_actor_id, None), ] @pytest.mark.asyncio -async def test_an_explicit_unknown_cause_is_not_replaced_by_the_actor( +async def test_a_run_cause_applies_only_when_the_run_creates_the_session( install, monkeypatch, ): install.use_fake_runtime(monkeypatch) - await install.db.create_session("leg", source="workflow", actor=None) - await install.engine.run( - "leg", "continue", source="workflow", - actor=install.db.system_actor, cause=None, - ) - assert install.turn_in("leg")["cause"] is None - assert await install.user_rows("leg") == [("continue", install.system_actor_id, None)] - - -async def _restart_and_resume(install: _Install, session_id: str, tmp_path) -> None: - """Restart the process and resume ``session_id`` from the resume queue.""" - await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) - # The new process has no turn causes in memory. - install.engine._turn_causes.clear() - queue = tmp_path / "resume-after-restart" - queue.write_text(f"{session_id}\n") - with patch("nerve.agent.engine.RESUME_QUEUE_FILE", queue): - assert await install.engine.resume_enrolled_sessions() == 1 - - -@pytest.mark.asyncio -async def test_resume_after_restart_keeps_the_cause_of_the_interrupted_turn( - install, monkeypatch, tmp_path, -): - install.use_fake_runtime(monkeypatch) - alice = install.actor_id("alice") - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - install.runtime(session_id).fail = True - await _chat(client, install.headers("alice"), session_id, "long job") - - await _restart_and_resume(install, session_id, tmp_path) + alice, bob = install.actor_id("alice"), install.actor_id("bob") + system = install.db.system_actor + await install.engine.run("leg", "start", source="workflow", actor=system, cause=alice) + await install.engine.run("leg", "go on", source="workflow", actor=system, cause=bob) + await install.engine.run("unknown", "start", source="workflow", actor=system, cause=None) - turn = install.turn_in(session_id) - assert turn["cause"] == alice - assert await install.events(turn["task_id"]) == [ - (None, "pending", session_id, install.system_actor_id, alice), - ] + assert await install.session_cause_of("leg") == alice + assert [turn["cause"] for turn in install.turns_in("leg")] == [alice, alice] + assert await install.session_cause_of("unknown") is None + assert install.turn_in("unknown")["cause"] is None @pytest.mark.asyncio -async def test_resume_of_an_interrupted_internal_turn_is_not_a_persons( +async def test_resume_after_restart_keeps_the_session_cause( install, monkeypatch, tmp_path, ): - """The interrupted turn is internal, so it has no user row. - - The newest user row is Alice's, from an earlier turn. The resumed work is - the system actor's own. - """ install.use_fake_runtime(monkeypatch) alice = install.actor_id("alice") - system = install.system_actor_id async with _client(install.app()) as client: session_id = await _new_session(client, install.headers("alice")) - await _chat(client, install.headers("alice"), session_id, "hello") - install.runtime(session_id).fail = True - await install.engine.run( - session_id, "[system tick]", internal=True, actor=install.db.system_actor, - ) - assert await install.user_rows(session_id) == [("hello", alice, alice)] - assert await install.turn_cause_of(session_id) == system + await _chat(client, install.headers("bob"), session_id, "long job") + await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) + # A restart: the new process has read no session row yet. + install.engine._session_causes.clear() + assert install.engine.session_cause(session_id) is None - await _restart_and_resume(install, session_id, tmp_path) + queue = tmp_path / "resume-after-restart" + queue.write_text(f"{session_id}\n") + with patch("nerve.agent.engine.RESUME_QUEUE_FILE", queue): + assert await install.engine.resume_enrolled_sessions() == 1 resumed = install.turns_in(session_id)[-1] - assert resumed["cause"] == system + assert resumed["cause"] == alice assert await install.events(resumed["task_id"]) == [ - (None, "pending", session_id, system, system), + (None, "pending", session_id, install.system_actor_id, alice), ] @pytest.mark.asyncio -async def test_run_later_and_fork_keep_the_cause_on_user_rows(install): +async def test_run_later_and_fork_keep_who_wrote_each_message(install): alice = install.actor_id("alice") async with _client(install.app()) as client: session_id = await _new_session(client, install.headers("alice")) @@ -1177,12 +840,12 @@ async def test_run_later_and_fork_keep_the_cause_on_user_rows(install): json={"source_session_id": session_id}, ) - assert await install.user_rows(session_id) == [("later", alice, alice)] - assert await install.user_rows(fork.json()["id"]) == [("later", alice, alice)] + assert await install.user_rows(session_id) == [("later", alice)] + assert await install.user_rows(fork.json()["id"]) == [("later", alice)] # --------------------------------------------------------------------------- # -# Turns that the system actor runs for a person # +# Sessions that plans start # # --------------------------------------------------------------------------- # @@ -1210,13 +873,13 @@ async def _pending_plan(install: _Install, suffix: str) -> str: ) await install.db.create_plan( plan_id=plan_id, task_id=task_id, content="Ship it", - session_id="proposal", version=1, + session_id=f"planner-{suffix}", version=1, ) return plan_id @pytest.mark.asyncio -async def test_rest_plan_approval_runs_for_the_approver(install, monkeypatch): +async def test_rest_plan_approval_starts_a_session_for_the_approver(install, monkeypatch): runs = _record_runs(install, monkeypatch) plan_id = await _pending_plan(install, "rest") async with _client(install.app()) as client: @@ -1225,29 +888,36 @@ async def test_rest_plan_approval_runs_for_the_approver(install, monkeypatch): ) assert response.status_code == 200 await asyncio.wait_for(install.ran.wait(), timeout=5) + impl = response.json()["impl_session_id"] + assert await install.session_cause_of(impl) == install.actor_id("bob") assert runs[0]["actor"] == install.db.system_actor - assert actor_id_of(runs[0]["cause"]) == install.actor_id("bob") @pytest.mark.asyncio -async def test_tool_plan_approval_runs_for_the_cause_of_the_turn(install, monkeypatch): +async def test_tool_plan_approval_starts_a_session_for_the_approving_sessions_cause( + install, monkeypatch, +): runs = _record_runs(install, monkeypatch) plan_id = await _pending_plan(install, "tool") alice = install.actor_id("alice") + # The agent approves the plan in a session that Alice started. ctx = ToolContext( - session_id="alices-turn", workspace=install.config.workspace, + session_id="alices-session", workspace=install.config.workspace, db=install.db, config=install.config, engine=install.engine, - actor=install.db.system_actor, cause=lambda: alice, + actor=install.db.system_actor, cause=alice, ) await plan_approve_handler(ctx, {"plan_id": plan_id}) await asyncio.wait_for(install.ran.wait(), timeout=5) + impl = (await install.db.get_plan(plan_id))["impl_session_id"] + assert await install.session_cause_of(impl) == alice assert runs[0]["actor"] == install.db.system_actor - assert runs[0]["cause"] == alice @pytest.mark.asyncio -async def test_rest_plan_revision_runs_for_the_requester(install, monkeypatch): - runs = _record_runs(install, monkeypatch) +async def test_a_revision_request_creates_the_planner_session_for_the_requester( + install, monkeypatch, +): + _record_runs(install, monkeypatch) plan_id = await _pending_plan(install, "revise") async with _client(install.app()) as client: response = await client.post( @@ -1256,23 +926,7 @@ async def test_rest_plan_revision_runs_for_the_requester(install, monkeypatch): ) assert response.status_code == 200 await asyncio.wait_for(install.ran.wait(), timeout=5) - assert runs[0]["actor"] == install.db.system_actor - assert actor_id_of(runs[0]["cause"]) == install.actor_id("bob") - - -@pytest.mark.asyncio -async def test_the_star_hook_runs_for_the_person_who_starred(install, monkeypatch): - async with _client(install.app()) as client: - session_id = await _new_session(client, install.headers("alice")) - runs = _record_runs(install, monkeypatch) - response = await client.patch( - f"/api/sessions/{session_id}", headers=install.headers("bob"), - json={"starred": 1}, - ) - assert response.status_code == 200 - await asyncio.wait_for(install.ran.wait(), timeout=5) - assert runs[0]["actor"] == install.db.system_actor - assert actor_id_of(runs[0]["cause"]) == install.actor_id("bob") + assert await install.session_cause_of("planner-revise") == install.actor_id("bob") # --------------------------------------------------------------------------- # @@ -1281,21 +935,27 @@ async def test_the_star_hook_runs_for_the_person_who_starred(install, monkeypatc @pytest.mark.asyncio -async def test_mcp_contexts_carry_the_system_actor_and_the_bound_turn_cause( +async def test_mcp_contexts_carry_the_system_actor_and_the_session_cause( install, monkeypatch, ): resolve = build_ctx_resolver(install.engine, SatelliteSessionResolver(install.db)) rctx = SimpleNamespace(request=None, session=None) + # A satellite session: its agent runs outside this instance. satellite = await resolve(rctx) + assert await install.session_cause_of(satellite.session_id) is None assert satellite.attribution == Attribution(install.system_actor_id, None) + # A Codex session that this instance runs, started by Alice. + await install.engine.sessions.get_or_create( + "codex-session", source="web", + actor=Actor(actor_id=install.actor_id("alice"), kind="human"), + ) monkeypatch.setattr( "nerve.mcp_server.http._bound_identity_from_request", lambda config, rctx: ("codex-session", {}), ) bound = await resolve(rctx) - install.engine._turn_causes["codex-session"] = install.actor_id("alice") assert bound.attribution == Attribution( install.system_actor_id, install.actor_id("alice"), ) @@ -1333,7 +993,7 @@ async def test_the_actor_directory_names_every_id_in_every_registered_column( assert referenced[(table, column)] <= directory, (table, column) # The scenario fills each column that this change adds. for pair in ( - ("messages", "caused_by_actor_id"), + ("sessions", "caused_by_actor_id"), ("task_events", "actor_id"), ("task_events", "caused_by_actor_id"), ): @@ -1395,13 +1055,15 @@ async def test_external_mode_records_principals_as_actor_and_cause( ) session_id = await _new_session(client, bob) await _chat(client, bob, session_id, "make a task") + await _chat(client, alice, session_id, "and one more") assert await install.events(task_id) == [ (None, "pending", "web", ALICE_PRINCIPAL, ALICE_PRINCIPAL), ("pending", "deferred", "web", BOB_PRINCIPAL, BOB_PRINCIPAL), ] - turn = install.turn_in(session_id) - assert turn["cause"] == BOB_PRINCIPAL - assert await install.events(turn["task_id"]) == [ - (None, "pending", session_id, install.system_actor_id, BOB_PRINCIPAL), - ] + assert await install.session_cause_of(session_id) == BOB_PRINCIPAL + for turn in install.turns_in(session_id): + assert turn["cause"] == BOB_PRINCIPAL + assert await install.events(turn["task_id"]) == [ + (None, "pending", session_id, install.system_actor_id, BOB_PRINCIPAL), + ] diff --git a/tests/test_plan_revise.py b/tests/test_plan_revise.py index 7a9c4e2d8..06a7f2e57 100644 --- a/tests/test_plan_revise.py +++ b/tests/test_plan_revise.py @@ -52,12 +52,14 @@ async def get_or_create( metadata: dict | None = None, *, actor: Actor | None, + cause=None, ) -> dict: self.calls.append({ "session_id": session_id, "title": title, "source": source, "actor": actor, + "cause": cause, }) return {"id": session_id, "title": title or session_id, "source": source} diff --git a/tests/test_resume_after_restart.py b/tests/test_resume_after_restart.py index 361fe0157..8bd911b4f 100644 --- a/tests/test_resume_after_restart.py +++ b/tests/test_resume_after_restart.py @@ -73,34 +73,6 @@ async def test_resumes_valid_session_and_drains_file(tmp_path): assert kwargs["user_message"] == _RESUME_AFTER_RESTART_PROMPT -ALICE_ACTOR_ID = "00000000-0000-4000-8000-0000000a11ce" - - -@pytest.mark.asyncio -async def test_resume_runs_for_the_cause_of_the_interrupted_turn(tmp_path): - qf = tmp_path / "resume-after-restart" - qf.write_text("S1\n") - session = {**_session("S1"), "turn_caused_by_actor_id": ALICE_ACTOR_ID} - engine = _engine_with({"S1": session}) - with patch("nerve.agent.engine.RESUME_QUEUE_FILE", qf): - assert await engine.resume_enrolled_sessions() == 1 - kwargs = engine.run.await_args.kwargs - assert kwargs["actor"] == FAKE_SYSTEM_ACTOR - assert kwargs["cause"] == ALICE_ACTOR_ID - - -@pytest.mark.asyncio -async def test_resume_without_a_stored_cause_runs_for_the_system_actor(tmp_path): - qf = tmp_path / "resume-after-restart" - qf.write_text("S1\n") - engine = _engine_with({"S1": _session("S1")}) - with patch("nerve.agent.engine.RESUME_QUEUE_FILE", qf): - assert await engine.resume_enrolled_sessions() == 1 - kwargs = engine.run.await_args.kwargs - assert kwargs["actor"] == FAKE_SYSTEM_ACTOR - assert kwargs["cause"] == FAKE_SYSTEM_ACTOR - - @pytest.mark.asyncio async def test_skips_missing_archived_external_and_no_sdk(tmp_path): qf = tmp_path / "resume-after-restart" From 4052ff71ab8e7739c4a3a7a6e5596c9c2ac4ef36 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:54:07 +0000 Subject: [PATCH 15/17] Document the session cause 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 --- docs/accounts.md | 84 ++++++++++++++++++------------------------------ docs/api.md | 22 ++++++------- docs/tasks.md | 6 ++-- 3 files changed, 45 insertions(+), 67 deletions(-) diff --git a/docs/accounts.md b/docs/accounts.md index a58dff0fc..aa7d2c064 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -176,17 +176,15 @@ Nerve can record two actor IDs for a change: tool call, a cron job, any internal process). - The **cause** is the principal on whose behalf the change was made. For a change that a person made directly, the cause is that person. For work by - the system actor, the cause is the person whose request started the work, - or the system actor for autonomous work. + the agent, the cause is the cause of its session. These columns store actor IDs: | Column | What it records | |---|---| | `sessions.created_by_actor_id` | Who created the session | +| `sessions.caused_by_actor_id` | The session's cause: on whose behalf all agent work in the session runs | | `messages.actor_id` | Who supplied the content of a user message | -| `messages.caused_by_actor_id` | The cause of the turn that a user message starts | -| `sessions.turn_caused_by_actor_id` | The cause of the agent turn that the session runs now, or ran last | | `task_events.actor_id`, `task_events.caused_by_actor_id` | Actor and cause of a task status change | | `task_status_events.actor_id`, `task_status_events.caused_by_actor_id` | Actor and cause of a change to the set of task statuses | @@ -195,58 +193,40 @@ names through `GET /api/actors`, which returns every actor that these columns can name: the display name, or the username when none is set. The web UI shows the system actor with a person as cause as "Nerve for ". -Each agent turn has one cause, fixed when the turn starts. Tool calls in the -turn record the system actor as actor and the turn's cause as cause. A -background task (a command or an agent that runs in the background) keeps the -cause of the turn that started it, and so does the turn that the agent starts -to report the result of the task. When a person sends a message while earlier -background output waits, Nerve processes that output for its own cause first: -it waits up to 30 seconds for the first output of a report turn that has -started, and then to the end of that turn. - -One agent process serves a session, and its tool calls do not say which turn -they belong to. So a turn runs with an unknown cause, not with its person's -cause, when output of another cause can still arrive during it: a report turn -that showed no output within the 30 seconds, or a running background task of -another cause that can act before it ends (an agent, a workflow or a -watcher). A report turn for background tasks of different causes also has an -unknown cause. Nerve records an unknown cause rather than the wrong person. - -Nerve stores a turn's cause on the session (`sessions.turn_caused_by_actor_id`) -before the turn reaches the agent, and tries the write twice. If both writes -fail, a turn that a message, a schedule or another Nerve caller started does -not start. A turn that the agent starts on its own, to report background work, -is already running in the agent: it continues with an unknown cause, and the -session row keeps the previous cause until a later write succeeds. A restart -in that window resumes the session for the previous cause. - -When Nerve sends the cause with model requests (external mode), the agent -process gets one principal when it starts, and that process serves the whole -session. Nerve does not stop background work (a background command, agent, -workflow or watcher) to start the process again. So when a turn starts while -background work that a turn of another principal started still runs, Nerve -keeps the process: - -- The model requests of the new turn, and of the background work, carry the - principal that the process was started for. That principal is billed. -- The new turn records an unknown cause if the background work can act while - it runs (an agent, a workflow or a watcher), and its own cause if the - background work is a command. -- This lasts until a turn starts while no background work runs. The process - then starts again for that turn's cause. +### The session's cause + +Every session has one cause. Nerve stores it when it creates the session and +never changes it: + +| Session | Cause | +|---|---| +| 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 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 | +| A workflow leg session | The system actor | +| A Telegram, Slack or other channel session, or an imported Codex session | None until a local identity mapping exists | +| An external MCP client's session | None | + +Everything the agent does in a session (a tool call, a task it changes, a plan +it approves, a session it starts) records the system actor as actor and the +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: + +- 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. | Work | Actor | Cause | |---|---|---| | A person sends a message, or changes a task through the web UI or the REST API | The person | The person | -| Nerve creates a session, including MCP and Codex sessions | The system actor | Not recorded | -| A cron job or a hook | The system actor | The system actor | -| A plan implementation or a plan revision that a person started | The system actor | That person | -| A plan approved or a revision requested by the agent in a turn | The system actor | The cause of that turn | -| The hook turn that starring a session starts | The system actor | The person who starred it | -| A turn resumed after a restart | The system actor | The cause stored when the interrupted turn started (`sessions.turn_caused_by_actor_id`), else the system actor | -| A turn that the agent starts between turns, when a background task finishes | The system actor | The cause of the turn that started the task | -| A tool call from an external MCP client | The system actor | None | -| Workflow legs, wakeups, deferred messages when they run, and answers relayed into a session | The system actor | The system actor | +| The agent works in a session (tool calls, task changes, sessions it starts) | The system actor | The session's cause | | Telegram, Slack, or imported Codex input | None until a local identity mapping exists | None | | Assistant and tool output | None | None | diff --git a/docs/api.md b/docs/api.md index ce777bf46..8f0d3abb8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -781,16 +781,17 @@ Create a new session. ```json Request: { "title": "My Session" } -Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_by_actor_id": "…" } +Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_by_actor_id": "…", "caused_by_actor_id": "…" } ``` `created_by_actor_id` is an actor id for [`GET /api/actors`](#actors), not an account id. It is `null` for legacy rows and sessions caused by an unidentified external person. Every session payload carries the field. -Session payloads also carry `turn_caused_by_actor_id`: the cause of the agent -turn that the session runs now, or ran last (an actor id, or `null` when -unknown). See [Attribution](accounts.md#attribution). +Session payloads also carry `caused_by_actor_id`: the session's cause, on +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). #### `GET /api/sessions/{id}` Get session details. @@ -799,16 +800,14 @@ Get session details. Get messages for a session. ```json -Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "...", "actor_id": "…", "caused_by_actor_id": "…" }] } +Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "...", "actor_id": "…" }] } ``` `actor_id` is who supplied the message: the signed-in person who typed it, or the system principal for a prompt Nerve composed itself. It is `null` on Slack, Telegram, and imported Codex human input until identity mappings exist; -on assistant/tool output; and on legacy history. `caused_by_actor_id` is set on -a user message that starts a turn: the principal on whose behalf the turn runs. -It is `null` on other rows, on input with no known person, and on legacy -history. `channel` stays transport provenance and is never identity. See +on assistant/tool output; and on legacy history. `channel` stays transport +provenance and is never identity. See [Accounts and identity](accounts.md) for what is and is not attributed. #### `DELETE /api/sessions/{id}` @@ -1387,9 +1386,8 @@ fresh. { type: "error", session_id: "main", error: "..." } // Another client of this session sent a message (the sender sees its own -// optimistically; actor_id is who sent it and caused_by_actor_id the cause of -// the turn it starts, matching the stored row) -{ type: "user_message", session_id: "main", content: "Hello", blocks: null, actor_id: "…", caused_by_actor_id: "…" } +// optimistically; actor_id is who sent it, matching the stored row) +{ type: "user_message", session_id: "main", content: "Hello", blocks: null, actor_id: "…" } // Session switch confirmed (includes running state, lifecycle status, buffered events for reconnect) { type: "session_status", session_id: "abc123", is_running: true, status: "active", buffered_events: [...] } diff --git a/docs/tasks.md b/docs/tasks.md index 87a15a3f8..4775c027c 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -146,11 +146,11 @@ synthesized origin from one that actually happened. and on whose behalf, as actor IDs (see [Attribution](accounts.md#attribution)). A change through the HTTP API records the person as both. A change by an agent tool call records the system actor -and the cause of the turn: the person whose message started the turn, or the +and the cause of its session: the person who started the session, or the system actor for a cron job. Rows written before v053 have NULL in both columns. The `GET /api/tasks/{id}/events` rows include both columns, and the -timeline shows "Nerve for " when the agent changed the status for a -person. +timeline shows "Nerve for " when the agent changed the status in a +session that the person started. Changes to the set of statuses (create, update, reorder, delete, through the HTTP API or the `task_status_create` tool) append a row to From 7661807c8d4ef8450980524c08297e98b39054f3 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:21:03 +0000 Subject: [PATCH 16/17] Know every session's stored cause from connect on 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 --- nerve/agent/engine.py | 23 ++++---------- nerve/agent/tools/registry.py | 4 +-- nerve/db/base.py | 7 +++++ nerve/db/sessions.py | 28 ++++++++++++++++- tests/test_cause_attribution.py | 53 +++++++++++++++++++++++++++++++-- 5 files changed, 91 insertions(+), 24 deletions(-) diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 55bbaa4a0..8981e8350 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -13,7 +13,6 @@ import asyncio import contextlib -import functools import json import logging import os @@ -255,11 +254,6 @@ def __init__(self, config: NerveConfig, db: Database): # Read by session-scoped tools (send_file) to avoid dispatching via # stale router context from a prior inbound channel. self._active_channel: dict[str, str] = {} - # Each session's stored cause (sessions.caused_by_actor_id), read - # when this process first loads the session row for a turn or a - # client. The value never changes, so it is safe to keep. Read - # through session_cause(). - self._session_causes: dict[str, str | None] = {} # Resolved model bound to each session's live SDK client. Used to # detect mid-session model switches (the CLI fixes its model at # connect time, so a change requires recreating the client). @@ -388,15 +382,9 @@ def session_cause(self, session_id: str) -> str | None: session the instance started for itself, or ``None`` when unknown (channel input with no known person, an imported or MCP satellite session). The value is set once when the session is created and never - changes. It is read from the session row when this process first - loads the session for a turn or a client; before that it is ``None``. + changes. See :meth:`nerve.db.sessions.SessionStore.session_cause`. """ - return self._session_causes.get(session_id) - - def _remember_session_cause(self, session: dict | None) -> None: - """Cache the stored cause of a session row that was just read.""" - if session and session.get("id"): - self._session_causes[session["id"]] = session.get("caused_by_actor_id") + return self.db.session_cause(session_id) # ------------------------------------------------------------------ # # Backend resolution (sticky per session) # @@ -406,7 +394,8 @@ def _build_tool_context(self, session_id: str) -> ToolContext: """Fresh per-session ToolContext for backend MCP servers. The agent's tool calls are the system actor's changes, made for the - session's stored cause. + session's stored cause. The value never changes, so the context + keeps the one read here (the client build has just read the row). """ return ToolContext( session_id=session_id, @@ -419,7 +408,7 @@ def _build_tool_context(self, session_id: str) -> ToolContext: engine=self, notification_service=self.notification_service, actor=self.db.system_actor, - cause=functools.partial(self.session_cause, session_id), + cause=self.session_cause(session_id), ) def _gateway_port(self) -> int | None: @@ -1122,7 +1111,6 @@ async def _get_or_create_client( # Session row first — backend resolution is sticky on it. session = await self.db.get_session(session_id) - self._remember_session_cause(session) backend = self._backend_for(session, source) requested_model = ( model or (session or {}).get("model") @@ -2649,7 +2637,6 @@ async def _run_inner( ) session = await self.db.get_session(session_id) - self._remember_session_cause(session) if not internal and session: current_title = session.get("title") diff --git a/nerve/agent/tools/registry.py b/nerve/agent/tools/registry.py index 06535e04d..96f3e276d 100644 --- a/nerve/agent/tools/registry.py +++ b/nerve/agent/tools/registry.py @@ -47,8 +47,8 @@ class ToolContext: principal on whose behalf the call happens: for an agent tool call, the session's stored cause (``sessions.caused_by_actor_id``); for a route, the person. It is a fixed value, or a callable that returns the cause when a - handler reads it (the engine reads its cache of the session's cause). Read - it through :attr:`cause_id` or :attr:`attribution`, never directly. + handler reads it. Read it through :attr:`cause_id` or :attr:`attribution`, + never directly. """ session_id: str diff --git a/nerve/db/base.py b/nerve/db/base.py index 39505dd4a..10c29f7e3 100644 --- a/nerve/db/base.py +++ b/nerve/db/base.py @@ -341,6 +341,10 @@ def __init__(self, db_path: Path, workspace: Path | None = None): # a caller or test can tune them before connect() (e.g. busy_timeout=0). self._pragmas: dict[str, object] = dict(_DEFAULT_PRAGMAS) self._system_actor: Actor | None = None + # Each session's stored cause (sessions.caused_by_actor_id). Loaded at + # connect and kept by create_session, get_session and delete_session; + # a session's cause never changes. Read through session_cause(). + self._session_causes: dict[str, str | None] = {} # Actor ID to the last display name upsert_external_actor() wrote. self._external_actor_names: dict[str, str | None] = {} # The state-file modes connect() found after its repair. The identity @@ -417,6 +421,7 @@ async def connect(self) -> None: ) await run_migrations(self._db) await self._cache_system_actor() + await self._cache_session_causes() # After migrations, so the table exists. A key that other users # could read is compromised. if exposed: @@ -425,6 +430,7 @@ async def connect(self) -> None: except BaseException: db, self._db = self._db, None self._system_actor = None + self._session_causes = {} try: await db.close() except Exception as e: # noqa: BLE001 — never mask the real failure @@ -515,6 +521,7 @@ async def close(self) -> None: await self._db.close() self._db = None self._system_actor = None + self._session_causes = {} self._external_actor_names.clear() @property diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index e63766214..b55a7297e 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -86,7 +86,32 @@ async def create_session( async def get_session(self, session_id: str) -> dict | None: async with self.db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) as cursor: row = await cursor.fetchone() - return dict(row) if row else None + if not row: + return None + session = dict(row) + # A row that another connection wrote after connect() is known from + # its first read on. + self._session_causes[session_id] = session.get("caused_by_actor_id") + return session + + async def _cache_session_causes(self) -> None: + """Load the stored cause of every session. Called by ``connect()``.""" + async with self.db.execute( + "SELECT id, caused_by_actor_id FROM sessions" + ) as cursor: + self._session_causes = {row[0]: row[1] async for row in cursor} + + def session_cause(self, session_id: str) -> str | None: + """The stored cause of a session: who all agent work in it runs for. + + Read from memory, without a query: the value is loaded for every + session at connect, and ``create_session`` and ``get_session`` keep it + for the rows they write and read. It never changes. ``None`` is a + stored NULL (no known cause) or a session that this connection does + not know: one that does not exist, or that another connection created + and this one has not read yet. + """ + return self._session_causes.get(session_id) async def list_sessions( self, limit: int = 50, include_archived: bool = False, @@ -265,6 +290,7 @@ async def delete_session(self, session_id: str) -> None: await self.db.execute("DELETE FROM channel_sessions WHERE session_id = ?", (session_id,)) await self.db.execute("DELETE FROM messages WHERE session_id = ?", (session_id,)) await self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + self._session_causes.pop(session_id, None) async def update_session_metadata(self, session_id: str, metadata: dict) -> None: """Update the metadata JSON for a session. diff --git a/tests/test_cause_attribution.py b/tests/test_cause_attribution.py index ef671e0f6..a16fe4d70 100644 --- a/tests/test_cause_attribution.py +++ b/tests/test_cause_attribution.py @@ -809,9 +809,10 @@ async def test_resume_after_restart_keeps_the_session_cause( session_id = await _new_session(client, install.headers("alice")) await _chat(client, install.headers("bob"), session_id, "long job") await install.db.update_session_fields(session_id, {"sdk_session_id": "native-1"}) - # A restart: the new process has read no session row yet. - install.engine._session_causes.clear() - assert install.engine.session_cause(session_id) is None + # A restart: a new connection knows the stored cause before any turn. + await install.db.close() + await install.db.connect() + assert install.engine.session_cause(session_id) == alice queue = tmp_path / "resume-after-restart" queue.write_text(f"{session_id}\n") @@ -825,6 +826,52 @@ async def test_resume_after_restart_keeps_the_session_cause( ] +@pytest.mark.asyncio +async def test_a_fresh_engine_knows_every_stored_session_cause(install): + """After a restart, before any turn, nothing reads unknown for a known cause.""" + alice = install.actor_id("alice") + await install.db.create_session( + "hers", actor=Actor(actor_id=alice, kind="human"), + ) + await install.db.create_session( + "nobodys", actor=install.db.system_actor, cause=None, + ) + await install.db.close() + await install.db.connect() + engine = AgentEngine(install.config, install.db) + + assert engine.session_cause("hers") == alice + assert engine._build_tool_context("hers").cause_id == alice + assert engine._build_tool_context("hers").attribution == Attribution( + install.system_actor_id, alice, + ) + # A stored NULL stays NULL. + assert engine.session_cause("nobodys") is None + assert engine._build_tool_context("nobodys").cause_id is None + + +@pytest.mark.asyncio +async def test_the_session_cause_is_known_from_creation_and_from_a_read(install): + alice, bob = install.actor_id("alice"), install.actor_id("bob") + engine = install.engine + await install.db.create_session("new", actor=Actor(actor_id=alice, kind="human")) + assert engine.session_cause("new") == alice + + # A row that another writer adds after connect is known from its first read. + await install.db.db.execute( + "INSERT INTO sessions (id, title, source, status, caused_by_actor_id," + " created_at, updated_at) VALUES ('elsewhere', 'x', 'web', 'idle', ?," + " '2026-01-01', '2026-01-01')", + (bob,), + ) + await install.db.db.commit() + await install.db.get_session("elsewhere") + assert engine.session_cause("elsewhere") == bob + + await install.db.delete_session("new") + assert engine.session_cause("new") is None + + @pytest.mark.asyncio async def test_run_later_and_fork_keep_who_wrote_each_message(install): alice = install.actor_id("alice") From d169f91e70a2ab3ebe00ac076e2f6ab145ac12ec Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:21:03 +0000 Subject: [PATCH 17/17] Describe causes without turns; remove dashes in session docs 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 --- nerve/agent/sessions.py | 6 +++--- nerve/identity.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index e720b4953..d192712b6 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -105,8 +105,8 @@ async def get_or_create( ) -> dict: """Get an existing session or create a new one. - ``actor`` and ``cause`` are only used when a row is actually created - — an existing session keeps the creator and the cause it was stamped + ``actor`` and ``cause`` are only used when a row is actually created. + An existing session keeps the creator and the cause it was stamped with, so re-resolving a session someone else started never re-attributes it. ``actor`` is required keyword-only, like the store method underneath it; ``cause`` defaults to the actor. @@ -653,7 +653,7 @@ async def add_message( """Add a message to a session's history. ``actor`` is the principal whose input this row records, or ``None`` - for assistant and tool output — see + for assistant and tool output. See :meth:`nerve.db.messages.MessageStore.add_message`. ``bump_updated_at=False`` records the message without moving the diff --git a/nerve/identity.py b/nerve/identity.py index abfeddcb2..828f8bf59 100644 --- a/nerve/identity.py +++ b/nerve/identity.py @@ -79,8 +79,8 @@ def is_human(self) -> bool: # An actor, or the ``actor_refs`` ID of one. A cause that comes from storage -# (the user row of an interrupted turn, a wakeup row) is an ID; a cause that -# comes from a request is the resolved :class:`Actor`. +# (a session's stored cause, for example) is an ID; a cause that comes from a +# request is the resolved :class:`Actor`. ActorOrId: TypeAlias = "Actor | str" @@ -113,7 +113,7 @@ def actor_id_of(ref: ActorOrId | None) -> str | None: def resolve_cause(actor: ActorOrId | None, cause: CauseArg = CAUSE_FROM_ACTOR) -> str | None: - """The cause actor ID for one write or one turn.""" + """The cause actor ID for one write, or for a session that is created.""" if isinstance(cause, CauseFromActor): return actor_id_of(actor) return actor_id_of(cause)