Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ These columns store actor IDs:
| `review_loop_decisions.actor_id`, `review_loop_decisions.caused_by_actor_id` | Actor and cause of one decision on a parked review loop |
| `session_wakeups.created_by_actor_id`, `session_wakeups.caused_by_actor_id` | Who scheduled a wakeup or a deferred message, and on whose behalf. The turn that it fires runs in its session, for the session's cause |
| `cron_logs.actor_id`, `cron_logs.caused_by_actor_id` | Who started a cron or source run, and on whose behalf |
| `task_comments.actor_id`, `task_comments.caused_by_actor_id` | Who wrote a task comment, and on whose behalf |

`nerve/db/actor_columns.py` lists every attribution column. Clients resolve
names through `GET /api/actors`, which returns every actor that these columns
Expand Down
29 changes: 29 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,35 @@ 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).

#### `GET /api/tasks/{id}/comments`
A task's comments, oldest first. `404` when the task does not exist.

```json
Response: { "comments": [
{ "id": 1, "task_id": "...", "body": "Can you check Safari?", "actor_id": "…", "caused_by_actor_id": "…", "created_at": "..." }
] }
```

#### `POST /api/tasks/{id}/comments`
Add a comment as the person who sends the request. The body is markdown,
at most 10,000 characters, and leading and trailing whitespace is removed.
`404` when the task does not exist, `422` for an empty or too long body.

```json
Request: { "body": "Can you check Safari?" }
Response: { "comment": { "id": 1, "task_id": "...", "body": "Can you check Safari?", "actor_id": "…", "caused_by_actor_id": "…", "created_at": "..." } }
```

Status `201`. See [Comments](tasks.md#comments).

#### `DELETE /api/tasks/{id}/comments/{comment_id}`
Delete a comment. Only its author can: `403` for anybody else, `404` when the
task has no such comment.

```json
Response: { "id": 1, "deleted": true }
```

#### `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
Expand Down
40 changes: 39 additions & 1 deletion docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ An install that existed before keeps the `task-planner` prompt in its `config/cr
| `task_update` | Update status/deadline/notes (updates both) |
| `task_read` | Read full task content |
| `task_done` | Mark complete, move to `done/` |
| `task_comment` | Add a comment to a task, signed as the agent (see [Comments](#comments)) |

### Duplicate Detection

Expand Down Expand Up @@ -197,11 +198,47 @@ The fence protects the row. Two writers that both pass it can still lose one
another's edits to the markdown body, since each builds the new body from its
own read of the file.

### Comments

People and the agent can add comments to a task. A comment is markdown text
of at most 10,000 characters. Comments are rows in `task_comments` (`id`,
`task_id`, `body`, `actor_id`, `caused_by_actor_id`, `created_at`), added in
migration v061. They are not in the task's markdown file: a file cannot hold a
trustworthy author, and an agent that rewrites the file would rewrite the
comments too.

`actor_id` is who wrote the comment and `caused_by_actor_id` on whose behalf
(see [Attribution](accounts.md#attribution)). A comment through the HTTP API
records the caller as both: the person, or the system actor for a system or
MCP token. A `task_comment` tool call records the system
actor and the cause of its session, so the web UI shows "Nerve for <person>"
when the agent wrote it in a session that the person started.

Adding or deleting a comment also updates `tasks.comment_count`,
`updated_at` and `revision` in the same transaction. Thus the count is on
every task row, the `task_updated` broadcast (`event` `commented` or
`comment_deleted`) refreshes open task views, and a writer that read the task
before the comment gets a revision conflict on a guarded write.

`task_read` shows the newest 50 comments between the file content and the
revision line, inside a `<!-- nerve: comments ... -->` block, with the time,
the author and the text of each. `task_write` strips the block, so a
read-edit-write round trip does not copy comments into the file. In the
`task_read` output, a file line that starts with `<!-- nerve:` is indented by
two spaces, so only Nerve's own block and revision line start with a marker:
file content cannot pose as comments, and the strip cannot remove file
content. Such a line stays indented after a `task_write` round trip; markdown
still reads it as an HTML comment.

Only the author of a comment can delete it, through
`DELETE /api/tasks/{id}/comments/{comment_id}`. Accounts have no roles, so
no person can delete the words of another. Comments cannot be edited.

### Live Updates

Every task mutation broadcasts a `task_updated` WebSocket event on the
`__global__` channel carrying the whole row (`event` is one of `created`,
`updated`, `moved`, `done`). It fires from both the HTTP routes and the tool
`updated`, `moved`, `done`, `commented`, `comment_deleted`). It fires from both the HTTP routes and the tool
handlers, so a card moves on any open board whether the change came from the
web UI, another tab, or the agent working in an unrelated session. Broadcast
failures are logged and swallowed — a stale card is never worth failing a
Expand Down Expand Up @@ -242,6 +279,7 @@ Full-page markdown editor for task content:
- **Status dropdown** — change status inline in the header
- **Metadata** — deadline, source, external link displayed in header
- **Plan**: the latest plan of the task (`GET /api/tasks/{id}/plans`), with its version, its status and **Open plan**. A pending plan with no revision request shows "A plan waits for your review". The section reads the plans again when the task changes, for example when `plan_propose` writes its note
- **Comments**: below the content in preview mode. Each comment shows its author ("Nerve for <person>" for an agent comment), its age and its markdown text. **Comment** (or Ctrl+Enter / Cmd+Enter) adds one; the delete button shows on your own comments only. The list reads the comments again when the task changes, so a comment by another person or by the agent shows without a reload. Board cards show the comment count
- **Back button** — returns to the task list

Saving writes the full markdown file to disk and re-syncs the title and deadline to SQLite.
3 changes: 2 additions & 1 deletion nerve/agent/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ async def broadcast_workflow_progress(
})

async def broadcast_task_event(self, task: dict[str, Any], event: str) -> None:
"""A task row changed — ``created``/``updated``/``moved``/``done``.
"""A task row changed: ``created``/``updated``/``moved``/``done``,
or ``commented``/``comment_deleted``.

Fanned out on ``__global__`` rather than a session channel because
the task board is a global view: a card has to move whether the
Expand Down
123 changes: 118 additions & 5 deletions nerve/agent/tools/handlers/tasks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Task tool handlers — task_search, task_create, task_list, task_update,
task_read, task_write, task_done.
task_read, task_write, task_done, task_comment.

Handlers are pure functions of ``(ToolContext, args)`` and return
:class:`ToolResult`. They read only from ``ctx`` for their collaborator
Expand All @@ -21,6 +21,7 @@

from nerve.agent.tools.registry import ToolContext, ToolResult, ToolSpec
from nerve.agent.tools.schemas import (
TASK_COMMENT_SCHEMA,
TASK_CREATE_SCHEMA,
TASK_DONE_SCHEMA,
TASK_LIST_SCHEMA,
Expand Down Expand Up @@ -125,6 +126,25 @@ def _revision_conflict(task_id: str, expected: int, *, reopened: bool = False) -
# strips it so a read-edit-write round trip cannot persist it.
_REVISION_TRAILER_RE = re.compile(r"^<!-- nerve: revision=\d+[^\n]*-->[ \t]*(?:\n|\Z)", re.MULTILINE)

# task_read shows the task's comments between these two lines, and task_write
# strips the block for the same reason as the revision trailer. Each comment
# line is indented, and so is each file line that starts with ``<!-- nerve:``
# (_FILE_MARKER_LINE_RE), so in task_read's output only Nerve's own lines
# start with a marker. A file cannot fake comments or lose content to the strip.
_FILE_MARKER_LINE_RE = re.compile(r"^(?=<!-- nerve:)", re.MULTILINE)
_COMMENTS_START = "<!-- nerve: comments (index metadata, not file content) -->"
_COMMENTS_END = "<!-- nerve: end of comments -->"
_COMMENTS_BLOCK_RE = re.compile(
rf"^{re.escape(_COMMENTS_START)}\n.*?^{re.escape(_COMMENTS_END)}[ \t]*(?:\n|\Z)",
re.MULTILINE | re.DOTALL,
)

# How many of the newest comments task_read shows.
_READ_COMMENT_LIMIT = 50

# The longest comment body, in characters.
MAX_COMMENT_LENGTH = 10_000


# Process-wide read-before-write guard. Tracks task IDs that have been
# read (via task_read) or created (via task_create) in this process
Expand Down Expand Up @@ -628,25 +648,105 @@ async def task_read_handler(ctx: ToolContext, args: dict) -> ToolResult:
# Guarding here would refuse a read with a "Cannot write" message
# the caller has no way to act on.
if file_path.exists():
content = await asyncio.to_thread(
content = _FILE_MARKER_LINE_RE.sub(" ", await asyncio.to_thread(
file_path.read_text, encoding="utf-8",
)
))
_tasks_read.add(task_id)
# Read after the row: a comment added in between shows here
# with the older revision, so a guarded write is refused and
# the agent reads again. The other order could hide it.
comments = (
await _format_comments(ctx, task)
if task.get("comment_count") else ""
)
# The adapters drop ``structured``, so the text carries it too.
sep = "" if content.endswith("\n") else "\n"
return ToolResult.text(
f"{content}{sep}<!-- nerve: revision={task['revision']} "
f"{content}{sep}{comments}<!-- nerve: revision={task['revision']} "
"(index metadata, not file content) -->",
structured={"revision": task["revision"]},
)

return ToolResult.text(f"Task file not found for: {task_id}")


async def _format_comments(ctx: ToolContext, task: dict) -> str:
"""The comments block that task_read adds after the file content."""
comments = await ctx.db.list_task_comments(task["id"], limit=_READ_COMMENT_LIMIT)
if not comments:
return ""
ids = {c[key] for c in comments for key in ("actor_id", "caused_by_actor_id")} - {None}
refs = {actor_id: await ctx.db.get_actor_ref(actor_id) for actor_id in ids}

lines = [_COMMENTS_START, f"## Comments ({task['comment_count']})"]
older = task["comment_count"] - len(comments)
if older > 0:
lines.append(f"({older} older comments are not shown.)")
for comment in comments:
# "Alice", or "Nerve (agent) for Alice" when the agent wrote it in a
# session that Alice started. The web UI uses the same rule.
author = _actor_name(refs, comment["actor_id"])
cause = refs.get(comment["caused_by_actor_id"])
if cause and cause["id"] != comment["actor_id"] and cause["kind"] != "system":
author += f" for {_actor_name(refs, cause['id'])}"
when = datetime.fromisoformat(comment["created_at"]).strftime("%Y-%m-%d %H:%M UTC")
lines.append(f"- {when}, {author}:")
lines.extend(f" {line}" for line in comment["body"].splitlines())
lines.append(_COMMENTS_END)
return "\n".join(lines) + "\n"


def _actor_name(refs: dict[str, dict | None], actor_id: str | None) -> str:
"""A display name for an ``actor_refs`` ID, with the web UI's fallbacks."""
if actor_id is None:
return "unknown"
ref = refs.get(actor_id)
if ref is None:
return actor_id[:8]
name = (ref["display_name"] or "").strip() or (ref.get("username") or "").strip()
if ref["kind"] == "system":
return f"{name or 'Nerve'} (agent)"
return name or actor_id[:8]


async def task_comment_handler(ctx: ToolContext, args: dict) -> ToolResult:
"""Add a comment to a task as the caller (``ctx.attribution``).

``structured["reason"]`` names a refusal, so the REST route can choose a
status code without parsing the message.
"""
task_id = args["task_id"]
body = (args.get("body") or "").strip()
if not body:
return ToolResult.text(
"Cannot add an empty comment.", is_error=True, structured={"reason": "empty"},
)
if len(body) > MAX_COMMENT_LENGTH:
return ToolResult.text(
f"A comment can have at most {MAX_COMMENT_LENGTH} characters; "
f"this one has {len(body)}.",
is_error=True, structured={"reason": "too_long"},
)
if not ctx.db:
return ToolResult.text("Database not available.", is_error=True)
comment = await ctx.db.add_task_comment(task_id, body, attribution=ctx.attribution)
if comment is None:
return ToolResult.text(
f"Task not found: {task_id}", is_error=True, structured={"reason": "not_found"},
)
await _emit_task_event(ctx, task_id, "commented")
return ToolResult.text(
f"Comment {comment['id']} added to task {task_id}.",
structured={"comment": comment},
)


@_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", ""))
new_content = _REVISION_TRAILER_RE.sub(
"", _COMMENTS_BLOCK_RE.sub("", args.get("content", "")),
)

if task_id not in _tasks_read:
return ToolResult.text(
Expand Down Expand Up @@ -969,6 +1069,18 @@ async def task_status_create_handler(ctx: ToolContext, args: dict) -> ToolResult
handler=task_done_handler,
)

TASK_COMMENT_SPEC = ToolSpec(
name="task_comment",
description=(
"Add a comment to a task. The comment shows in the task's comment list "
"in the web UI, signed as you, and task_read shows it to agents. Use it "
"for discussion with people about the task; use task_update's note for "
"a progress line in the task file."
),
input_schema=TASK_COMMENT_SCHEMA,
handler=task_comment_handler,
)

TASK_STATUS_LIST_SPEC = ToolSpec(
name="task_status_list",
description="List the configured task statuses with their colors and descriptions. Use this to discover valid status values for task_create/task_update.",
Expand Down Expand Up @@ -999,6 +1111,7 @@ async def task_status_create_handler(ctx: ToolContext, args: dict) -> ToolResult
TASK_READ_SPEC,
TASK_WRITE_SPEC,
TASK_DONE_SPEC,
TASK_COMMENT_SPEC,
TASK_STATUS_LIST_SPEC,
TASK_STATUS_CREATE_SPEC,
]
12 changes: 12 additions & 0 deletions nerve/agent/tools/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@
"required": ["task_id"],
}

TASK_COMMENT_SCHEMA = {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Task ID"},
"body": {
"type": "string",
"description": "Comment text, in markdown. At most 10000 characters.",
},
},
"required": ["task_id", "body"],
}

TASK_STATUS_LIST_SCHEMA = {
"type": "object",
"properties": {},
Expand Down
2 changes: 2 additions & 0 deletions nerve/db/actor_columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@
("session_agents", "stopped_by_actor_id"),
("session_agents", "stopped_caused_by_actor_id"),
("onboarding", "actor_id"),
("task_comments", "actor_id"),
("task_comments", "caused_by_actor_id"),
)
63 changes: 63 additions & 0 deletions nerve/db/migrations/v061_task_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""V61: comments on tasks, with the person or agent that wrote each one.

Adds the table ``task_comments``. One row is one comment: a markdown
``body``, the task it belongs to, and two nullable ``actor_refs`` IDs:

- ``actor_id``: who wrote the comment. A person through the UI or REST, or
the system actor for an agent tool call.
- ``caused_by_actor_id``: on whose behalf it was written. For a person, the
person. For the agent, the cause of its session.

Comments live here and not in the task's markdown file. A file can be edited
by anyone with access to the workspace, so it cannot hold a trustworthy
author, and an agent that rewrites the file would rewrite the comments too.

Adds ``tasks.comment_count``. The comment store keeps it equal to the number
of rows for the task, in the same transaction as each insert and delete, so
every reader of a task row (the board, the list, the task broadcast) has the
count with no extra query. ``NOT NULL DEFAULT 0`` is correct for every
existing row, because no task has comments yet.

The foreign keys follow v048: an ID that resolves to no actor, or to no
task, can never be stored. NULL is exempt from the check. ``CREATE ... IF NOT
EXISTS`` and the column check make a second run do nothing.
"""

from __future__ import annotations

import logging

import aiosqlite

logger = logging.getLogger(__name__)

SQL = """
CREATE TABLE IF NOT EXISTS task_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id),
body TEXT NOT NULL,
actor_id TEXT REFERENCES actor_refs(id),
caused_by_actor_id TEXT REFERENCES actor_refs(id),
created_at TEXT NOT NULL
);

-- The only read pattern: one task's comments in ``id`` order. An index on
-- ``task_id`` holds the rowid too, so it serves that order.
CREATE INDEX IF NOT EXISTS idx_task_comments_task
ON task_comments(task_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:
await db.executescript(SQL)
if not await _has_column(db, "tasks", "comment_count"):
await db.execute(
"ALTER TABLE tasks ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0"
)
logger.info("v061: task_comments ready, tasks.comment_count present")
Loading
Loading