universelabSCI · CLI · BIO
ESEspañol

Canopy

Execution

Last updated

Commit rights

Most graphs want exactly one node to land work in git — a committer at the end of the chain — while every earlier node leaves its changes uncommitted so the reviewers downstream have a real diff to review. Asking for that in the prompt does not hold: three different models have committed anyway against an explicit, capitalised “you have no commit rights” rule, and each time the reviewers that followed were handed an empty diff and the quality gate quietly became a no-op.

So the engine enforces it. Mark the committer in its node config:

commit_rights: true

The engine records git rev-parse HEAD before and after every node it runs and compares the two. A node without commit_rights that moved HEAD is a deterministic fail, whatever the node itself reported — it routes through the fail edge like any other failure, and the reason (Node 'X': HEAD moved from a1b2c3 to d4e5f6 while it ran, plus the new commits in that range, capped at 5) lands in the run output, in canopy graph info, and in the next node’s {{previous_feedback}}. The engine cannot know who moved HEAD, so the message never claims the node committed: the commit may come from this node, another session, a hook, or a human — the listing shows each new commit as a1b2c3d subject line (2026-10-16) so you can tell at a glance when it belongs to something else entirely.

Three things are deliberate:

  • Enforcement is opt-in per graph. It activates only once some node in the graph declares commit_rights: true. A graph that designates nobody can’t be told apart from one written before this key existed, so enforcing there would fail the very node it relies on to land work. Existing graphs are unchanged until you name a committer.
  • Rights are explicit configuration, never inferred from a node’s name, kind, or prompt. Nodes without the key have no commit rights.
  • The engine never undoes the commit. It reports and routes; it will not reset, revert, or rewrite your history, because the unauthorized commit usually contains the correct work made by the wrong node, and an unattended daemon rewriting history is a far worse failure than the one it is fixing. Both hashes are in the output — undo it yourself if it doesn’t belong.

Ensemble members are checked as a group rather than individually: they run concurrently against one workdir, so a moved HEAD can’t be attributed to a single member, and the quorum fails as a whole. Non-git workdirs are unaffected, as is any node that edits files without committing — the normal case.

The same commit_rights: true node is also what populates {{spec_committed_head}}: whenever its own execution moves HEAD, that new HEAD is recorded against this spec run, overwritten each time it commits again (e.g. a review/retry cycle). A downstream check node uses it to verify this run’s own committer landed a commit, not merely that HEAD differs from wherever the spec started — see Template variables.

Editing the recommended check command text here does not retroactively touch any graph. A node’s command is whatever text was written into its config when the graph was authored (graph_node add, or copied from a node document) — the engine reads it fresh at execution time but never rewrites it. A spec already using the old {{spec_start_head}}-only comparison keeps using it until someone explicitly runs graph_node update on it; only graphs authored or edited after adopting {{spec_committed_head}} get the stronger check.

Self-report requirement

A harness can exit 0 having done nothing: every tool call refused, a quota exhausted, a provider outage — the process still ends cleanly and the graph engine sees a clean exit code. By default that is recorded as Pass if the process also produced output, exactly as it always has. Set require_report: true in an agent node’s config to close that gap for a node whose graph depends on being able to tell:

require_report: true

With the flag set, an agent run that exits 0 but never calls graph_complete_node itself is recorded as a deterministic fail (failure_kind: "no_report") and routes down the node’s fail edge. It is re-dispatched exactly once with an explicit reporting instruction (the # [REPORT REQUIRED] nudge) rather than retried on the infra-crash budget, since nothing actually crashed; a second unreported exit fails the node as usual. An explicit self-report always wins regardless of this flag, pass or fail; require_report only judges the case where none was ever made.

Whether or not the flag is set, every agent run that finishes without calling graph_complete_node carries "unreported": true in its output — this is unconditional, so a resilience node downstream can always tell “the harness ran and chose not to report” apart from “the harness never ran,” without needing require_report itself. Ensemble members are judged individually, exactly like a lone node.

An agent never retypes its run’s ids to file a verdict: every agent process starts with CANOPY_GRAPH_ID, CANOPY_NODE_ID and CANOPY_NODE_RUN_ID in its environment, and its prompt’s [REPORTING] section tells it to call graph_complete_node/graph_report_blocker without ids — the daemon resolves them from that identity. A supplied id with the right 8-character prefix but a mistyped tail is corrected to the environment’s run (the reply says Corrected ...); any other unknown id fails naming the run id the environment carries, and an explicit id naming a different, still-running run is honored as before.

Infra-retry budget

An agent attempt that never files a verdict (no graph_complete_node, no graph_report_blocker) is an infrastructure crash, retried with a doubling backoff. One exception: an attempt whose process exited 0 with real output is re-dispatched exactly once with a # [REPORT REQUIRED] instruction first — only if that retry also exits unreported does the node settle as failed. Three keys control the budget, with engine defaults of 2 retries / 60 s crash-shape window / 30 s backoff:

infra_retry_limit: 0
infra_crash_max_seconds: 60
infra_backoff_seconds: 30

infra_retry_limit: 0 means one attempt and no retry on the crash budget: a member that exits with the infra-crash signature falls through to the next cascade member immediately. (The one # [REPORT REQUIRED] re-dispatch above is outside this budget, so it still applies once.)

Precedence, most specific first: member → ensemble → node config → platform config → engine default. A member’s own value wins over the ensemble’s default; a plain node’s own config key wins over its platform; the platform value lives in config.toml’s [[clis]] entries (infra_retry_limit, infra_crash_max_seconds, infra_backoff_seconds) and is read fresh on every dispatch, so editing config.toml needs no daemon restart. More specific ensemble/member/node overrides win; absent everything, the engine default (2/60/30) applies.

A retry-skipped (infra_retry_limit: 0) crash is recorded with infra_retry_skipped: "limit 0" alongside infra_crash/infra_attempt, readable via graph node_run (which already surfaces the infra_attempt/infra_crash markers when present).

graph get reports the effective value and its source ("node"/"platform"/"default") per node under infra_config, and the ensemble’s own JSON carries the raw ensemble-level fields plus each member’s *_override fields so member vs ensemble vs platform can be told apart. graph audit lists every node/member whose effective budget differs from its platform’s own under infra_config_overrides.

Transient database-busy retries

Engine-internal writes on the run path (event recording, run bookkeeping, mutex leases) can hit transient SQLITE_BUSY/SQLITE_LOCKED when another connection holds a write lock; retried automatically — 3 attempts with short backoff over the 5 s busy timeout — instead of failing the graph.

If contention persists past the retry budget the node settles as normal fail with transient database busy during {operation} ... safe to retry the node, routed through its fail edge. Re-run via graph_control continue (mode: retry_current_node); only non-busy errors fail immediately.

Execution limit

A spec that fails with a genuine verdict in separate graph executions is counted per spec, across relaunches and daemon restarts. Once it reaches the limit (spec_attempt_limit, default 3) the graph pauses with a blocker instead of failing again. Infrastructure failures do not count.

To clear the limit, do one of these:

  • Reset the spec by id. graph_control reset with specs: [<spec id>] zeroes that spec’s execution count and leaves its worktree untouched. Then graph_control run or a directed graph_control continue (mode: run_from_node) relaunches it. A graph_control reset without specs keeps every spec’s count.
  • Give an explicit directive. graph_control run with start_node_id, graph_control continue with mode: run_from_node, or graph_control continue with mode: retry_current_node (on a failed graph, or on a pending spec with a recorded node) resets the targeted spec’s count before its run starts. The response says Execution limit reset for spec '<name>'.

Automatic re-runs (cron auto-continue, autorun) never reset the count.

Lifecycle

Create → run → (pause / continue) → complete. graph_control continue supports retry and skip strategies for stuck nodes, and graph_report_blocker escalates to a human when intervention is needed. Iteration limits prevent infinite retry graphs. graph_control reset returns a completed/failed graph to pending so graph_control run can restart it, and graph_control schedule_autorun sets a future time at which the graph auto-resumes (useful for quota-limited graphs that fail and need to wait before retrying). Call it again with at omitted to cancel a pending schedule.

A run remembers the workdir it was launched with — the workdir override when run was given one, else the graph’s stored workdir — and every continuation of that run (continue in any mode, scheduled resumes, quota auto-resume) re-dispatches in it, as do the run’s hooks ({{workdir}}, CANOPY_HOOK_WORKDIR). continue without workdir keeps it; an explicit workdir replaces it. A new run without an override uses the stored workdir again; graph get/list show the run’s as run_workdir when it differs.

If the daemon stops while a node is running — a graceful shutdown or a crash — the killed run is finalized as interrupted and routes no edge (in particular, never its fail edge). On the next start the graph is reconciled to Paused with the spec Interrupted, and graph_control continue with mode: retry_current_node re-runs the interrupted node itself with the same input it had (the same command for a check node, the same previous_feedback for an ensemble member) — not whichever node an edge would have led to.

When a node is terminated, canopy also ends the harness session that node started, for platforms that declare how to end a session (opencode does). Without that, a terminated opencode session could be resumed by the platform’s background service after a restart and edit the repo beside the next attempt. Every harness node runs with the node’s workdir as its working directory, so the session list the end step searches is the same one the node created. The outcome is recorded on the run’s output as session_end — "ended <id>", "not configured" (the platform declares no way to end a session), "not found" (no id on the run and no title match), or "failed: <reason>" — readable via graph node_run.

An agent node that has filed its verdict must not keep running. Once the verdict is recorded, its process gets a grace period to exit on its own (graphs.verdict_grace_seconds, default 15; 0 disables the shutdown). If it is still alive when the grace period ends, canopy terminates its process group and ends its harness session as described above. The next node, ensemble member or join starts only after that process is gone; in a parallel ensemble each member gets the same grace period and is ended without waiting for the others. Termination never changes the verdict: a pass stays a pass. The node’s output records ended_after_verdict — "exited" when the process left on its own, "terminated" when canopy ended it — readable via graph node_run.

A spec interrupted by a no-verdict ensemble block (see No-verdict blocks) is resumed the same way: graph_control continue with mode: retry_current_node re-enters the blocked ensemble with its current member list, without re-running nodes or specs that already passed, and graph_control continue with mode: skip_next_spec marks that blocked spec skipped and moves on to the next one. graph_control reset still restarts the blocked spec from its entry node.

When the work up to some node is already done and good — after a crash, a rewired ensemble, or a hand fix — a directed run starts a spec at a node you choose instead of at Start: graph_control continue with mode: run_from_node and optional spec_id (CLI: canopy graph continue --from <node> [--spec <spec>]), or graph_control run with start_node_id for the first spec the run executes. The spec reference accepts a full id, an unambiguous id prefix, or the spec’s code (e.g. FIX-7), resolved within the graph’s workdir; on a queue-driven graph it must name a member of the queue being run. --spec is optional: on a queue-driven graph it defaults to the graph’s current spec — an in-flight member (running or interrupted) first, else a failed one, else the next pending one — so a bare directed run restarts the failed spec rather than the next fresh one. It reuses the most recent recorded output of each node upstream of the start node for that spec; the start node and everything downstream of it always run again, and its own old output is never reused. If an upstream node’s latest run is failed, error or interrupted, the directed run is refused and that node is named: a failed prerequisite is never treated as passed. An upstream node that belongs to an ensemble is judged by the ensemble’s quorum result — its join node’s run for this spec — not by any member’s own latest run: a member that failed while the quorum passed is a satisfied prerequisite, while a failed quorum refuses the run and names the ensemble. A quorum only governs the pass it settled: a member that ran again after the last join (a crash between the two) has no verdict for that attempt yet, so the run is refused naming the ensemble and its missing verdict — re-run the ensemble first or start at an earlier node. The spec cursor is set to the start node, and the graph then runs with its normal edges, routers, ensembles and gates. On a queue-driven graph the chosen spec is what runs first: queue members before it are marked skipped, members after it run in their usual order, and any other member left running by a stale or mistaken state is reset to pending and left for a later run. On a graph running its own bound specs the same rule holds: bound specs before the chosen one are marked skipped — one that already completed or failed keeps its status, and one left running is named in the graph_failed event instead — the chosen spec runs first whatever its own status, and the bound specs after it run in their usual order. The per-node iteration counters restart at 1 (earlier attempts stay in the run history). A directed run accepts a pending, running, interrupted, failed or completed spec on a graph with no active run; a running graph refuses. An ensemble is targeted by its name; targeting the node the ensemble fans out from starts at that node itself, runs it, and fans out to the ensemble from there — the named node always runs first and is never reported as skipped. A join node or a single ensemble member is refused, naming the ensemble. On a graph that ran with an idea (a spec-less idea run), the directed run resumes that run: the graph remembers the idea it last ran with, {{spec_content}} renders the stored idea, and the failed run’s blank-name bookkeeping spec plus its node history (so {{previous_feedback}} still carries the failed run’s feedback) are kept rather than purged — no spec_id is needed. An idea run that predates the graph remembering its idea (older databases) resumes the same way: the idea is read from that bookkeeping spec’s text and remembered from then on. An idea graph that has never run has nothing to resume: graph_control continue with mode: run_from_node (and mode: retry_current_node) refuses it, naming graph_control run with an idea as the way to start one. If a reachable prompt or check references a node with no recorded output for the spec, the run is refused and the missing node is named.

Bound specs are read live. Every advance re-reads the graph’s bound specs and picks the first one still pending, instead of working through a list captured when the run started. A spec added with graph_edit attach_spec while the graph runs is therefore never left behind: one bound at a position after the current spec runs later, in position order, and one bound at an earlier position runs right after the current spec. graph_completed is emitted only when no bound spec is left runnable; if a run would settle with one still pending — or one left running or interrupted by a stale state — the graph fails instead, naming those spec ids in its graph_failed event, and on_completed hooks do not fire. graph_control continue then picks the pending spec up.

Deterministic commits

Canopy can build a deterministic commit message from a spec’s type and scope, and number specs automatically per type — a short code that serves as a quick reference and counts features and bugs.

  1. Number specs by change type. Give each spec a type (one of the Conventional Commits types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert). Canopy assigns code = <TYPE>-<n> with TYPE upper-cased (FEAT-12, FIX-7, DOCS-3) and n the next number for that type among the specs tagged with the same workdir (counter persisted per workdir and type; never reused, even after a spec is deleted). The code is immutable and never set by callers. Setting type later on a legacy spec through spec_edit update assigns its code at that moment; changing the type afterwards does not renumber it.

  2. Conventional titles. When spec_edit create/graph_edit attach_spec receive a name of the form <type>(<scope>)?!?: <subject> and no explicit type, canopy parses it and stores the subject as the name, with type and scope extracted. An explicit type wins over the parsed one.

  3. Commit message. Every spec exposes CANOPY_SPEC_COMMIT_MESSAGE to check nodes and {{spec_commit_message}} to agent prompts. The message is <type>(<scope>): <name> (or <type>: <name> without scope). A deterministic commit node uses $CANOPY_SPEC_COMMIT_MESSAGE as the commit message and never guesses.

  4. Opt-in enforcement. A graph created or updated with require_spec_type: true (default false) refuses a spec without a type in graph_edit attach_spec, and graph_control run refuses a queue holding one, naming the spec. queue add_spec refuses an untyped spec while any graph has the setting on, because queues are not tied to a single graph. Legacy specs keep working everywhere else.

Recipe: deterministic commit node

Add a check node with commit_rights: true and use the prebuilt message:

{
  "name": "commit",
  "kind": "check",
  "command": "git add -A && git commit -m \"$CANOPY_SPEC_COMMIT_MESSAGE\"",
  "success_condition": "exit_code_0",
  "commit_rights": true
}

When the spec has type: fix and scope: tui, and its name is "open F1 from inside a session", the node commits with message fix(tui): open F1 from inside a session.

Spec notes

Every spec carries one working-memory note per owner: a short document a node writes while it works, and that every later node, model and iteration of the same spec receives in its prompt. Notes are working memory for the next agent, not a log — the history of changes lives in the event log’s note rows above.

Ownership. The owner of a note is the node that wrote it — except when the writer is an ensemble member, in which case the owner is the ensemble, so every member shares the one note and a replacing member continues where the cut one left off. Each spec has at most one note per owner, plus one operator note written by people and orchestrators without a run. A node can only write its own note: spec_edit note_write takes no owner argument.

Writing and reading. An agent writes with spec_edit note_write (run_id, body) — the engine resolves the owner from the active run and refuses a run_id that is no longer running. The body is at most 2 500 characters; longer writes are refused naming the limit. spec_edit note_write with (spec_id, body) and no run_id writes the operator note. Read with spec notes (spec_id) (newest first), or canopy spec notes <spec-id>.

Prompt injection. Every agent-node prompt gets a [SPEC NOTES] section before [REPORTING] with a 5 000-character budget: the caller’s own note first, then the Designer’s note (the owner of the graph’s first agent node or ensemble after Start), then the rest newest-first. A note is included whole or as a stub naming its owner, size and time — never truncated — so the same notes always give the same section bytes.

Lifetime. Notes survive bounces, resumes, graph_control continue and node retries of the same spec. graph_control reset keeps them unless called with clear_notes: true. Deleting the spec deletes its notes.

Check nodes. Before a check node’s command runs, the engine writes the spec’s notes to a temp file — one ## <owner_name> section per note — and exports its path as CANOPY_SPEC_NOTES_FILE. A presence check like grep -q '^## Designer' "$CANOPY_SPEC_NOTES_FILE" needs no canopy binary inside a check command.