Repository navigation
Record who starts, kills and decides workflow runs and review loops - #519
Open
alex-clickhouse wants to merge 12 commits into
Open
alex-clickhouse wants to merge 12 commits into
alex-clickhouse wants to merge 12 commits into
Conversation
This was referenced Oct 17, 2026
alex-clickhouse
added this pull request to stack #521
October 17, 2026 07:20
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
force-pushed
the
alex/attr-05-workflows
branch
from
October 17, 2026 11:43
a16a465 to
cc76018
Compare
alex-clickhouse
marked this pull request as ready for review
October 17, 2026 12:06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Nerve records 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:
actor_refson bothworkflow_runsandreview_loops:created_by_actor_id,caused_by_actor_id,killed_by_actor_idandkilled_caused_by_actor_id;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:
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_bykeeps 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
POST /api/review-loops/{id}/decision:remind_4hwith 400; the reminder exists only on the card;okfalse, instead of 409, when the decision was applied but its resumed leg did not start.DispatchResult.after_snoozecallback.Testing
Stack
main)🤖 Generated with Claude Code