Skip to content

Record who starts, kills and decides workflow runs and review loops - #519

Open
alex-clickhouse wants to merge 12 commits into
alex/attr-04-notifications-plansfrom
alex/attr-05-workflows
Open

alex-clickhouse wants to merge 12 commits into
alex/attr-04-notifications-plansfrom
alex/attr-05-workflows

Conversation

@alex-clickhouse

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

Copy link
Copy Markdown
Collaborator

What

Nerve records who starts, kills and decides workflow runs and review loops. The session of a run, and of each loop leg, is created for the person who started the work, so its model requests name that person.

How

  • Migration v056:

    • adds four nullable foreign keys to actor_refs on both workflow_runs and review_loops: created_by_actor_id, caused_by_actor_id, killed_by_actor_id and killed_caused_by_actor_id;
    • adds the append-only table review_loop_decisions, with one row for each decision on a parked loop.

    Old rows stay empty. The attribution column registry lists the new columns.

  • These actions record the person:

    • starting or killing a run or a loop through the REST API;
    • creating a loop with its session;
    • deciding on a parked loop through REST or its card in the web UI;
    • a Stop from the web UI on a run's session.

    The workflow and review-loop tools record the system actor for the cause of the session that called them. A cron job's run is the system actor's own work.

  • A run's session is created with the run's cause, so everything the agent does in it, including its model requests in external mode, names that cause. Each loop leg is a run that Nerve creates for the loop's cause.

  • A decision and its record are one transaction. A card reminder is recorded after the card is snoozed, and as not applied when the card was no longer pending.

  • GET /api/review-loops/{id} returns the decisions. The workflow runs page shows "created by Alice" (or "Nerve for Alice") and who killed a run.

  • workflow_runs.created_by keeps its format, because the sidebar reads it to nest a run under the session that started it.

Why

Workflow runs and review loops are the most expensive work an agent does. Recording the person, and creating the legs' sessions for that person, lets the model usage of this work be attributed to the person who caused it.

Approximation

A run that the agent starts in a shared session is attributed to the person who started that session.

Behaviour changes

  • The session of a run that a person started is created with that person as its cause.
  • POST /api/review-loops/{id}/decision:
    • refuses remind_4h with 400; the reminder exists only on the card;
    • returns 200 with ok false, instead of 409, when the decision was applied but its resumed leg did not start.
  • A decision whose record cannot be written fails and changes nothing.
  • Approval dispatchers can return the new optional DispatchResult.after_snooze callback.

Testing

  • New tests for every path, including the decision and snooze race, and an external-mode test of the principal on leg turns. Every commit passes its tests.
  • Full suite on this branch: 5907 passed, 39 skipped. Web: build ok, 737 tests passed.

Stack

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

🤖 Generated with Claude Code

alex-clickhouse and others added 12 commits October 17, 2026 10:22
Workflow runs and review loops recorded only an origin text, and every
leg turn ran as the system actor for itself. The model requests of a
run therefore never named the person who started it.

Migration v056 adds nullable actor_refs columns, appended to the
registry: created_by_actor_id, caused_by_actor_id, killed_by_actor_id
and killed_caused_by_actor_id on workflow_runs and on review_loops.
A decision on a parked loop leaves no record other than the changed
loop row, so v056 also adds the append-only table
review_loop_decisions (decision, applied, message, decided_by origin,
actor_id, caused_by_actor_id). Old rows stay NULL.

- REST create and kill of a run, REST kill and decision of a loop, and
  a loop attached at session creation record the person.
- The workflow and review-loop tools record the system actor and the
  cause of the turn that calls them.
- A run's turn runs as the system actor with the run's cause. Loop
  legs are runs created by the system actor for the loop's cause, so
  every leg turn runs for the person who started the loop. A NULL
  cause stays NULL. A scheduled cron run is system for system.
- A loop kill records the same kill on its current leg; a leg killed
  through its run kills the loop with the leg's kill attribution.
- Approval-card decisions record the answering person from the
  dispatcher's actor argument (NULL for a channel answer). Cards that
  a decision closes record the person, and the WS
  notification_answered event carries actor_id; cards that a new park
  supersedes record the system actor.
- GET /api/review-loops/{id} returns the loop's decisions.

workflow_runs.created_by keeps its format: the sidebar reads it as the
pointer to the session that the run started from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workflow runs page showed only the origin text ("api",
"session:<id>"). It now names the person through ActorAttribution:
"created 5m ago by Alice" on the run row, "Nerve for Alice" for a run
that the agent started in Alice's turn, and the creator and the killer
in the run detail. The origin text stays as the tooltip, and runs from
before attribution keep showing it.

The client types carry the new run and loop columns and the decision
history that GET /api/review-loops/{id} returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/accounts.md lists the new columns and the work rows: a person who
starts, kills or decides records themselves, the tools record the
system actor for the turn's cause, and the turns of a run and of each
loop leg run for the cause of the run or the loop. The legs left the
"system for system" row.

docs/workflow-runs.md gets an Attribution section (REST, tools, cron,
loop legs, kills, the CLI's own token, created_by as the session
pointer). docs/review-loops.md describes the decision history and the
loop attribution. docs/api.md adds the run fields and the kill and
create notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decision history wrote its row after the loop change and only
logged a failed write, so an applied decision could lose its record.
Its "applied" flag also followed the resume result: a budget grant
that was committed but whose leg did not start was recorded as not
applied, and a reminder was recorded as applied on any loop, also
through REST, where nothing snoozes a card.

- ReviewLoopStore.apply_review_loop_decision does the loop change as a
  CAS on the parked status and inserts the decision row in the same
  transaction. A reminder changes no loop column; its row is inserted
  only while the loop is parked. A failed write rolls the change back
  and is raised.
- handle_decision uses it for accept, abandon, iterate, budget and
  adopt-and-continue (adopted criteria are now written with the
  decision) and for a reminder. "applied" is true once the change
  commits, also when the resumed leg does not start. A decision on a
  loop that is not parked is recorded with applied false; a failure to
  record it is raised too. The result carries "applied".
- POST /api/review-loops/{id}/decision refuses remind_4h with 400 (only
  the card can be snoozed) and gives 409 only for a decision that was
  not applied.
- Docstrings without em dashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/review-loops.md: a decision that changes the loop is written with
its record in one transaction; "applied" means the change committed,
also when the resumed leg does not start; a reminder is a card answer
only, and the REST route refuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Remind me in 4h" answer was recorded as an applied decision in the
dispatcher, before the notification service wrote the snooze. When the
expiry sweep took the claimed card during the dispatch, the snooze
found no pending row, but the decision history still said applied.

- DispatchResult gets after_snooze: an async callable that the service
  awaits with the result of its snooze write. The service also logs a
  snooze that found no pending row.
- The review-loop dispatcher handles reminders itself (_card_reminder):
  it checks that the loop is parked and returns a callback that records
  the reminder as applied when the card was snoozed, and as not applied,
  with the reason, when it was not. A reminder on a loop that is not
  parked is recorded as not applied at once and asks for no snooze.
- handle_decision refuses remind_4h (not recorded): a reminder is an
  answer on the decision card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/review-loops/{id}/decision returns "applied" next to "ok",
but the client typed the response as ok and message only. The new
ReviewLoopDecisionResult type has all three; a client test checks the
request and the returned flags, and the type check fails without the
field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/review-loops.md: a reminder is recorded after the snooze write,
as not applied when the card was no longer pending.
docs/architecture.md: the after_snooze callback of an approval
dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stop of the leg session (the Stop button, WS stop) killed the run
with no kill actor, because the engine stop listener received only the
session ID. engine.stop_session already carries the stop's
attribution; the listener now receives it, and the run records it as
the kill. A channel /stop names no person. Nerve's own stop after a
kill or budget stop does not win the CAS, so it does not overwrite.

Tests: the created event of a leg session (the system actor for the
run's cause, and no cause for a run without one); a stop of a run's
session by Bob; and, with T2's recording CLI in external mode, the
inference principal of leg sessions: Carol for a REST run and for a
leg of her loop, the system actor for scheduled work, none for a run
without a cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/workflow-runs.md: a stop of the run's session is a kill by the
person who asked for it; the session's created event names the run's
cause. docs/api.md: when the kill pair is null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the session-cause model a session has one cause, stored when it
is created, and all agent work in it uses that cause. A run's leg
session is already created with the run's cause (and a loop leg with
the loop's cause), so the per-turn parts of this story go:

- The leg turn no longer passes cause= to engine.run(); the session
  that the run creates carries it. A run without a cause still gets a
  session without one.
- Tool-started runs and loops take the calling session's cause through
  ctx.attribution, as before; docstrings, comments, the v056 docstring
  and the docs say "the session's cause" instead of "the turn's cause".
- Tests use session terms (message writers, the session's stored
  cause, the cause that tool calls see) and no longer assert a cause on
  the turn or on the client fetch. The external-mode recording-CLI test
  covers the header.
- The workflow stop call-site tests seed a run started by a person: the
  run's session is created with that cause, and Nerve's own stop after
  a kill or a budget stop records the system actor for that person, so
  a hard-coded system or NULL cause fails.
- docs/workflow-runs.md also names the cause of Nerve's own stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The approval claim now stores who answered. When the expiry sweep takes
a claimed review-loop card before the reminder's snooze, the reminder
is recorded as not applied and the expired card still names the person
who answered. The race test asserts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 17, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant