Skip to content

Control IR

Control IR is the list of side-effect operations the LLM may emit alongside its artifact. The OS dispatches each op and returns the result for the LLM (or the next phase) to consume.

Op kinds

Kind Purpose Permission required
read_file Read a file (optionally a line range) file.read
write_file Write (create / overwrite) a file file.write
edit_file Replace a string in a file file.write
delete_file Delete a file file.write
glob_files List files matching a glob pattern file.read
grep_files Search file contents by regex file.read
ask_user Pause the phase and ask the user a question none (always allowed)
sandboxed_exec Run argv under a SandboxPolicy via a SandboxBackend (replaces the removed shell op) enforced by backend (SandboxPolicy)
web_search Search the public web via DuckDuckGo Tier 1 — default allow; web.search: deny in reyn.yaml blocks
web_fetch Fetch a single URL and return extracted text Tier 1 — default allow; web.fetch: deny in reyn.yaml blocks
mcp Call a tool on a configured MCP server permissions.mcp: [server_name] in skill frontmatter
mcp_install Install an MCP server from the registry into the project config permissions.mcp_install: true in skill frontmatter
mcp_drop_server Remove an MCP server from project/local/user config (inverse of mcp_install) permissions.mcp_drop_server: true in skill frontmatter
skill_install Register a skill (local dir or git/URL source) into the project skills config file.write: [.reyn/config/skills.yaml] in skill frontmatter; http.get: [{host: <source_host>}] when source is set
index_query Semantic vector search over one indexed source none
recall Macro: embed query (provider-direct) → index_query per source → merge top-K none (embedding API cost)
index_drop Remove an indexed source entirely (destructive) permissions.index_drop: ask in skill frontmatter
judge_output LLM scorer: rubric + threshold + on_fail policy none (LLM cost)
compact Voluntarily compact the conversation/phase history (advisory) none (LLM cost; the mandatory retry_loop backstop is independent)
task.create Create a Task (deps for ordering; link_type awaited/background sets whether a sub-task gates the parent's completion — §2187; sub-task ownership is OS-derived from execution context — §16) requester-gated (caller becomes requester)
task.update_status Declare a status transition assignee-gated (single-writer CAS on assignee == caller session_id)
task.get Read one Task record requester-gated
task.list List Tasks (filter by assignee / requester / status); requester=<task-id> lists sub-tasks owned by that task none (filtered read)
task.add_dependency Add a depends-on edge (dependency DAG) requester-gated
task.remove_dependency Drop a depends-on edge (idempotent); may promote a now-satisfied dependent requester-gated
task.repoint_dependency Atomically repoint an edge from_depends_onto_depends_on (cycle-checked first); re-evals readiness requester-gated
task.abort Remove-op (= delete): archive the task + sub-tree (cooperative-terminal) requester-gated
task.heartbeat Liveness / unblock-predicate trigger for a blocked Task assignee-gated
task.register_unblock_predicate Register a deterministic unblock predicate assignee-gated
task.comment Append a comment to a Task's thread none
task.assign Assign a session to a Task (§27-31 pending-assignment queue): claim an UNASSIGNED task or reassign an assigned one; rebinds the WAL subscription + OS-derives the now-startable status + wakes the new assignee UNASSIGNED → any session may claim; assigned → current-assignee-gated (owner hand-off)

Common envelope

Every op is a JSON object with a kind discriminator:

{
  "kind": "read_file",
  "path": "src/foo.py"
}

The OS validates the op against its kind's schema, executes it, and returns a result to the calling phase.

File ops (fine-grained)

The LLM-emittable file operations are six fine-grained kinds — the same subset the chat router exposes as tools (see concepts/architecture/llm-invocation-surfaces.md). Each is a distinct op kind with its own schema; there is no op sub-field.

{"kind": "read_file", "path": "src/foo.py"}
{"kind": "read_file", "path": "src/foo.py", "offset": 100, "limit": 40}

{"kind": "write_file", "path": "out.txt", "content": "..."}

{"kind": "edit_file", "path": "src/foo.py",
 "old_string": "...", "new_string": "...", "replace_all": false}

{"kind": "delete_file", "path": "tmp.txt"}

{"kind": "glob_files", "path": ".", "pattern": "**/*.py", "max_results": 50}

{"kind": "grep_files", "path": "src", "pattern": "def \\w+",
 "glob": "**/*.py", "case_sensitive": false, "max_results": 50}
Kind Permission Notes
read_file file.read offset / limit (line range) optional.
write_file file.write Creates or overwrites; parent dirs created as needed.
edit_file file.write old_string must be unique unless replace_all: true.
delete_file file.write
glob_files file.read path defaults to ..
grep_files file.read glob filters which files are searched.

Permission scopes are configured per-op kind. See reference/config/permissions.md.

A successful edit_file result additionally carries a preview (str): a numbered-line view (<lineno>\t<text>, 1-based) of the changed region — the lines around where new_string landed (±3 by default), so the agent can SEE what changed and at what indentation, not just {status, replacements}. It is show-not-judge (numbered lines only — no syntax check or validity verdict), language-agnostic (pure line slicing), and bounded (capped height). For replace_all it shows the first changed region; the count is in replacements.

The coarse file execution backend (not phase-emittable)

The fine kinds above are the only file ops a phase advertises to (and accepts from) the LLM. They are dispatched through the unified ToolRegistry, then build a coarse FileIROp ({kind: "file", op: ...}) internally and route to the shared op_runtime/file.py backend. That coarse file kind — dropped from OP_KIND_MODEL_MAP — is not an LLM-emittable Control IR kind. It survives only as:

  • the shared execution backend the fine handlers delegate to, and
  • the target of OS-deterministic preprocessor run_op steps ({kind: file, op: ...}), the chat host file methods, and the reyn memory CLI.

Those non-phase callers also reach extended sub-ops the fine kinds do not expose — mkdir, move, stat, and regenerate_index (used by reyn memory and memory-managing skills via the preprocessor / CLI, never as phase Control IR).

ask_user

Pauses the phase and asks the user. The OS prints the question, reads stdin, and re-runs the same phase with the answer merged into the input as a user_message artifact. Visit count does not increment.

{
  "kind": "ask_user",
  "question": "Which model do you want to target?",
  "suggestions": ["light", "standard", "strong"],
  "options": ["light", "standard", "strong"],
  "required": true
}

suggestions are free-text hints (the user may still type anything). options (PR-F3, #2233) is a closed selectable set — when non-empty, the frontend renders a selector over exactly those answers (empty → free-text input). required (default true) — when false, the user may dismiss without answering.

sandboxed_exec

Executes argv under a declared SandboxPolicy via the OS's selected SandboxBackend. Replaces shell for cases that need (or will need, once SeatbeltBackend / LandlockBackend land) real isolation enforcement.

{
  "kind": "sandboxed_exec",
  "argv": ["echo", "hello"],
  "network": false,
  "read_paths": ["{{workspace}}"],
  "write_paths": ["{{workspace}}/output"],
  "allow_subprocess": false,
  "env_passthrough": ["PATH"],
  "timeout_seconds": 60
}

Fields: - argv (required) — command + arguments. argv[0] is the executable. - network (optional, default false) — allow outbound network. - read_paths (optional) — filesystem paths the process may read (glob patterns OK). - write_paths (optional) — filesystem paths the process may write. - allow_subprocess (optional, default false) — may spawn children. - env_passthrough (optional) — env-var names that pass through (others are stripped). - timeout_seconds (optional, default 60) — wall-clock cap.

Backend selection: get_default_backend() chooses per platform. On macOS < 26, SeatbeltBackend (sandbox-exec SBPL). On Linux ≥ 5.13 with the sandbox-linux extra installed, LandlockBackend (+ optional seccomp-BPF stack). On other platforms or when the chosen backend is unavailable, falls back to NoopBackend (audit-only, no enforcement) — emits a one-line WARN on first use. Override via reyn.yaml sandbox.backend (auto | seatbelt | landlock | noop) and sandbox.on_unsupported (warn | error | ignore).

Result fields: returncode, stdout, stderr, truncated, backend.

Events emitted: sandboxed_exec_started, sandboxed_exec_completed (P6 audit trail).

Searches the public web using DuckDuckGo and returns structured results. Tier 1 — default allow; no permission declaration required. Can be blocked project-wide with web.search: deny in reyn.yaml.

{
  "kind": "web_search",
  "query": "reyn agent OS site:github.com",
  "max_results": 10,
  "backend": "duckduckgo"
}

Fields: query (required), max_results (optional, default 10), backend (optional, default "duckduckgo"; currently the only supported value).

Standard DuckDuckGo search operators are supported in query:

  • site:<domain> — scope results to one domain (e.g. site:news.ycombinator.com)
  • "phrase" — require exact phrase match
  • -term — exclude results containing term

Use operators when the user's intent is site-specific or phrase-anchored; plain keywords work otherwise. Results are returned as a list of {title, url, snippet} objects under results.

web_fetch

Fetches a single URL and returns its text-extracted content. Tier 1 — default allow; no permission declaration required. Typically used after web_search to read a result page in detail. Block with web.fetch: deny in reyn.yaml; pre-approve silently with web.fetch: allow.

{
  "kind": "web_fetch",
  "url": "https://example.com/article",
  "prompt": "extract the key findings",
  "max_length": 50000
}

Fields: url (required), prompt (optional hint describing what to extract — informational for the LLM, not executed by the OS), timeout (optional, default 30 seconds), max_length (optional, default 50000 characters).

HTML responses are text-extracted (scripts, styles, and non-content tags stripped). If the content exceeds max_length, it is truncated and truncated: true appears in the result. Non-HTML responses are returned as-is.

mcp

Calls a tool on a configured MCP server. Requires the server to be declared in reyn.yaml under mcp.servers: and listed in the skill's permissions.mcp frontmatter block.

{
  "kind": "mcp",
  "server": "filesystem",
  "tool": "read_text_file",
  "args": {"path": "README.md"}
}

Fields: server (required — must match a key under mcp.servers: in reyn.yaml), tool (required — tool name as advertised by the server's tools/list response), args (optional, default {}).

Advertised name. Phases advertise this op to the LLM under the chat-tool name call_mcp_tool; the OS aliases it back to the mcp kind at the parse boundary. mcp remains the canonical kind in OP_KIND_MODEL_MAP and on the dispatched op.

The OS resolves the server's transport (stdio, http, or sse), dispatches via MCPClient, and returns the tool result. Every call emits mcp_called, mcp_completed, and (on failure) mcp_failed events.

See concepts/tools-integrations/mcp.md for server configuration, transport options, and the security model.

mcp_install

Installs an MCP server from registry.modelcontextprotocol.io into the project's config. Phase-only (not available from the router). Requires permissions.mcp_install: true in the skill's frontmatter and user approval.

{
  "kind": "mcp_install",
  "server_id": "io.github.modelcontextprotocol/server-filesystem",
  "scope": "local",
  "env_overrides": {"GITHUB_TOKEN": "ghp_..."}
}

Fields: - server_id (required) — registry identifier (e.g. "io.github.foo/bar-mcp"). - scope (optional, default "local") — config tier to write to: - "local"<project>/.reyn/config.yaml - "project"<project>/reyn.yaml - "user"~/.reyn/config.yaml - env_overrides (optional) — pre-supplied secret env values; skip interactive prompt for keys present here.

Handler lifecycle: 1. Fetches server.json via RegistryClient 2. Checks runtime command availability (npx / uvx / docker / dnx) 3. Gates via PermissionResolver.require_file_write (= .reyn/mcp.yaml) + require_http_get (= registry host); the legacy require_mcp_install bool-axis gate has been removed 4. Prompts for isSecret=true env vars via intervention_bus; each save_secret routes through PermissionResolver.require_secret_write (= Phase 6 wildcard "*" covers the runtime-determined key set) 5. Writes mcp.servers.<name> to the target scope config file 6. Emits mcp_server_installed event (P6) — key names only, no values

Removed ops. The embed and index_write control-IR ops were removed. Embedding + index writing are now done provider-direct inside reyn.api.safe.embed_index.embed_and_index() (a safe-mode python step streams its own chunks into it — the bundled index_docs / index_events chunkers were removed along with the stdlib skills that wrapped them) and inside the recall op (query embedding). The EmbeddingProvider and SqliteIndexBackend primitives are unchanged — only the run-op wrappers and bundled chunkers are gone. Nothing emits kind: embed / kind: index_write anymore.

skill_install

Registers a skill (from a local directory or a git/GitHub source URL) into the project's skills.entries config. Two tool surface verbs converge on the same op_runtime/skill_install.py handler: skill_management__install_local (local path) and skill_management__install_source (git/URL, PR-D, #2548).

Local-path example:

{
  "kind": "skill_install",
  "path": "skills/my-skill",
  "name": "my-skill"
}

Source/git example:

{
  "kind": "skill_install",
  "source": "https://github.com/user/skill-repo",
  "name": "my-skill"
}

Subdir convention (mirrors Terraform): "https://github.com/user/repo//skills/my-skill" selects the skills/my-skill subdirectory inside the cloned repo.

Fields: - path (required when source is absent) — path to the skill directory (containing SKILL.md) or the direct path to the SKILL.md file. May be absolute or project-root-relative. When pointing at a directory the handler appends /SKILL.md. Ignored when source is set. - source (optional, PR-D) — git or GitHub URL. The handler shallow-clones the repo to .reyn/skills/<name>/. Subdir inside the repo is specified via // separator. Requires http.get: [{host: <source_host>}] in the caller's permission declaration. - scope (optional, default ".reyn/config/skills.yaml") — retained for forward compat; currently unused (all installs write to .reyn/config/skills.yaml). - name (optional) — config key override. When absent the handler resolves: frontmatter name: field → directory basename → repo/subdir basename (in that order). The resolved name is sanitized to a single safe path component ([A-Za-z0-9._-]; no /, \, .., or leading .) — an unsafe name (from caller op.name OR third-party SKILL.md frontmatter) is rejected with status="error", never used to build a path.

Handler lifecycle (source path inserts steps 0a–0d before step 1): 0. Source path only: (a) Gate require_http_get for the source host. (b) Sanitize the candidate name (_safe_skill_name) + verify the clone destination is contained under .reyn/skills/ (_contained_under) — refuse before any filesystem mutation if either fails (path-traversal → arbitrary-rmtree guard). Shallow-clone repo to .reyn/skills/<candidate_name>/. (c) Locate SKILL.md in root or subdir. (d) After the frontmatter name is resolved AND sanitized, containment-check + rename clone dir if name ≠ candidate. 1. Resolve SKILL.md path (dir → <dir>/SKILL.md or direct file) 2. Read SKILL.md and split_frontmatter() — extract name and description 3. Apply op.name override when set 4. Threat-scan description via content_guard.scan_for_threats(scope="strict") — block on blocking-severity match (source path: removes clone on block) 5. Gate via PermissionResolver.require_file_write (= .reyn/config/skills.yaml) 6. Write skills.entries.<name> to .reyn/config/skills.yaml with {path, description, enabled: true, auto_invoke: true} (+ source: <url> when set) 7. Call record_config_generation (recovery-core: truncation-surviving snapshot, #2259 / CLAUDE.md gate) 8. Emit skill_installed event (P6 audit trail) 9. Request hot-reload via get_active_hot_reloader().request_reload(source="skill_install")

Result fields: status ("installed" / "blocked" / "error"), name, path, description, config_path, source (empty string for local installs).

Events emitted: skill_install_threat_match, skill_install_threat_blocked (threat scan), skill_installed (P6 on success).

index_query

Semantic similarity search over a single indexed source.

{
  "kind": "index_query",
  "source": "project_docs",
  "query_vector": [0.1, 0.2, ...],
  "top_k": 5,
  "filters": {"path": "docs/concepts"}
}

Fields:

  • source (str, required) — logical source name.
  • query_vector (list[float], optional) — pre-computed embedding. If null, falls back to catalog enumeration (up to fallback_size_cap tokens).
  • top_k (int, default 5) — number of results to return.
  • filters (dict[str, str], optional) — metadata key/value filters applied before ranking.
  • fallback_size_cap (int, default 4096) — token cap for enumerate fallback when query_vector is null.

Returns: {"kind": "index_query", "source": str, "results": [{"text": str, "score": float, "metadata": dict}]}.

recall

Macro op: embed a query → call index_query per source → merge and return top-K results globally. The preferred high-level op for RAG retrieval.

{
  "kind": "recall",
  "query": "How does crash recovery work?",
  "sources": ["project_docs", "api_reference"],
  "top_k": 5,
  "embedding_model": "standard"
}

Fields:

  • query (str, required) — natural-language query to embed and search.
  • sources (list[str], required) — logical source names to search. Must not be empty.
  • top_k (int, default 5) — number of results returned after global merge.
  • filters (dict[str, str], optional) — forwarded to each index_query sub-op.
  • embedding_model (str, default "standard") — model class forwarded to the embed sub-op.

Returns: {"kind": "recall", "results": [{"text": str, "score": float, "source": str, "metadata": dict}]}.

Events: recall_embed_failed if the embed sub-op fails (query, error).

index_drop

Removes an indexed source entirely — deletes its SQLite backend and manifest entry. Destructive and irreversible. Requires permissions.index_drop: ask (or explicit allow) in skill frontmatter, and triggers a user-approval gate by default.

{
  "kind": "index_drop",
  "source": "project_docs"
}

Fields:

  • source (str, required) — logical source name to drop.

Returns: {"kind": "index_drop", "source": str, "chunks_dropped": int}.

Events: index_dropped (source, chunks_dropped).

judge_output

LLM-based output scorer for in-phase evaluation loops. Resolves a target dot-path to a value, calls an LLM with the caller-supplied rubric, and returns a score (0.0–1.0) plus a pass/fail flag.

{
  "kind": "judge_output",
  "target": "artifact.data.summary",
  "rubric": "Score 0.0-1.0: is the summary concise, accurate, and complete?",
  "threshold": 0.8,
  "on_fail": "transition"
}

Fields: - target (str, required): Dot-path to the value being scored (e.g. "artifact.data.summary"). Resolved against the current workspace artifact. - rubric (str, required): LLM prompt body. Skill author writes the evaluation criteria. The OS never interprets this content (P7). - threshold (float, optional, default 0.8): Passing score in [0.0, 1.0]. - on_fail ("transition" | "abort" | "continue", optional, default "transition"): - "transition": LLM picks next phase (existing decision flow). - "abort": Abort skill execution. - "continue": Score recorded only; no flow change. - model (str | null, optional): Model class override (e.g. "strong"). Defaults to the skill's current model.

Returns: {"kind": "judge_output", "score": float, "passed": bool, "reason": str, "threshold": float, "on_fail": str}

Audit event: tool_executed with op=judge_output, target, score, passed, threshold, reason (P6).

P7 note: Reyn is rubric-agnostic. The rubric content is part of the skill's authored prompt; the OS only routes it to the LLM without inspection.

compact

Voluntarily compact the conversation/phase history now, freeing context window. The OS injects a context-size signal (a ## Context window header with the exact-token free window) when the window is filling; the model may respond by emitting compact instead of waiting for the mandatory retry_loop backstop. The op routes to the caller-wired compaction (chat: force_compact_now; phase: compact_control_ir_results on-demand seam) and reports the freed tokens + the free window afterwards, in exact tokens (unit-aligned with the media load-contract error so "should I compact" and "what fits now" use the same scale).

{
  "kind": "compact"
}

Fields: - reason (str, optional): Short model-supplied rationale for the audit trail. The OS never interprets it.

Returns: - status: "ok" | "error" - freed_tokens: int — exact-token reduction. Per-axis meaning: on the phase axis this is the real control_ir_results shrink. On the chat axis it is ~0 by construction — the router prompt is head+tail turn-count bounded (_build_history_for_router), so compaction does not shrink the bounded view; it compresses the already-elided middle into a summary bridge. Don't front freed_tokens for chat. - free_window_after / free_window_before: int — exact-token headroom after / before. - Chat-axis compression metric (the meaningful chat signal; null on the phase axis): summarized_turns: int (older turns folded into the bridge), compressed_tokens: int (their raw token cost), bridge_tokens: int (the summary's token cost). The chat value is the compressed_tokens → bridge_tokens compression, not freed_tokens. - On error: error_kind (compaction_unavailable when no compaction context is wired here; compaction_failed) + error.

Events: compact_op_requested / compact_op_completed (freed_tokens, free_window_after, + chat-axis summarized_turns / compressed_tokens / bridge_tokens) / compact_op_failed / compact_op_unavailable (P6). The inner compaction engine emits its own compaction events.

Permission: none required (LLM cost only). Voluntary and independent of the involuntary retry_loop backstop, which always runs regardless.

Visibility: advertised to the LLM (tool / available_control_ops) only when the window is filling — paired with the context-size signal — so it is not offered when there is nothing to compact (mirrors the search_actions visibility gate). The permission gate stays "allow"; only when surfaced is gated.

Axis scope (chat vs phase): the compact op is available on both axes. On the chat axis, it routes to force_compact_now; on the phase axis, it routes to the compact_control_ir_results on-demand seam wired by the phase runtime (in addition to the automatic per-frame compaction that fires regardless). In both cases the OS wires ctx.compact_now; the op handler itself is axis-agnostic. Both axes also inject the paired context-size signal so the model knows when to emit compact.

Task ops

First-class trackable work-units. A Task is opt-in and additive — the session model (concurrent / interleaved) is unchanged; a Task is a discrete handle whose lifecycle is tracked independently. Completion is an explicit declaration (the assignee emits task.update_status), never inferred from session state.

These ops are term-neutral (P7): names + fields are generic; A2A vocabulary (contextId, TaskState) maps only at the A2A layer. The op family is gated by allowed_ops (declare task for the whole family, or individual task.* kinds); like any op, each is also subject to the per-session contextual gate.

{ "kind": "task.create", "name": "ship-feature" }
{ "kind": "task.create", "name": "sub", "deps": ["<other-id>"] }
{ "kind": "task.create", "name": "bg-sub", "link_type": "background" }
{ "kind": "task.update_status", "task_id": "<id>", "status": "running" }
{ "kind": "task.add_dependency", "task_id": "<id>", "depends_on": "<other-id>" }
{ "kind": "task.abort", "task_id": "<id>" }

Roles. A Task is owned by two session identities (the per-contextId routing-key): the requester (origin / assigner / disposition notify-target — the caller of task.create, set by the OS, not an op field) and the assignee (the worker session, the single-writer of status). Under #2187 backend-master the assignee is a rebindable WAL subscription binding (§27-31), not an immutable field: it may be None (UNASSIGNED — the pending-assignment queue) and is changed via task.assign (claim / owner-initiated hand-off / re-queue). On task.create: an explicit assignee delegates (or self-assigns); an owned sub-task (created while executing a task — OS-derived ownership, §16) with no assignee defaults to the caller (the decomposition continuation); a top-level task with no assignee is UNASSIGNED (it waits in the queue until claimed). One session can be the assignee of many Tasks (1 : N).

Single-writer is an op-layer CAS caller session_id == the CURRENT (hydrated) assignee (OpContext.session_id, threaded by the OS — not an op field, so it cannot be forged), checked against the rebindable WAL binding; a non-assignee write is rejected. This is not a permission gate (the permission system is resource-scoped, no caller identity at op-exec). The single-writer invariant (one writer per task) keeps the read-then-check race-free without a claim token / version.

Pending-assignment queue (§27-31). A top-level task.create with no assignee produces an UNASSIGNED task (no binding, no execute-wake) that sits in the queue — listed by task.list status=unassigned. task.assign then binds a session: an UNASSIGNED task may be claimed by any session; an already-assigned task may be reassigned only by its current assignee (owner-initiated hand-off — others request a change via conversation). Assignment rebinds the WAL subscription, OS-derives the now-startable status (READY / BLOCKED-by-deps), and wakes the new assignee to execute.

Role-based op authority (P5). Each op is gated on the caller's session_id: assignee-gatedupdate_status / heartbeat / register_unblock_predicate; requester-gatedcreate / add_dependency / get / abort; assign is current-assignee-gated for a reassign but open for an UNASSIGNED claim. A violation returns a role_denied result.

abort = delete (cooperative-terminal). task.abort is the requester's remove-op (it absorbs the former task.archive): it archives the task and its whole sub-tree (DOWN-cascade, §18). There is no forced cancel — the assignee's in-flight work is rejected by the terminal-state guard on update_status at its next write (so no straggler lands, and a sibling task's work is untouched). This is correct under 1:N (a session owns many tasks) and needs no cross-session machinery. UP-notify (§16): a non-completed terminal (aborted / failed / cap_exceeded) with still-alive dependents notifies the task's requester (the §16 disposition notify-target — the request-owner) to decide recovery. For an origin=self (internal) task the requester's session is woken (the slice-7 TaskWaker) so its LLM re-wires the stuck dependents via ordinary task ops (P7 — no decision= vocabulary); for an origin=external task the A2A layer routes it to the external (webhook) channel. The requester is always present, so a root task is notified too (#2107: the prior parent-keyed routing dropped roots). Abort also emits a generic P6 task_disposition event per aborted task (task_id / requester / origin / disposition).

States (7-state, #2187 §3.4): unassigned (no assignee — the pending-assignment queue) / blocked (deps not all terminal) / ready (assigned, startable) / running / done / failed / aborted. Soft-delete (archived_at) is an orthogonal retention marker, not a state.

Dependency DAG (§13). task.add_dependency and task.create(deps=[...]) add depends-on edges through a shared edge-guard (completeness): the depends_on task must exist and the edge must not create a cycle. A rejected edge returns a decision-enabling error result (status="error", error.kind="cycle" / "dep_not_found", the offending edge, and — for a cycle — the path), never a raised exception. Edge-add is a pure topology write — it never flips a task's status (the requester does not write the assignee's status). A task born with not-all-completed deps is OS-derived blocked at create (deps-less tasks keep their requested status).

Mutable edges (slice 6-ext). task.remove_dependency drops an edge (idempotent — a no-op on a missing edge); task.repoint_dependency atomically repoints from_depends_onto_depends_on, cycle-checking the NEW edge BEFORE any mutation (a cycle/dangling repoint changes nothing and returns the same structured error). Both are requester topology writes that go through the same shared edge-guard, then run the OS-authority readiness re-derive.

Readiness re-derive (OS-authority, P3). A single primitive derives a task's readiness over the pre-run scheduling states {pending, ready, blocked}: promote blocked → ready when all deps are satisfied (incl. a deps-less task — removing the last dep readies it), re-block {pending, ready} → blocked when they are not. An in_progress (the assignee owns the run) or terminal task is left untouched — this is the single-writer split (the OS schedules pre-run, the assignee owns the run), and the write bypasses the assignee CAS like abort. Completion-recompute (relax → only promotes), remove (relax), and repoint (may demote or promote) all share it. A readiness change emits a generic P6 task_readiness event.

Disposition → requester routing (§16, S1 #2134). When a task reaches a non-completed terminal (aborted via task.abort, failed via task.update_status, or cap_exceeded on a per-Task budget cap-hit) and has still-alive dependents, the OS notifies the task's requester (the §16 disposition notify-target — the request-owner) to decide recovery — the requester re-wires via ordinary ops (repoint / remove / fail / support-self), not a decision= vocabulary (P7). If the requester is a task (requester_kind=TASK — a task-as-request owns the failed dependent), the OS resolves one hop to that task's assignee (the managing session) before waking. The requester is always present (every task carries one), so a root task is notified too — the prior parent_id-keyed routing silently dropped root-task recovery wakes (#2107). The disposition is carried first-class (in both the P6 task_dependency_aborted event and the requester payload) so a budget cap_exceeded is never conflated with a genuine error failed (slice 8). External-origin tasks route via the A2A/webhook channel rather than an in-session wake.

Per-Task budget cap (slice 8). record_cost(task_id, delta) accumulates an LLM call's cost onto the Task's cost_accum; when it crosses the Task's budget_cap (an INDEPENDENT cap dimension, enforced alongside the session / daily caps — the tighter hits first), the OS force-terminates the task (abort-like) and routes the cap_exceeded disposition through the SAME requester-LLM seam — so one recovery mechanism resolves both a terminal-dependency and a cap-hit. (The production wiring of the LLM cost recorder to record_cost co-lands with the task-execution engine in a later slice.)

The TaskWaker (slice 7). The OpContext.task_waker (the OS TaskWaker driver) turns these dispositions into actual session wakes via the canonical resolve_session → _put_inbox → ensure_session_running triple: a promoted dependent (on a predecessor completed OR a recovery repoint/remove) is woken with a task_ready inbox message; a requester (or its managing session) is woken with task_dependency_aborted. Both surface to the woken session's LLM as one router turn (OS-generic inbox kinds, P7) so it resumes / recovers via ordinary task ops. A loopless session (A2A / MCP, no run-loop) is booted by ensure_session_running; a looped one is an idempotent no-op. This in-process driver is complementary to the cross-process A2A webhook disposition sweep.

Still landing in later slices: per-task liveness (unblock-predicate / heartbeat evaluation) — the residual non-terminal-stuck backstop.


Note for contributors: When adding a new Control IR op kind to src/reyn/schemas/models.py and src/reyn/core/op_runtime/registry.py, also add a section here in the same PR. The reference and the registry must stay in sync — see CLAUDE.md for the rule.

Where ops are exposed to the LLM

The OS injects available ops into every context frame as available_control_ops. Each entry includes a kind, a one-line description, and a worked example. The LLM picks ops by matching its intent to descriptions — phase markdown MUST NOT describe op syntax (P8).

See also

  • events.md — events emitted per op kind
  • Concepts: principles P8 (principles doc removed)