Source code for fractal.core.node

"""Implements ``Node`` class."""

from __future__ import annotations

import functools
import json
import logging
import os
import pathlib
import signal
import subprocess
import time
import typing
from collections.abc import Callable, Iterator
from typing import Any, Optional

import fractal.util
from fractal.constants import (
    CONFIG_FILE,
    DB_FILE,
    FRACTAL_FOLDER,
    PAUSED_FILE,
    PGID_FILE,
    PROJECT_FOLDER,
    SOCKET_FILE,
    STATUS_FILE,
    STATUSES,
    STEP_PGID_FILE,
    WORKTREES_FOLDER,
)
from fractal.typing import PathLike

from . import commit, render, worktree
from .config import DEFAULT_RESERVE_FRACTION, RESERVE_PRECISION, Config
from .cost import Cost
from .db import Database
from .files import Files
from .plan import Plans
from .radio import Radio
from .record import Record, _invalid_status
from .session import Sessions
from .time import Time

if typing.TYPE_CHECKING:
    from .agent import Agent, Invocation, StreamEvent

__all__ = [
    'Node',
    'node_dir',
    'tmux_session_name',
]

# module logger (the fractal.* hierarchy; the package never configures
# handlers -- a host attaches its own) for the classmethod diagnostics
# that have no instance logger
logger = logging.getLogger(__name__)

# quiet-time floor for the listing's staleness flag: an active loop writes
# activity at least once per step, so silence past max(step_timeout, 5m)
# earns the '!' suffix on the rendered age
_STALE_AGE_FLOOR_SECONDS = 300.0


[docs] class Node: """An autonomous agent node in a git worktree. Tracks status in a ``.status`` file and the tree's central database (hosted in the root node's data directory); delegates shell-native work (git, tmux) to ``_scripts/``. """ __config__: type[Config] = Config __cost__: type[Cost] = Cost __files__: type[Files] = Files __plans__: type[Plans] = Plans __radio__: type[Radio] = Radio __record__: type[Record] = Record __sessions__: type[Sessions] = Sessions __time__: type[Time] = Time def __init__(self: Node, path: PathLike, *, branch: Optional[str] = None) -> None: """Initialize ``Node``. Args: path: Worktree directory (or repo root for init). branch: Pin the node's branch instead of reading it from git. Only for resolving a node whose worktree is checked out to a different branch (the user node from a non-init checkout, via :meth:`resolve_user`); ``None`` reads the live git branch. """ self._root = pathlib.Path(path).expanduser().resolve() self._branch = branch @property def is_user(self: Node) -> bool: """Return whether this is a user (root) node.""" return self.config.get('user', False)
[docs] @functools.cached_property def db(self: Node) -> Database: """Return the central database, hosted in the root node's data directory. Resolved through the ``root`` config key (written at init, inherited from the parent) and the root's ``.worktrees/.project/<root>`` cache, so any node in the tree opens the same ``.db`` without a worktree lookup. """ root = self.config.get('root') project = worktree.project_path(self.repo_dir, root) if project == '.': db_path = self.repo_dir / FRACTAL_FOLDER / root / DB_FILE else: db_path = self.repo_dir / project / FRACTAL_FOLDER / root / DB_FILE schema_path = pathlib.Path(__file__).parent / 'schema.sql' return Database(db_path, schema_path)
[docs] @functools.cached_property def radio(self: Node) -> Radio: """Return the node radio.""" # construct through the collaborator slot return self.__radio__(self)
[docs] @functools.cached_property def config(self: Node) -> Config: """Return the node configuration surface.""" # construct through the collaborator slot return self.__config__(self)
[docs] @functools.cached_property def cost(self: Node) -> Cost: """Return the node cost ledger (config caps over step-cost rows).""" # construct through the collaborator slot return self.__cost__(self)
[docs] @functools.cached_property def files(self: Node) -> Files: """Return the node work-product surface (project files).""" # construct through the collaborator slot return self.__files__(self)
[docs] @functools.cached_property def plans(self: Node) -> Plans: """Return the node plan files.""" # construct through the collaborator slot return self.__plans__(self)
[docs] @functools.cached_property def record(self: Node) -> Record: """Return the node execution record (run/iter/step spans, events, signals).""" # construct through the collaborator slot return self.__record__(self)
[docs] @functools.cached_property def sessions(self: Node) -> Sessions: """Return the node per-iteration agent session map.""" # construct through the collaborator slot return self.__sessions__(self)
[docs] @functools.cached_property def time(self: Node) -> Time: """Return the node deadline accounting (config timeouts over row instants).""" # construct through the collaborator slot return self.__time__(self)
[docs] @functools.cached_property def logger(self: Node) -> logging.Logger: """Return the stdlib logger named for the concrete class.""" name = f'{type(self).__module__}.{type(self).__name__}' return logging.getLogger(name)
[docs] def log(self: Node, message: str, level: int = logging.INFO) -> None: """Log a message to this node's logger.""" self.logger.log(level, message)
[docs] @functools.cached_property def branch(self: Node) -> str: """Return the current git branch name (one ``git rev-parse``, cached). A worktree's checked-out branch is fixed for the life of a ``Node`` handle -- teardown/re-init paths construct fresh handles, so a fresh ``Node`` is a fresh read. """ # a pinned branch (the cross-checkout user-node resolver) wins over the # live git read -- the user node's worktree may sit on another branch if self._branch is not None: return self._branch # safe to cache: fractal never switches a worktree's branch # mid-process -- handles are per-invocation (CLI) or refreshed # through TuiData's branch-keyed cache (TUI); cached_property caches # only successful resolution, so exists()'s RuntimeError swallow on # a repo-less path is unaffected return fractal.util.git.branch(self._root)
@property def worktree(self: Node) -> pathlib.Path: """Return the node's resolved worktree path.""" return self._root @property def _package_dir(self: Node) -> pathlib.Path: """Return the installed ``fractal`` package root (where the code lives).""" return pathlib.Path(__file__).parent.parent
[docs] @functools.cached_property def repo_dir(self: Node) -> pathlib.Path: """Return the main git repo root (through worktrees; one probe, cached).""" return fractal.util.git.common_dir(self._root)
@property def node_dir(self: Node) -> pathlib.Path: """Return the node data directory. Under a sub-project (per the ``.worktrees/.project/<branch>`` cache) the dir nests at ``<worktree>/<project>/.fractal/<branch>``. """ return node_dir(self._root, self.project_path, self.branch) @property def _tree_latch_file(self: Node) -> pathlib.Path: """Return the tree-wide pause latch marker, beside the central database. Written by a user-node :meth:`pause` and removed by its :meth:`resume`: a depth-1 node's only ancestor is the statusless user root, so without the marker a start racing a tree-wide pause fan-out would slip in unfrozen. Lives in the root node's data directory, so it rides a filesystem transplant with the rest of the paused state. """ return self.db.path.parent / PAUSED_FILE @property def _status_file(self: Node) -> pathlib.Path: """Return the path to the node's ``.status`` file (lifecycle state).""" return self.node_dir / STATUS_FILE @property def project_path(self: Node) -> str: """Return the worktree-relative project sub-path (``'.'`` for a repo-root node). Cached per-branch at ``.worktrees/.project/<branch>`` (written at init); absent for a repo-root project, which reads as ``'.'``. """ return worktree.project_path(self.repo_dir, self.branch) @property def parent(self: Node) -> Optional[Node]: """Return the parent by dotted-branch derivation (``None`` for the user node). Also ``None`` when the parent's worktree is gone (pruned out of band), matching the ancestor walk's skip. """ if '.' not in self.branch: return None parent_branch, *_ = self.branch.rsplit('.', 1) worktree_dir = fractal.util.git.find_worktree(self.repo_dir, parent_branch) return self.__class__(worktree_dir) if worktree_dir else None @property def tmux_session(self: Node) -> str: """Return the node's tmux session name (via :func:`tmux_session_name`).""" try: branch = self.branch except RuntimeError: # branchless fallback still matches the shells' name sanitization return self.repo_dir.name.replace('.', '-').replace(':', '-') return tmux_session_name(self.repo_dir, branch) def _tmux_session_exists(self: Node) -> Optional[bool]: """Return whether this node's tmux session is alive (``None`` unknown). Mirrors ``start.sh``'s check exactly: an exact-match (not ``tmux -t``, which resolves targets by prefix/fnmatch and false-matches longer names) of the session name against ``tmux list-sessions``. The listing asks the server the session lives on -- the socket the loop recorded at boot (``.socket``) -- because a tmux answer is evidence about one server only: the ambient socket's "no sessions" (a shell with a different ``TMUX_TMPDIR``) says nothing about a session alive on the recorded one; without a record (a tmux-less launch), the ambient socket is all there is. ``None`` when the probe is inconclusive -- :func:`fractal.util.tmux.probe` got no answer from tmux (binary absent, or ``list-sessions`` failed for anything but the definitive ``no server running``) -- so the reconcile path never mistakes a blind host for a dead loop. """ # probe the recorded socket, not whichever one this shell resolves socket_file = self.node_dir / SOCKET_FILE if socket_file.exists(): socket = socket_file.read_text(encoding='utf-8').strip() sessions = fractal.util.tmux.probe(socket=socket) else: sessions = fractal.util.tmux.probe() if sessions is None: return None return self.tmux_session in sessions def _reconcile_status(self: Node) -> None: """Stamp a crashed-but-active node ``exited``. A loop that dies without ending (a hard kill, a direct ``tmux kill-session``, a host crash) leaves the ``.status`` file ``active`` with no tmux session, wedging the reject-active guards. The one-loop-per-node invariant (``start.sh`` refuses to launch while the session exists) makes a missing session proof the loop is gone, so stamp the same honest terminal :meth:`Record.run_start` uses for a stranded run -- both the ``.status`` file and the crashed run's still-open runs/iters/steps rows, so a later merge/delete/retire (none of which start a loop) cannot leave the DB reading ``active`` while the status reads ``exited``. The settle also heals any config/registry cap drift (:meth:`Config.reconcile`): a loop that died before its next iteration boundary never ran the boundary reconcile, and the dead row would otherwise carry the drift forever. A no-op unless the status is ``active``, so a settled node never pays the tmux probe -- and a ``paused`` node is never healed: no session is its *normal* parked state (the loop exits at pause; ``resume`` relaunches it), on this host or after a filesystem transplant to another. An inconclusive probe (no answer from tmux) also skips the heal: the reap keys off proof the session is gone, never off ignorance -- a shell without tmux visibility (a cron/CI host) must not kill a healthy loop's process groups, and a shell on a *different* socket gets its proof from the loop's recorded one (see :meth:`_tmux_session_exists`). Never reconciles the node from inside its own running loop: the loop self-finishes (``send_budget_finish`` calls ``node finish``), and a host probing a socket its own session does not live on would otherwise read its own live run as crashed and kill the very run it is driving. """ if self._is_own_loop(): return if self.status() != 'active': return if self._tmux_session_exists() is False: self._reap_orphan() self.record.close_open('exited') self.status_set('exited') self.config.reconcile() # the heal is the record's catch -- the settled node keeps no # socket handle (the next boot writes a fresh one) (self.node_dir / SOCKET_FILE).unlink(missing_ok=True) def _reap_orphan(self: Node) -> None: """Kill surviving process groups recorded by a dead loop. The loop records its process group at run start (``.pgid``) and each agent invocation's own group (``.step_pgid``); both are removed on any in-band exit, so a file that outlives the tmux session marks an out-of-band pane death (tmux kill/crash, host OOM) whose agent may still be running -- and spending -- headless. The reap follows ``kill.sh``'s TERM-grace-KILL cadence and logs an ``orphan`` event naming each reaped pgid. Best-effort: a dead, recycled, or foreign group reads as already gone -- recycled meaning the OS re-issued a dead group's id to an unrelated same-user group, which answers a liveness probe but is younger than its record (:func:`_recorded_group`). """ for name in (PGID_FILE, STEP_PGID_FILE): pgid_file = self.node_dir / name if not pgid_file.exists(): continue # trust the record only while its group is still alive; a record # a rival reconciler already swept reads as gone try: recorded_at = pgid_file.stat().st_mtime pgid = int(pgid_file.read_text(encoding='utf-8').strip()) os.killpg(pgid, 0) except ( FileNotFoundError, ValueError, ProcessLookupError, PermissionError, ): pgid = 0 # aliveness is not identity: a recycled pid answers the probe from # an unrelated group -- spare any group younger than its record if pgid > 0 and not _recorded_group(pgid, recorded_at): pgid = 0 # reap the group and audit the reap if pgid > 0: try: os.killpg(pgid, signal.SIGTERM) for _ in range(10): time.sleep(0.2) os.killpg(pgid, 0) os.killpg(pgid, signal.SIGKILL) except (ProcessLookupError, PermissionError): pass event_id = self.record.event_start( 'orphan', metadata=f'reaped pgid {pgid}', ) self.record.event_end(event_id=event_id, status='completed') pgid_file.unlink(missing_ok=True) def _is_own_loop(self: Node) -> bool: """Return whether this process is running inside this node's own loop. The loop exports ``_NODE`` for the node it drives, so a ``fractal`` call it spawns (e.g. its budget finish's ``node finish``) resolves the caller back to this node -- proof the loop is alive regardless of a tmux session, which the test harness and a tmux-less host lack. """ caller = self.resolve_caller() return caller is not None and caller._root == self._root
[docs] @classmethod def resolve_caller(cls: type[Node]) -> Optional[Node]: """Resolve the calling node from the environment. When running inside a node (``_NODE`` env var set), returns a ``Node`` bound to the caller's worktree. Returns ``None`` outside a node context. """ if node_dir := os.environ.get('_NODE'): # resolve worktree root via git (handles scoped # project paths where .fractal/ is nested deeper) worktree = fractal.util.git.toplevel( pathlib.Path(node_dir), check=False, ) if worktree: return cls(worktree) return None
[docs] @classmethod def resolve_user(cls: type[Node], path: PathLike) -> Optional[Node]: """Resolve the repo's user (root) node by config, not the checkout. A bare ``Node`` keys on the repo's *current* branch, so on a non-init checkout (the user on their own branch while nodes run) the user node reads as uninitialized even though the fractal exists. Scan the repo's fractal data dirs (top-level and each sub-project) for the ``config.json`` marked ``user: true`` and pin a ``Node`` to that branch, independent of the git checkout. Args: path: Any path inside the repo. Returns: The user (root) node, branch-pinned, or ``None`` when there is no user node to find (no fractal, or no git repo at all). """ # mirror exists(): a repo-less path has no user node, not an error try: repo = cls(path).repo_dir except RuntimeError: return None # the user config lives at <repo>/[<project>/].fractal/<branch>/config.json; # check the top level first, then each sub-project dir fractal_dirs = [repo / FRACTAL_FOLDER] fractal_dirs += [ sub / FRACTAL_FOLDER for sub in sorted(repo.iterdir()) if sub.is_dir() and sub.name != WORKTREES_FOLDER ] # a nested sub-project (e.g. packages/foo) sits below iterdir's reach; # its dir resolves through the .worktrees/.project cache (written at # init, kept through reset) project_dir = repo / WORKTREES_FOLDER / PROJECT_FOLDER if project_dir.is_dir(): for project_file in sorted(project_dir.iterdir()): project = worktree.project_path(repo, project_file.name) if project != '.': fractal_dirs.append(repo / project / FRACTAL_FOLDER) for fractal_dir in fractal_dirs: if not fractal_dir.is_dir(): continue for config_path in sorted(fractal_dir.glob(f'*/{CONFIG_FILE}')): try: config = json.loads(config_path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): continue if config.get('user'): # anchor at the git root -- Node.node_dir derives the # <project>/ prefix from the .worktrees/.project cache, so a # sub-project anchor would double the prefix (mirrors # resolve_init_target); the branch is the config dir's name return cls(repo, branch=config_path.parent.name) return None
[docs] def exists(self: Node) -> bool: """Return whether this node has been initialized.""" try: return (self.node_dir / CONFIG_FILE).exists() except RuntimeError: return False
[docs] def init( self: Node, name: Optional[str] = None, *, path: Optional[PathLike] = None, title: Optional[str] = None, scope: Optional[list[str]] = None, base: Optional[str] = None, meta: Optional[str] = None, inherit: Optional[list[str]] = None, agent: Optional[str] = None, provider: Optional[str] = None, model: Optional[str] = None, effort: Optional[str] = None, max_iters: Optional[int] = None, max_depth: Optional[int] = None, max_children: Optional[int] = None, max_descendants: Optional[int] = None, timeout: Optional[str] = None, iter_timeout: Optional[str] = None, step_timeout: Optional[str] = None, step_retries: Optional[int] = None, step_retry_backoff: Optional[str] = None, interval: Optional[str] = None, sleep: Optional[str] = None, wait: Optional[str] = None, max_cost: Optional[float] = None, max_iter_cost: Optional[float] = None, max_step_cost: Optional[float] = None, reserve_budget: Optional[float] = None, sync: Optional[bool] = None, detached: Optional[bool] = None, local: Optional[bool] = None, blind: bool = False, reset: bool = False, user: bool = False, ) -> str: """Initialize an autonomous node. Creates a git worktree on a new branch named ``<name>`` (or ``<parent>.<name>`` if the current branch is a node) and populates a ``.fractal/<branch>/`` data directory with steps, hooks, skills, and configuration from the skill source. Args: name: Node name (current branch for user node). path: Project path (relative to repo) for user node; for a child, a non-root value selects its sub-project (default: inherit the parent's). A path under ``.worktrees/`` (the cwd resolving into a worktree) names the parent instead. title: Human-readable display name (defaults to the de-slugged name). scope: Subdirectory scope within the worktree. base: Branch to start from. meta: Target node branch for meta-configuration. inherit: Surfaces to seed from the parent node instead of the package seed (``steps``, ``scripts``, ``skills``, ``config``, or ``all``). ``config`` copies the parent's preference keys only -- budget-class caps never inherit. agent: Agent type. provider: Provider route for the agent (e.g. ``openrouter``; default: the vendor-native endpoint, inherited from the nearest ancestor). model: Model override (passed via the agent CLI's model flag). effort: Reasoning-effort override (passed via the agent CLI's effort flag). max_iters: Maximum number of iterations. max_depth: Maximum child node nesting depth. max_children: Maximum direct child nodes. max_descendants: Maximum total descendant nodes. timeout: Timeout per run (e.g. ``30m``). iter_timeout: Timeout per iteration (e.g. ``30m``). step_timeout: Timeout per step (e.g. ``30m``). step_retries: Retries per failed step (default 1; 0 disables). step_retry_backoff: Delay before each step retry (e.g. ``10s``; default ``10s``). interval: Fixed iteration schedule (e.g. ``30m``). sleep: Delay between iterations (e.g. ``10s``). wait: Sleep between approval-wait sync invocations (e.g. ``5m``). max_cost: Maximum cost in USD. max_iter_cost: Maximum cost per iteration in USD. max_step_cost: Maximum cost per step in USD (warn-only when unenforceable). reserve_budget: USD reserved for cleanup; shifts when the node enters reserve mode (not enforced). sync: Run sync mode before each step. detached: Separate agent invocation per step. local: Skip pushing to remote after each commit. blind: Subscribe to no channels (the parent still reads it). reset: Delete all node files and reinitialize. user: Initialize as a user node (DB + radio only). Returns: Script output. """ from .agent import command_base, resolve # coerce path to a str -- downstream '.' comparisons and persisted # caches expect the string form if path is not None: path = str(path) # handle user node init (name derived from current branch) if user: if name: raise ValueError('User nodes do not accept a name.') return self._init_user(path=path, agent=agent, provider=provider) # validate name if not name: raise ValueError('Node name is required.') worktree.validate_name(name) # flatten scope entries -- the CLI form is comma-separated, with # repeated flags tolerated, and whitespace is the stored form's # separator (config _set splits on it), so split on both here; # validating the pre-split string would pass roots the canonical # form later shatters if scope: scope = [ root for entry in scope for chunk in entry.split(',') for root in chunk.split() ] # a scope root is a repo-relative subdirectory -- reject an # absolute or '..' path, which would persist into config.json # (against the no-absolute-paths rule) and never match commit.py's # relative prefix check, bricking every scoped commit for root in scope: parts = pathlib.PurePosixPath(root).parts if pathlib.PurePosixPath(root).is_absolute() or '..' in parts: raise ValueError( f'--scope must be a repo-relative subdirectory, not' f' {root!r} (no absolute or ".." paths).' ) # flatten inherit entries the same way and expand the 'all' alias -- # agent config alone inherits from the parent unconditionally; every # other surface is opt-in if inherit: inherit = [ surface.strip() for entry in inherit for surface in entry.split(',') if surface.strip() ] for surface in inherit: if surface not in ('steps', 'scripts', 'skills', 'config', 'all'): raise ValueError( f'Unknown inherit surface: {surface!r}' ' (valid: steps, scripts, skills, config, all).' ) if 'all' in inherit: inherit = ['steps', 'scripts', 'skills', 'config'] # expand --meta into --base + --scope if meta: # handle mutually exclusive flags if scope: raise ValueError('--meta cannot be combined with --scope.') if base: raise ValueError('--meta cannot be combined with --base.') # validate target exists worktree_dir = self.repo_dir / WORKTREES_FOLDER / meta if not worktree_dir.is_dir(): raise ValueError( f'Meta target {meta!r} has no worktree.' ' Initialize the target node first.' ) # branch from the target node base = meta # scope to the target's seed dir; read its project from the .project # cache so sub-project nodes get the right prefix target_project = worktree.project_path(self.repo_dir, meta) if target_project == '.': scope = [f'{FRACTAL_FOLDER}/{meta}'] else: scope = [f'{target_project}/{FRACTAL_FOLDER}/{meta}'] # the base is also the squash-merge target: merge.sh squashes inside # the base's checked-out worktree, so a worktree-less base (a typo, # or a branch nothing has checked out) would only fail at merge time, # long after init printed its success -- refuse now, naming the # requirement if base and fractal.util.git.find_worktree(self.repo_dir, base) is None: raise ValueError( f'Base branch {base!r} has no checked-out worktree.' ' The base is the squash-merge target, so it must be' ' checked out in this repository.' ) # prefer the calling node (_NODE) so an agent's child nests under it, # not the repo-root user node; fall back to self for a top-level spawn parent = self.resolve_caller() # only adopt an ambient caller that lives in this repo -- a _NODE pointing # at a different repo would register the child in the wrong DB (split-brain) if parent is not None and parent.repo_dir != self.repo_dir: parent = None # no ambient caller but a path under .worktrees/ (the CLI derives path # from cwd, so this is a manual init from inside a worktree): parent on # that worktree's node rather than the root default if parent is None and path is not None: parts = pathlib.Path(path).parts if len(parts) >= 2 and parts[0] == WORKTREES_FOLDER: candidate = Node(self.repo_dir / WORKTREES_FOLDER / parts[1]) if candidate.exists(): parent = candidate if parent is None or not parent.exists(): parent = self # validate parent node if not parent.exists(): raise FileNotFoundError( 'Parent node could not be located.' " Run 'fractal init' to create a user node." ) # the composed child branch is bounded too (git writes a ref # file per branch, plus a .lock suffix) -- checkable only now # that the parent is resolved worktree.validate_name(name, parent_branch=parent.branch) # validate the cost and duration flags (the one merged validator: # finiteness, positive ceiling, reserve range, step <= iter <= run # ordering, unit suffixes) -- a pure check of the passed flags (no live # state), so it stays out here; the live subtree/budget caps # (max-children/depth/descendants/cost-remaining) are enforced inside the # .worktrees flock below, after a fresh re-read, so concurrent fan-out # cannot each pass the check before any of them takes the lock (a TOCTOU # race that would defeat the caps) config_values = { 'max_cost': max_cost, 'max_iter_cost': max_iter_cost, 'max_step_cost': max_step_cost, 'reserve_budget': reserve_budget, 'timeout': timeout, 'iter_timeout': iter_timeout, 'step_timeout': step_timeout, 'step_retry_backoff': step_retry_backoff, 'interval': interval, 'sleep': sleep, 'wait': wait, } self.config.validate(config_values) # inherit local from the parent; local is immutable once set if parent.config.get('local'): if parent is self: if local is False: raise ValueError('Local flag cannot be changed once set.') local = True elif local or local is None: local = True else: raise ValueError('Parent is local; child cannot push.') # default to non-local (allow pushing) if local is None: local = False # inherit the agent from the nearest ancestor (the user node sets the # default via `fractal init --agent`) when the spawn doesn't specify one if agent is None: for ancestor in parent._self_and_ancestors(): if ancestor_agent := ancestor.config.get('agent'): agent = ancestor_agent break if agent is None: raise ValueError( 'No --agent given and no ancestor has one configured;' " pass --agent or set a default with 'fractal init --agent'." ) # resolve the agent's base command against the registry now, reading # the CLASS without construction (the child node does not exist yet) # -- a junk name would store fine and kill the loop at boot inside # the tmux pane, after start already printed its success, so the # typo refuses here with the registry's supported-list error base_word = command_base(agent) resolved = resolve(base_word, root=parent.db.path.parent) # inherit the provider route the same way, materializing it only when # the child's agent supports routes -- an openrouter-defaulting # ancestor must never pin a route on a route-less backend (no raise # when absent: None means the vendor's own endpoint) if provider is None: for ancestor in parent._self_and_ancestors(): if ancestor_provider := ancestor.config.get('provider'): provider = ancestor_provider break if provider is not None and provider not in resolved.providers: provider = None # an explicit route the backend does not support would store fine # and silently spend vendor-native -- refuse like the agent typo # above, naming the supported set (only the explicit flag refuses; # the inherited default keeps its silent drop) elif provider not in resolved.providers: supported_providers = ', '.join(resolved.providers) or 'none' raise ValueError( f'Unsupported provider: {provider!r} (supported: {supported_providers})' ) # inherit the parent's preference config when requested -- explicit # flags win, values land in the child's config as a spawn-time # snapshot, and budget-class keys (costs, iters, depth/children, # run timeout) never inherit; a null parent value stays null (the # loop defaults at read time) if inherit and 'config' in inherit: if model is None: model = parent.config.get('model') if effort is None: effort = parent.config.get('effort') if sync is None: sync = parent.config.get('sync') if detached is None: detached = parent.config.get('detached') if iter_timeout is None: iter_timeout = parent.config.get('iter_timeout') if step_timeout is None: step_timeout = parent.config.get('step_timeout') if step_retries is None: step_retries = parent.config.get('step_retries') if step_retry_backoff is None: step_retry_backoff = parent.config.get('step_retry_backoff') if wait is None: wait = parent.config.get('wait') # sleep and interval are rival pacing keys (the loop rejects # both set): inherit them only when the spawn sets neither if sleep is None and interval is None: sleep = parent.config.get('sleep') interval = parent.config.get('interval') # the child records the tree's root (inherited from the parent) so any # node can resolve the central database from its own config root = parent.config.get('root') # default the display title to the de-slugged node name if title is None: title = fractal.util.name_to_title(name) # build arguments (name and path are positional) args = [name, f'{self._root}'] args.append(f'--title={title}') args.append(f'--parent={parent.branch}') args.append(f'--root={root}') # a non-root path selects the child's sub-project (default: inherit); a # .worktrees/ path is the cwd-in-a-worktree case above -- inherit too if path is not None and path != '.': parts = pathlib.Path(path).parts if parts[0] != WORKTREES_FOLDER: args.append(f'--project={path}') for scope_root in scope or []: args.append(f'--scope={scope_root}') if base: args.append(f'--base={base}') if meta: args.append(f'--meta={meta}') if inherit: joined = ','.join(inherit) args.append(f'--inherit={joined}') if agent: args.append(f'--agent={agent}') if provider: args.append(f'--provider={provider}') if model: args.append(f'--model={model}') if effort: args.append(f'--effort={effort}') if max_iters is not None: args.append(f'--max-iters={max_iters}') if max_depth is not None: args.append(f'--max-depth={max_depth}') if max_children is not None: args.append(f'--max-children={max_children}') if max_descendants is not None: args.append(f'--max-descendants={max_descendants}') if timeout is not None: args.append(f'--timeout={timeout}') if iter_timeout is not None: args.append(f'--iter-timeout={iter_timeout}') if step_timeout is not None: args.append(f'--step-timeout={step_timeout}') if step_retries is not None: args.append(f'--step-retries={step_retries}') if step_retry_backoff is not None: args.append(f'--step-retry-backoff={step_retry_backoff}') if interval is not None: args.append(f'--interval={interval}') if sleep is not None: args.append(f'--sleep={sleep}') if wait is not None: args.append(f'--wait={wait}') if max_cost is not None: args.append(f'--max-cost={max_cost}') if max_iter_cost is not None: args.append(f'--max-iter-cost={max_iter_cost}') if max_step_cost is not None: args.append(f'--max-step-cost={max_step_cost}') if reserve_budget is not None: args.append(f'--reserve-budget={reserve_budget}') if sync is True: args.append('--sync') if sync is False: args.append('--no-sync') if detached is True: args.append('--detached') if detached is False: args.append('--no-detached') if local: args.append('--local') if blind: args.append('--blind') if reset: args.append('--reset') # ensure git excludes self._git_exclude() # serialize concurrent child inits -- git worktree add is not parallel-safe with worktree.lock(self.repo_dir): # refuse to spawn into a paused subtree -- the pause latch admits # no new work until resume if latched := parent.pause_latched(): raise RuntimeError( f'Cannot spawn under a paused node ({latched}). Resume it first.' ) # enforce the live subtree/budget caps under the lock, off a fresh # re-read of live descendants -- only now (serialized) is the count # authoritative, so concurrent fan-out can't each pass before any of # them registers its child and blows past the cap parent._enforce_spawn_limits(child_max_cost=max_cost) # locate child worktree child_branch = f'{parent.branch}.{name}' child_worktree_dir = fractal.util.git.find_worktree( repo_dir=self.repo_dir, branch=child_branch, ) # a case-insensitive filesystem resolves a name differing from a # sibling only by case onto the sibling's worktree dir -- init.sh # would then run every path below (node dir, init event, a # --reset's rm -rf) against the sibling's node -- so refuse when # the child's path lands on another branch's worktree if child_worktree_dir is None: child_path = self.repo_dir / WORKTREES_FOLDER / child_branch if child_path.is_dir(): worktrees = fractal.util.git.worktree_map(self.repo_dir) for branch, worktree_dir in worktrees.items(): if child_path.samefile(worktree_dir): raise ValueError( f'Node {child_branch!r} would alias existing' f' node {branch!r} on this case-insensitive' ' filesystem; use a name that differs by more' ' than letter case.' ) # refuse an implicit adopt: exiting 0 against an existing node # would leave its old config in place and silently drop the # requested caps -- reuse is explicit in this CLI, never an accident if child_worktree_dir is not None: child = self.__class__(child_worktree_dir) if child.exists(): if not reset: raise ValueError( f'Node {child_branch!r} already exists; start it with' ' `fractal node start --continue`, remove it with' ' `fractal node delete`, or pass --reset to' ' reinitialize it.' ) # reset rm -rf's the node dir, so refuse over a running or # frozen node exactly as delete/merge/retire do -- a live # loop's step files or a paused node's frozen run context # would otherwise be wiped irrecoverably child._reconcile_status() if child.status() == 'active': raise RuntimeError( 'Cannot reinitialize an active node. Stop or kill it first.' ) if child.status() == 'paused': raise RuntimeError( 'Cannot reinitialize a paused node.' ' Resume or kill it first.' ) # check for pre-existing branch cmd = ['show-ref', '--verify', f'refs/heads/{child_branch}'] pre_existing_branch = fractal.util.git.run( cmd, cwd=self.repo_dir, check=False, ) # init.sh creates the worktree before the registration below, so a # failure in between would strand a live worktree with no registry row # -- roll back, but only what *this* init created (a pre-existing # worktree means init failed on collision; leave it alone), and # never delete a reused pre-existing branch's committed history pre_existing = child_worktree_dir is not None try: # run script result = self._run_script('init.sh', *args) # seed the new node's radio (internal -- agents must not create # radios explicitly; user nodes seed theirs in _init_user) child_worktree_dir = fractal.util.git.find_worktree( repo_dir=self.repo_dir, branch=child_branch, ) if child_worktree_dir: self.__class__(child_worktree_dir).radio.init() # register child in the nodes table; log the spawn on the parent # (run lineage attaches only when it's mid-run -- an autonomous # spawn during EXECUTE -- else NULL) event_id = parent.record.event_start('spawn', metadata=child_branch) try: parent.child_add( name=name, title=title, max_cost=max_cost, max_depth=max_depth, max_children=max_children, max_descendants=max_descendants, ) except Exception: if event_id is not None: parent.record.event_end(event_id=event_id, status='failed') raise if event_id is not None: parent.record.event_end(event_id=event_id, status='completed') except Exception: if not pre_existing: created_branch = pre_existing_branch is None worktree.cleanup_failed_worktree( repo_dir=self.repo_dir, branch=child_branch, created_branch=created_branch, ) raise # surface the summary + any notices, but drop the per-artifact # "Created ..." progress lines that flood logs under wide fan-out # (errors don't come back through stdout here -- a failed init raises); # success-path warnings ride stderr and would vanish with the # CompletedProcess, so they append below the summary summary = '\n'.join( line for line in result.stdout.strip().splitlines() if not line.startswith('Created ') ) notices = result.stderr.strip() return f'{summary}\n{notices}' if notices else summary
def _init_user( self: Node, path: Optional[PathLike] = None, *, agent: Optional[str] = None, provider: Optional[str] = None, ) -> str: """Initialize a user node at the repo root or a sub-project. Creates a lightweight ``<project>/.fractal/<branch>/`` data directory with database and radio only (no steps, hooks, skills, or scripts). ``self._root`` must be the worktree root; ``path`` is its project-relative path (``.`` for the repo root, ``app`` for a monorepo sub-project). Descendants inherit the project. Args: path: Project path relative to the repo root. Defaults to ``self._root`` relative to the git root. agent: Default agent command stored on the user node; spawned nodes inherit it when they omit ``--agent``. provider: Default provider route stored on the user node; spawned nodes inherit it when they omit ``--provider``. Returns: Confirmation message. """ from .agent import command_base, resolve # alias branch branch = self.branch # a detached checkout (a tag clone, CI, mid-bisect) resolves to the # literal 'HEAD' -- a name git reserves, so it can only mean detachment # -- and a tree anchored on that pseudo-ref re-resolves to whatever is # checked out later, orphaning the registry and stranding the baseline if branch == 'HEAD': raise ValueError( 'Cannot initialize a user node on a detached HEAD.' " Check out a branch first ('git switch -c <branch>')." ) # reject a slash branch up front -- every per-branch artifact (the # .project cache entry, the .fractal/<branch> data dir, the scripts' # reads of both) keys on the branch as a single path component, so a # 'feat/x'-style branch fails here before any partial init is written if '/' in branch: raise ValueError( f'Cannot initialize a user node on branch {branch!r}:' " branch names containing '/' are not supported -- switch" ' to a slash-free branch and re-run init.' ) # default the path to self._root relative to the repo root; coerce to a # str so it serializes cleanly into config.json and the .project cache if path is None: path = str(self._root.relative_to(self.repo_dir)) else: path = str(path) # reject initializing inside a worktree (path under .worktrees/) parts = pathlib.Path(path).parts if parts and parts[0] == WORKTREES_FOLDER: raise ValueError( 'Cannot initialize a user node inside a worktree.' ' Run from the repo root or a sub-project folder.' ) # enforce one fractal per branch -- a branch maps to a single project project_dir = self.repo_dir / WORKTREES_FOLDER / PROJECT_FOLDER project_file = project_dir / branch if project_file.exists(): existing = project_file.read_text(encoding='utf-8').strip() if existing != path: raise ValueError( f'A fractal already exists on branch {branch!r} for project' f' {existing!r}; one branch maps to a single project --' ' use a separate branch.' ) # derive the project name from the repo dir (dashes -> _, validated as an # ASCII identifier) up front so a bad dir name fails before any partial # init is written; it doubles as the wiki name wiki_name = worktree.derive_project_name(self.repo_dir) # resolve the default agent against the registry up front, for the # same reason -- a typo'd name would store fine, every spawn would # inherit it, and each start's loop would die on a vanishing tmux # pane with the registry error unread; the node dir (the central # DB's future home, derivable pre-init) is consulted for a hook # file when present -- a fresh init has none, so built-ins gate if agent is not None: base_word = command_base(agent) resolve(base_word, root=self.node_dir) # idempotent: an existing user node is not clobbered, but a partial # prior init (config.json written before db/radio/wiki) is repaired # on re-run -- db.init and radio.init are both idempotent if self.is_user: if agent is not None: self.config.set('agent', agent) if provider is not None: self.config.set('provider', provider) # repair a stranded DB/radio: config.json marks the node a user before # db/radio are seeded, so a crash between them leaves a valid-looking # config over an unseeded tree until re-run re-seeds them self.db.init() self.radio.init() created = worktree.ensure_project_wiki( worktree=self._root, repo_dir=self.repo_dir, path=path, name=wiki_name, ) # re-check host hooks for the formatter lanes (informational) worktree.verify_hook_formatters(self.repo_dir) message = f'User node already initialized on branch {branch!r}.' if agent is not None: message += f' Updated default agent to {agent}.' if provider is not None: message += f' Updated default provider to {provider}.' if created: message += ' Re-created the missing project wiki.' return message # refuse if another active fractal on this machine shares this repo's # tmux namespace: node sessions and `node kill` resolve by the global # name '<repo-basename> (<branch>)', so two fractals under one basename # collide and a kill can cross-fire onto the other tree -- tmux names # carry no repo path, but this fractal does not exist yet, so any live # session under our basename is necessarily a different repo's repo_name = self.repo_dir.name.replace('.', '-').replace(':', '-') sessions = fractal.util.tmux.probe() if sessions is not None: prefix = f'{repo_name} (' clash = next((name for name in sessions if name.startswith(prefix)), None) if clash is not None: raise RuntimeError( f'Another active fractal already uses the tmux name' f' {repo_name!r} (session {clash!r}). Two fractals sharing a' f' repository basename collide on node sessions and' f' `node kill` -- rename this repository directory or stop' f' that fractal first.' ) # write the project cache first so node_dir resolves under <project>/ worktree.set_project_path(self.repo_dir, branch, path) # create node directory (under <repo_dir>/<project>/.fractal/<branch>) node_dir = self.node_dir node_dir.mkdir(parents=True, exist_ok=True) # write config (the 'user' flag marks node identity, not lifecycle; # 'root' anchors the central database for the whole tree) config = { 'user': True, 'project': path, 'root': branch, } if agent is not None: config['agent'] = agent if provider is not None: config['provider'] = provider config_path = node_dir / CONFIG_FILE text = json.dumps(config, indent=2) fractal.util.filesystem.write_atomic(config_path, text + '\n') # resolve the seed and wiki paths (sub-project nodes nest under <project>/) if path == '.': seed, wiki = FRACTAL_FOLDER, 'wiki' else: seed, wiki = f'{path}/{FRACTAL_FOLDER}', f'{path}/wiki' # ensure git excludes -- a fresh tree is untracked by definition (there # is no exclude block to probe yet), so the seed-dir ignore is written # outright; `fractal track` is the only opt-in worktree.exclude_update(self.repo_dir, track=False, seed_dir=f'{seed}/{branch}') # initialize database and radio self.db.init() self.radio.init() # initialize the project wiki if it doesn't exist created = worktree.ensure_project_wiki( worktree=self._root, repo_dir=self.repo_dir, path=path, name=wiki_name, ) # check host hooks for the formatter lanes (informational) worktree.verify_hook_formatters(self.repo_dir) # report what landed and the baseline commit that must follow -- a # node worktree can only branch from a committed tree if path == '.': headline = f'Initialized user node on branch {branch}' else: headline = f'Initialized user node on branch {branch} (project {path!r})' summary = f'Created {seed}/{branch}/ (config, database, radio)' if created: summary += f' and the project wiki at {wiki}/' next_step = 'Next: commit the baseline: fractal commit "<message>" --init' return f'{headline}\n{summary}\n{next_step}' def _git_exclude(self: Node) -> None: """Write fractal's ignore patterns into the repo-local ``info/exclude``. Resolves the user node's seed-dir prepend, then delegates the marker-delimited block rewrite to :func:`worktree.exclude_update`. """ # prepend the user node's own seed dir so the top-level branch ignores it; # child seeds (.fractal/<branch>.<child>) stay tracked so meta and merge-up # keep working -- `fractal track` opts the top-level branch back in; skip # when the repo has no commit yet (no branch to resolve a node from) branch = fractal.util.git.branch(self._root, check=False) user = None if branch: for ancestor in self._self_and_ancestors(): if ancestor.is_user: user = ancestor break track = False seed_dir = None if user is not None: project = user.config.get('project', '.') if project == '.': seed = FRACTAL_FOLDER else: seed = f'{project}/{FRACTAL_FOLDER}' seed_dir = f'{seed}/{user.branch}' # tracking truth lives in the exclude block itself, so the rewrite # preserves the current `fractal track`/`untrack` choice track = worktree.exclude_tracks(self.repo_dir, seed_dir) worktree.exclude_update(self.repo_dir, track=track, seed_dir=seed_dir)
[docs] def start( self: Node, *, continue_run: bool = False, clean: bool = False, max_cost: Optional[float] = None, ) -> str: """Launch the node in a tmux session. Creates a tmux session (or window if already inside tmux) that runs the iteration loop. All run parameters are read from ``config.json`` (set at init or edited before launch); ``continue_run`` (with its optional ``max_cost`` retune) is the only launch-time action. A continue re-enters the unsettled pool, so it re-checks the width/descendant gates (:meth:`_enforce_rearm_limits`) and re-arms to ``idle`` under the ``.worktrees`` flock; the loop stamps ``active`` at boot just as a fresh start does. A budget-ended run (the run row's ``exited``/0 landing) never re-arms silently: a bare continue refuses, naming the spent and armed figures, and an explicit ``max_cost`` is applied through the parent's retune (:meth:`child_retune`) before the launch. Every successful launch logs a completed ``start`` event (metadata ``continue`` on a continue), so the event log carries the node's restart chain with the actor column answering who re-armed. Args: continue_run: Continue a stopped/exited node. clean: Acknowledge that the continue's worktree restore discards uncommitted project files (required when any exist). max_cost: New cost cap in USD for the continued run, applied through the parent's retune; required when the last run ended on its cost budget. Returns: Script output, prefixed by any launch-time notices (the retune echo, the continue-from-killed countermand). """ from .agent import command_base, resolve # reject user nodes if self.is_user: raise RuntimeError('Cannot start a user node.') # reconcile a crashed-but-active node so --continue isn't wedged self._reconcile_status() # validate status current_status = self.status() if current_status == 'retired': raise RuntimeError('Cannot start a retired node. Unretire it first.') if current_status == 'paused': raise RuntimeError('Cannot start a paused node. Resume it first.') # statuses a continue may re-arm from continuable = ('completed', 'stopped', 'exited', 'killed') if continue_run: if current_status not in continuable: raise RuntimeError(f'Cannot continue from status: {current_status!r}') else: if current_status != 'idle': raise RuntimeError( f'Cannot start from status: {current_status!r}.' f' Use --continue to restart.' ) # refuse to launch into a paused subtree -- the pause latch admits no # new work until resume if latched := self.pause_latched(): raise RuntimeError( f'Cannot start under a paused node ({latched}). Resume it first.' ) # refuse a foreign tmux-session collision on a fresh start: a # first-start node has no session of its own, so a live session under # its name belongs to another fractal sharing this repo's basename, # and `node kill`/attach would resolve ambiguously across both; a # continue is exempt -- its own session may legitimately be present, # and start.sh's exact-name check still backstops that path if not continue_run: session = self.tmux_session sessions = fractal.util.tmux.probe() if sessions is not None and session in sessions: raise RuntimeError( f'Cannot start: the tmux session {session!r} is already' f' active for another fractal (a repository sharing this' f' basename and node name). Stop it, or rename one' f' repository directory.' ) # launch-time notices (the retune echo, the kill countermand) ride # the returned confirmation -- core never prints; the CLI echoes it notices: list[str] = [] # the retune's {key: (old, new)} priors -- the refusal and failure # paths below roll a persisted retune back through them, so a refused # or failed continue never keeps a --max-cost the caller passed changes: dict[str, tuple[Any, Any]] = {} # a budget-ended run (the exited/0 landing) never re-arms silently -- # continuing spends real money the caller must re-authorize, so a bare # continue refuses with the recorded figures, and an explicit new cap # applies through the parent's retune (reserve re-derivation included) # before the config reads below see it if continue_run: rows = self.record.runs(limit=1) last_run = rows[0] if rows else None run_exited = (last_run is not None) and (last_run['status'] == 'exited') budget_ended = run_exited and (last_run['exit_code'] == 0) if budget_ended and max_cost is None: spent = self.cost.spent(run_id=last_run['run_id']) # the stamped arm survives later retunes; a NULL stamp (the # run started uncapped) falls back to the config cap -- an # uncapped node landed by an ancestor's cascade leaves both # empty, so it has no armed figure to name armed = last_run['max_cost'] if armed is None: armed = self.config.get('max_cost') if armed is not None: figures = f'spent ${spent:.4f} of ${armed} armed' else: figures = f'spent ${spent:.4f}, no cap armed' # a cascade landing carries the recomposed abort reason, so # name whose budget cut the run rather than claiming its own ended = 'ended on its cost budget' if (last_run['metadata'] or '').startswith('ancestor budget abort:'): ended = "was cut by an ancestor's cost budget" raise RuntimeError( f'Cannot continue: the last run {ended} ({figures}).' f' Pass --max-cost=<usd> to arm the next run explicitly.' ) # a continue's worktree restore (the loop's checkout/clean) discards # uncommitted project files -- refuse without the explicit --clean # acknowledgment; node-dir paths are exempt (the restore commits # them, config.json included); -uall is load-bearing: without it git # collapses an untracked node dir to a bare `?? .fractal/` entry the # prefix filter would miss; checked before the retune below so a # refused continue never persists a --max-cost the caller passed if not clean: node_prefix = f'{self.node_dir.relative_to(self._root)}/' # -z emits NUL-terminated entries with verbatim paths -- the # line form C-quotes spaces and non-ASCII, defeating the prefix # exemption -- and needs the unstripped run_bytes: strip would # eat the first entry's leading status space, shifting offsets porcelain = fractal.util.git.run_bytes( ['status', '--porcelain', '-z', '--untracked-files=all'], cwd=self._root, ) # fail closed: a status failure must not read as a clean tree if porcelain is None: raise RuntimeError('Cannot continue: git status failed.') decoded = porcelain.decode('utf-8', errors='replace') # each field is `XY <path>`; a rename/copy appends the original # path as a bare extra field -- consume it with its entry fields = iter(decoded.split('\0')) doomed = [] for field in fields: if not field: continue code, path = field[:2], field[3:] if 'R' in code or 'C' in code: next(fields, None) if not path.startswith(node_prefix): doomed.append(path) if doomed: listed = '\n'.join(f' {path}' for path in doomed) raise RuntimeError( 'Cannot continue: the worktree restore would discard' f' uncommitted changes:\n{listed}\n' 'Commit them first, or pass --clean to discard them.' ) if max_cost is not None: # echo the retune old -> new like `node update` -- a silent # launch-time retune is indistinguishable from a dropped one *_, name = self.branch.rsplit('.', 1) # the retune records on the parent; an orphaned parent (worktree # pruned out of band) can't take it, so refuse cleanly rather # than dereference None parent = self.parent if parent is None: raise RuntimeError( f'Cannot retune {self.branch} on start: its parent' ' worktree is gone, so --max-cost cannot be recorded.' ) changes = parent.child_retune(name, max_cost=max_cost) for key, values in changes.items(): prior, value = values if prior is None: prior = 'unset' notices.append(f'{key}: {prior} -> {value}') # a non-positive ceiling launches straight into a degenerate $0 finish, so # reject it; a missing ceiling means uncapped -- allowed but warned loudly # since spend is then untracked, bounded only by --max-iters/--timeout (a # token-priced agent with no priced model can only run this way -- a # cost cap would force it onto a priced model) max_cost = self.config.get('max_cost') if max_cost is not None and max_cost <= 0: raise RuntimeError( 'Cannot start with a non-positive max_cost;' ' set a positive cap with `fractal node update --max-cost=<usd>`' ' or unset it to run uncapped.' ) if max_cost is None: self.log( message=f'Warning: starting {self.branch} without a cost cap;' ' spend is untracked and bounded only by --max-iters/--timeout.', level=logging.WARNING, ) # re-validate the rest of the config the loop reads -- the documented # steering path edits config.json directly, bypassing the init/update # setters' checks; a bad duration or cost ordering would otherwise abort # the loop after start prints "Started", wedging the node idle with the # only error on a dying tmux pane self.config.validate() # resolve the stored agent against the registry the way the loop's # boot does (the loop reads this node's own config key, never the # ancestor walk) -- the same steering path can typo or drop it, and # the loop's registry error would land on the same dying pane agent = self.config.get('agent') if not agent: raise ValueError('No agent configured; set --agent at node init.') base_word = command_base(agent) resolve(base_word, root=self.db.path.parent) # a blind node reads no channels -- sweep any subscriptions that # landed between init (which seeds none) and this launch if self.config.get('blind'): self.db.delete('subs', where={'node': self.branch}) # build arguments args = [f'{self._root}'] if continue_run: args.append('--continue') # ensure git excludes self._git_exclude() if continue_run: # a continue from killed re-arms over another actor's explicit # kill, so surface the recorded countermand first -- the latest # *completed* kill event's attribution (refused and failed # kills also ride the event stream, as failed rows that must # not shadow the real kill) if current_status == 'killed': where = {'node': self.branch, 'event': 'kill', 'status': 'completed'} rows = self.db.read('events', where=where, limit=1) if rows: countermand = rows[0]['metadata'] notices.append(f'Previous run {countermand}') try: # gate re-check and idle re-arm stay atomic under the # .worktrees flock (init's check+register atomicity) -- the # loop stamps active only at boot, so a post-lock re-arm # would let a concurrent gate read this node as settled and # hand its slot away with worktree.lock(self.repo_dir): # re-read under the lock -- a concurrent continue that won the # race has already re-armed this node out of a settled status current_status = self.status() if current_status not in continuable: raise RuntimeError( f'Cannot continue from status: {current_status!r}' ) self._enforce_rearm_limits() self.status_set('idle') # launch outside the lock -- the idle re-arm above already holds # the slot; roll a failed launch back to the settled status so # --continue stays the retry path (idle reads as never-started) try: result = self._run_script('start.sh', *args) except Exception: self.status_set(current_status) raise except Exception: # a refused gate or failed launch must not keep the retune -- # restore the priors in both stores (config.json, and the # registry row for its one table-backed key) for key, values in changes.items(): prior, _ = values self.config.set(key, prior) if 'max_cost' in changes: prior, _ = changes['max_cost'] self.db.update( data={'max_cost': prior}, table='nodes', where={'node': self.branch}, ) raise else: # run script result = self._run_script('start.sh', *args) # log the lineage only after start.sh returns, on both paths -- the # continue arm rolls a failed launch back to the settled status (a # lock-time event would survive that rollback as a phantom), so the # restart chain is exactly the ordered completed start events metadata = 'continue' if continue_run else '' event_id = self.record.event_start('start', metadata=metadata) self.record.event_end(event_id=event_id, status='completed') # prepend the launch-time notices to the script output -- one # confirmation string for the CLI to echo output = result.stdout.strip() if notices: output = '\n'.join([*notices, output]) return output
[docs] def attach(self: Node) -> None: """Attach to the node's tmux session.""" # validate status if self.status() != 'active': raise RuntimeError('Cannot attach: node is not active.') # run attach script, then attach to the tmux session (named by start.sh); # the interactive handoff bypasses _run_script: tmux owns the terminal, # so output is not captured and tmux reports its own errors self._run_script('attach.sh', f'{self._root}') # the = prefix forces an exact target match (-t resolves by prefix, # so a short name would false-match longer session names) subprocess.run(['tmux', 'attach', '-t', f'={self.tmux_session}'])
def _signal_guard( self: Node, verb: str, event: str, *, fan_out: bool = False, ) -> Optional[int]: """Gate a graceful signal verb; return the run it signals. The shared preamble of ``finish``/``finish_cancel``/``stop``/ ``pause`` (the per-node helpers run it under their ``.worktrees`` flock, so the status they read cannot settle mid-verb; the fan-out verbs also run it once before their sweep, so a non-active target refuses before any descendant is signaled): reconcile a crashed-but-active node so it hits the clear not-active guard, not the misleading no-run error, then require an active status -- an active node with no run cannot be signaled. Refusals route through :meth:`_signal_refuse`, which puts a failed event row on the record before raising (or, on fan-out, skipping). Args: verb: The verb for the refusal texts (e.g. ``'finish'``). event: Event type for the refusal record (e.g. ``'finish_cancel'``). fan_out: Refusals return ``None`` instead of raising. Returns: The current run's id, or ``None`` when a fan-out call is refused. Raises: RuntimeError: If the node is not active or has no run. """ # reconcile a crashed-but-active node so it hits the clear # not-active guard below, not the misleading no-run error self._reconcile_status() # validate status if self.status() != 'active': self._signal_refuse(verb, event, 'node is not active', fan_out=fan_out) return None # an active node with no run cannot be signaled _, _, run_id = self.record.resolve_context() if run_id is None: self._signal_refuse(verb, event, 'node has no run', fan_out=fan_out) return None return run_id def _signal_refuse( self: Node, verb: str, event: str, reason: str, *, fan_out: bool = False, ) -> None: """Record a refused signal verb, then raise (or skip, on fan-out). The refusal is put on the record either way -- a ``failed`` event row whose metadata names the reason -- so a sweep that skips a settled descendant still leaves evidence of the attempt. Args: verb: The verb for the refusal text (e.g. ``'finish'``). event: Event type for the refusal record. reason: Why the verb is refused. fan_out: Return instead of raising. Raises: RuntimeError: Always, unless ``fan_out``. """ event_id = self.record.event_start(event, metadata=f'refused: {reason}') if event_id is not None: self.record.event_end(event_id=event_id, status='failed') if not fan_out: raise RuntimeError(f'Cannot {verb}: {reason}.') def _fan_out_reason(self: Node, verb: str, reason: Optional[str]) -> str: """Attribute a fanned-out reason to this node. The signal row a descendant records must name this node as the source -- an unattributed parent budget-reserve finish would read as the child's own boundary mis-fire. Args: verb: The fanning-out verb (e.g. ``'finish'``). reason: The caller's reason, if any. Returns: The attributed reason for the descendants' signal rows. """ if reason: return f'{reason} (via {verb} of {self.branch})' return f'via {verb} of {self.branch}'
[docs] def finish(self: Node, reason: Optional[str] = None) -> str: """Finish the node and its active descendants (children first). Each loop stops after its current iteration. On the user (root) node the fan-out covers the whole tree with no self signal -- the user node has no loop of its own. Args: reason: Optional reason for finishing. Returns: Confirmation message. """ # a user-node finish is the tree-wide broadcast (budget cascades # land here): no self guard or signal, just the descendant sweep if self.is_user: propagated = self._fan_out_reason('finish', reason) finished_count = 0 for _, descendant in self._live_descendants(status='active'): descendant._finish(propagated, fan_out=True) finished_count += 1 if finished_count == 0: return 'No active nodes to finish.' suffix = 's' if finished_count != 1 else '' result = ( f'Finish signal sent to {finished_count} node{suffix}' ' (each will stop after its current iteration)' ) if reason: result += f': {reason}' return result # gate on self before the sweep -- a non-active target must refuse # with no descendant signaled; the per-node helper re-checks under # the flock for the race window self._signal_guard('finish', 'finish') propagated = self._fan_out_reason('finish', reason) # finish descendants first, then self -- each helper guards its own # node under the flock, so a descendant that settled mid-sweep is # skipped (refusal recorded) while the self-act refusal raises for _, descendant in self._live_descendants(status='active'): descendant._finish(propagated, fan_out=True) self._finish(reason) # build confirmation result = 'Finish signal sent (will stop after current iteration)' if reason: result += f': {reason}' return result
def _finish( self: Node, reason: Optional[str] = None, *, fan_out: bool = False, ) -> None: """Send the ``finish`` signal to this node only. The guard re-read and the event/signal writes stay atomic under the ``.worktrees`` flock; ``finish.sh`` runs outside the lock. """ with worktree.lock(self.repo_dir): # re-read under the lock -- a rival verb or the settling loop # may have moved this node since the caller enumerated it if self._signal_guard('finish', 'finish', fan_out=fan_out) is None: return event_id = self.record.event_start('finish', metadata=reason or '') self.record.signal_set('finish', reason or '') self._run_script('finish.sh', f'{self._root}') self.record.event_end(event_id=event_id, status='completed')
[docs] def finish_cancel(self: Node, reason: Optional[str] = None) -> str: """Withdraw this node's pending ``finish`` signal. Deletes the signal rows for the current run, so the loop's boundary checks no longer see a pending finish. Descendants are untouched: a subtree ``finish`` fans out, but its cancel must not -- finishing is a descendant's normal completion path, not something to withdraw. Args: reason: Optional reason for the cancellation. Returns: Confirmation message. """ run_id = self._signal_guard('cancel finish', 'finish_cancel') if self.record.signal_get('finish', run_id=run_id) is None: raise RuntimeError('Cannot cancel finish: no finish signal is set.') # delete the pending rows, bracketed by an audit event -- one of the # deliberate withdrawals catalogued on signal_clear event_id = self.record.event_start('finish_cancel', metadata=reason or '') self.db.delete( 'signals', where={'node': self.branch, 'run_id': run_id, 'signal': 'finish'}, ) self.record.event_end(event_id=event_id, status='completed') # build confirmation result = 'Finish signal cancelled (loop continues)' if reason: result += f': {reason}' return result
[docs] def stop(self: Node, reason: Optional[str] = None) -> str: """Stop the node and its active descendants (children first). Each loop stops after its current step. On the user (root) node the fan-out covers the whole tree with no self signal -- the user node has no loop of its own. Args: reason: Optional reason for stopping. Returns: Confirmation message. """ # a user-node stop is the tree-wide broadcast: no self guard or # signal, just the descendant sweep if self.is_user: propagated = self._fan_out_reason('stop', reason) stopped_count = 0 for _, descendant in self._live_descendants(status='active'): descendant._stop(propagated, fan_out=True) stopped_count += 1 if stopped_count == 0: return 'No active nodes to stop.' suffix = 's' if stopped_count != 1 else '' result = ( f'Stop signal sent to {stopped_count} node{suffix}' ' (each will stop after its current step)' ) if reason: result += f': {reason}' return result # gate on self before the sweep -- a non-active target must refuse # with no descendant signaled; the per-node helper re-checks under # the flock for the race window self._signal_guard('stop', 'stop') propagated = self._fan_out_reason('stop', reason) # stop descendants first, then self -- each helper guards its own # node under the flock, so a descendant that settled mid-sweep is # skipped (refusal recorded) while the self-act refusal raises for _, descendant in self._live_descendants(status='active'): descendant._stop(propagated, fan_out=True) self._stop(reason) # build confirmation result = 'Stop signal sent (will stop after current step)' if reason: result += f': {reason}' return result
def _stop( self: Node, reason: Optional[str] = None, *, fan_out: bool = False, ) -> None: """Send the ``stop`` signal to this node only. The guard re-read and the event/signal writes stay atomic under the ``.worktrees`` flock; ``stop.sh`` runs outside the lock. """ with worktree.lock(self.repo_dir): # re-read under the lock -- a rival verb or the settling loop # may have moved this node since the caller enumerated it if self._signal_guard('stop', 'stop', fan_out=fan_out) is None: return event_id = self.record.event_start('stop', metadata=reason or '') self.record.signal_set('stop', reason or '') self._run_script('stop.sh', f'{self._root}') self.record.event_end(event_id=event_id, status='completed') def _killable(self: Node, current: str) -> bool: """Return whether ``current`` admits a kill of this node. ``active`` and ``paused`` always do. ``idle`` does only while the node's tmux session is live -- a boot in flight (``start.sh`` created the session, the loop's preflight has not stamped ``active`` yet), which a status read alone cannot tell from a never-started node. An inconclusive probe reads as no session: the reap keys off proof a loop lives, never off ignorance. Args: current: The node status the caller read. Returns: Whether a kill may proceed. """ if current in ('active', 'paused'): return True return current == 'idle' and bool(self._tmux_session_exists())
[docs] def kill(self: Node, reason: Optional[str] = None) -> str: """Kill the node and its active or paused descendants (children first). Reaps each tmux session and marks its active rows ``killed``. Paused nodes are killable -- the escape hatch for a parked subtree; with no loop alive the kill is pure bookkeeping (``kill.sh`` no-ops and the open rows close ``killed``). A booting node -- session up, ``active`` not yet stamped -- is killable too (:meth:`_killable`), so a spawn in flight when the kill lands is reaped, not skipped. Args: reason: Optional reason for killing. Returns: Confirmation message. """ # gate on self before the sweep -- a settled target must refuse with # no descendant reaped; the self-kill below re-checks under the flock # for the race window current = self.status() if not self._killable(current): self._signal_refuse( verb='kill', event='kill', reason=f'node is not active or paused (status: {current})', ) propagated = self._fan_out_reason('kill', reason) # reap descendants first (best-effort), then self # NOTE: re-enumerate to a fixpoint -- a descendant that registered # mid-sweep (a spawn already in flight when the kill signal landed) # would escape a single pass, so re-read until no fresh live # descendant appears; _killable also admits a booting descendant # ('idle' until preflight stamps 'active'), which a status-only # filter would exit over; seen bounds the loop (each branch is # reaped at most once and the subtree is finite), so it converges # even if a stuck child never settles; each helper guards its own # node under the flock, so a descendant that settled mid-sweep is # skipped (refusal recorded) while the self-act refusal raises seen: set[str] = set() while True: fresh = [ (row, descendant) for row, descendant in self._live_descendants() if descendant._killable(row['status']) and row['node'] not in seen ] if not fresh: break for row, descendant in fresh: seen.add(row['node']) try: descendant._kill(propagated, fan_out=True) except Exception: # best-effort: surface the failure but keep reaping the # rest of the subtree -- a stuck child must not leave its # siblings or the parent running self.log( message=f'Warning: failed to kill {descendant._root}', level=logging.WARNING, ) return self._kill(reason)
def _kill( self: Node, reason: Optional[str] = None, *, fan_out: bool = False, ) -> str: """Kill this node only and mark its active rows ``killed``. The status re-read and the kill's event/signal writes stay atomic under the ``.worktrees`` flock; ``kill.sh`` and the row marking run outside the lock. The attribution -- ``killed by <actor>``, with the reason appended when one is given -- lands identically on the kill event, the ``kill`` signal, and the killed run row, so every surface answers who ended the run. """ # compose the attribution: who killed, and why when a reason rides caller = self.resolve_caller() actor = caller.branch if caller else 'operator' label = f'killed by {actor}: {reason}' if reason else f'killed by {actor}' with worktree.lock(self.repo_dir): # re-read under the lock -- a rival verb or the settling loop # may have moved this node since the caller enumerated it current = self.status() if not self._killable(current): self._signal_refuse( verb='kill', event='kill', reason=f'node is not active or paused (status: {current})', fan_out=fan_out, ) return '' # set signal and log event; the tmux kill runs outside the lock event_id = self.record.event_start('kill', metadata=label) self.record.signal_set('kill', label) # vet the recorded process groups before the script's fallback reap: # kill.sh gates on liveness alone, and a recycled id (the OS re-issued # a dead group's id to an unrelated same-user group) answers that # probe -- drop any record whose live group is not the one it named # (see _recorded_group), so the fallback only ever sees vetted files for name in (PGID_FILE, STEP_PGID_FILE): pgid_file = self.node_dir / name try: recorded_at = pgid_file.stat().st_mtime pgid = int(pgid_file.read_text(encoding='utf-8').strip()) except (FileNotFoundError, ValueError): continue if not _recorded_group(pgid, recorded_at): pgid_file.unlink(missing_ok=True) try: result = self._run_script('kill.sh', f'{self._root}') except Exception: if self.exists(): self._mark_active_killed(skip=event_id, metadata=label) self.status_set('killed') if event_id is not None: self.record.event_end(event_id=event_id, status='failed') raise # mark active rows as killed; a loop that landed its own terminal # between the signal and the reap keeps it -- the row marking is # already fenced, so the status stamp must not overwrite either if self.exists(): self._mark_active_killed(skip=event_id, metadata=label) if self.status() in ('active', 'paused'): self.status_set('killed') if event_id is not None: self.record.event_end(event_id=event_id, status='completed') return result.stdout.strip()
[docs] def pause(self: Node, reason: Optional[str] = None) -> str: """Pause the node and its active descendants (parent-first). Signals ``pause`` then aborts each node's in-flight agent invocation (``pause.sh`` reaps the recorded step process group), so every loop reclassifies the abort and parks: it exits with status ``paused``, leaving its run and iteration rows open for :meth:`resume` to adopt. Fans out parent-first -- the inverse of every other signal -- so a parent parked before its children can never drain-complete over them, and re-enumerates until the subtree is fully signaled, catching children spawned mid-fan-out (``init``/``start`` refuse new work under the pause latch). On the user (root) node, which has no loop of its own, the fan-out covers the whole tree with no self signal -- the tree-wide brake -- and latches the root first, so even a depth-1 start racing the sweep refuses until resume. Args: reason: Optional reason for pausing. Returns: Confirmation message. """ # a tree-wide pause latches the root before fanning out, so a start # or spawn racing the sweep (even at depth 1, where no pausable # ancestor exists) refuses instead of slipping in unfrozen if self.is_user: self._tree_latch_file.write_text('paused\n', encoding='utf-8') propagated = self._fan_out_reason('pause', reason) # pause self first, then sweep descendants shallowest first until # every active one carries a pause signal -- the re-enumeration # catches children spawned mid-fan-out; best-effort per node # (mirrors kill), each attempted exactly once paused_count = 0 if not self.is_user: self._pause(reason) paused_count += 1 attempted = set() while True: pending = [ (row['node'], descendant) for row, descendant in self._live_descendants(status='active') if row['node'] not in attempted and descendant.record.signal_get('pause') is None ] if not pending: break pending.sort(key=lambda entry: entry[0].count('.')) for branch, descendant in pending: attempted.add(branch) try: if descendant._pause(propagated, fan_out=True): paused_count += 1 except Exception: self.log( message=f'Warning: failed to pause {descendant._root}', level=logging.WARNING, ) # build confirmation if paused_count == 0: return 'No active nodes to pause (tree latched until resume).' suffix = 's' if paused_count != 1 else '' result = ( f'Pause signal sent to {paused_count} node{suffix}' ' (in-flight agents aborted; loops park paused)' ) if reason: result += f': {reason}' return result
def _pause( self: Node, reason: Optional[str] = None, *, fan_out: bool = False, ) -> bool: """Signal ``pause`` and abort this node's in-flight agent invocation. The guard re-read and the event/signal writes stay atomic under the ``.worktrees`` flock; ``pause.sh`` runs outside the lock. Reports whether the signal landed, so the sweep's count skips a refused descendant. """ with worktree.lock(self.repo_dir): # re-read under the lock -- a rival verb or the settling loop # may have moved this node since the caller enumerated it if self._signal_guard('pause', 'pause', fan_out=fan_out) is None: return False # the signal lands before the abort so the loop reclassifies # the killed step as paused, never as a failed step (a failure # would force-commit and open a fresh session) event_id = self.record.event_start('pause', metadata=reason or '') self.record.signal_set('pause', reason or '') try: self._run_script('pause.sh', f'{self._root}') except Exception: # the signal is durable -- the loop still parks at its next # checkpoint -- but the failed abort must surface self.record.event_end(event_id=event_id, status='failed') raise self.record.event_end(event_id=event_id, status='completed') return True
[docs] def resume(self: Node) -> str: """Resume the node and its paused descendants (leaf-first). Relaunches each paused loop, which adopts its open run where the pause left it: same budgets and iteration count, the interrupted step re-entered (resuming the recorded agent session when one exists, re-orienting fresh otherwise), and run/iteration deadlines credited for the paused span. Leaf-first so every child reads ``active`` again before its parent's drain-waits can look. A node still parking (``active`` with a pending pause signal) gets its pause withdrawn instead -- the live loop then never parks. On the user (root) node the fan-out covers the whole tree with no self relaunch -- the tree-wide release, which also lifts the root latch. Returns: Confirmation message. """ # a tree-wide resume releases the latch first -- new starts and # spawns are legal again the moment the release begins, even when # nothing is left parked to relaunch if self.is_user: self._tree_latch_file.unlink(missing_ok=True) # a non-user node must itself be paused or still pausing; the user # node only fans out self_pausing = False if not self.is_user: current = self.status() if current == 'active': self_pausing = self.record.signal_get('pause') is not None if current != 'paused' and not self_pausing: raise RuntimeError( f'Cannot resume: node is not paused (status: {current}).' ) # refuse to resume into a paused subtree -- the pause latch # admits no new work while an ancestor is frozen, and the # resume boot skips the ancestor walk (the leaf-first fan-out's # exemption), so the verb itself must refuse; resuming the # latching node relaunches this one with it if latched := self.pause_latched(skip_self=True): raise RuntimeError( f'Cannot resume under a paused node ({latched}). Resume it first.' ) # a node still parking (active with a pending pause signal) has a live # loop that cannot be relaunched -- withdraw its pause instead, so the # loop never parks; the resume event closes the span for the credit # walk, and a loop that already read the signal parks anyway (honest: # it lands paused for the next resume to relaunch) resumed_count = 0 withdrawn = [ descendant for _, descendant in self._live_descendants(status='active') if descendant.record.signal_get('pause') is not None ] if self_pausing: withdrawn.append(self) for node in withdrawn: event_id = node.record.event_start('resume') node.record.signal_clear('pause') node.record.event_end(event_id=event_id, status='completed') resumed_count += 1 # resume parked descendants leaf-first (deepest first), then self -- # children must be running again before a parent's drain-wait can # conclude anything about them; best-effort per node (mirrors kill) pending = self._live_descendants(status='paused') pending.sort(key=lambda entry: entry[0]['node'].count('.'), reverse=True) for _, descendant in pending: try: descendant._resume() resumed_count += 1 except Exception: self.log( message=f'Warning: failed to resume {descendant._root}', level=logging.WARNING, ) if not self.is_user and not self_pausing: self._resume() resumed_count += 1 # build confirmation if resumed_count == 0: return 'No paused nodes to resume.' suffix = 's' if resumed_count != 1 else '' return ( f'Resumed {resumed_count} node{suffix}' ' (parked loops relaunched leaf-first; live pauses withdrawn)' )
def _resume(self: Node) -> None: """Relaunch this node's loop to adopt its paused run. The relaunched loop itself withdraws the run's pause signals (:meth:`Record.signal_clear` at adoption), so a bare ``--resume`` launch -- e.g. after a filesystem transplant -- self-clears too. """ # the resume event lands before the relaunch so the booting loop's # deadline credit sees the pause..resume span closed event_id = self.record.event_start('resume') try: self._run_script('resume.sh', f'{self._root}') except Exception: self.record.event_end(event_id=event_id, status='failed') raise self.record.event_end(event_id=event_id, status='completed')
[docs] def pause_latched( self: Node, *, tree_only: bool = False, skip_self: bool = False, ) -> Optional[str]: """Return the branch of the nearest paused or pausing node at-or-above. The pause latch: a paused subtree admits no new work, so ``init`` (spawn), ``start``, and a targeted :meth:`resume` refuse -- and a booting loop parks -- while any ancestor (or the node itself) is ``paused`` or still ``active`` with a pending ``pause`` signal, or while the tree-wide latch (a user-node pause) is set. Walks by name so a pruned intermediate never hides a paused ancestor. Args: tree_only: Check only the tree-wide latch, skipping the ancestor walk -- the resume-boot variant, where paused ancestors are the leaf-first fan-out's normal state but a NEW tree-wide brake must still park the boot. skip_self: Skip the node itself in the walk -- the resume-verb variant, where the target is legally paused but a frozen ancestor (or the tree-wide brake) must still refuse the relaunch. Returns: The latching node's branch (the root branch for the tree-wide latch), or ``None`` when the path is clear. """ if not tree_only: for node in self._self_and_ancestors(): if node.is_user: continue if skip_self and node is self: continue status = node.status() if status == 'paused': return node.branch if status == 'active' and node.record.signal_get('pause') is not None: return node.branch if self._tree_latch_file.exists(): return self.config.get('root') return None
[docs] def merge(self: Node) -> tuple[str, str]: """Squash-merge the node's branch into its merge target. ``merge.sh`` resolves the target -- the node's configured ``base`` if set (e.g. a meta node merging back into the node it optimizes), else the dotted parent (the branch minus its last segment) -- runs the squash in the target's worktree, and logs the ``merge`` event there so the record survives this node's later deletion. The full commit history is preserved on the node's branch; only a single squash commit lands on the target. Refuses while the target is active or paused -- the squash, index refresh, and recovery ``reset --hard`` all mutate the target worktree -- except from inside the target's own loop, which merges its settled children as part of its normal iteration. Returns: Tuple of script output and collected stderr notices (e.g. a skipped merge-base advance). """ # reject user nodes if self.is_user: raise RuntimeError('Cannot merge a user node.') # reconcile a crashed-but-active node so it can be merged self._reconcile_status() # validate status current = self.status() if current == 'active': raise RuntimeError('Cannot merge an active node. Stop or kill it first.') if current == 'paused': raise RuntimeError('Cannot merge a paused node. Resume or kill it first.') # the target must be settled too: merge.sh squashes, refreshes # indexes, and commits inside the target worktree, and its failure # paths reset --hard -- racing a live target loop would absorb or # destroy that loop's fresh work; the target's own loop is exempt # (a node merging a settled child is single-actor: the loop is # blocked on the very agent step running the merge); resolve the # target as merge.sh does (base config, else the dotted parent), # reconciled so a crashed-but-active target never wedges the merge, # and leave a target that does not resolve to merge.sh's own errors target_branch = self.config.get('base') or '' if not target_branch and '.' in self.branch: target_branch, *_ = self.branch.rsplit('.', 1) target_worktree = fractal.util.git.find_worktree(self.repo_dir, target_branch) if target_worktree is not None: target = self.__class__(target_worktree) if not target._is_own_loop(): target._reconcile_status() target_status = target.status() if target_status == 'active': raise RuntimeError( f'Cannot merge into active target {target_branch}.' ' Stop or kill it first.' ) if target_status == 'paused': raise RuntimeError( f'Cannot merge into paused target {target_branch}.' ' Resume or kill it first.' ) # run merge script -- merge.sh resolves the target and logs the # merge event on it (it's the single source of truth for the target) result = self._run_script('merge.sh', f'{self._root}') # success-path warnings ride stderr (e.g. a skipped merge-base # advance predicting spurious re-merge diffs) and would vanish with # the CompletedProcess -- return them beside the output return result.stdout.strip(), result.stderr.strip()
[docs] def delete(self: Node) -> tuple[str, str]: """Recursively remove the node and its whole subtree. Tears down every descendant too (deepest first), then the node itself: each live worktree via ``delete.sh`` (worktree + branch + remote), and the subtree's registry rows and subscriptions are cleared from the central database -- its history rows (runs, steps, messages, ...) persist. Refuses if the node or any descendant is active or paused -- stop, resume, or kill the subtree first. Returns: Tuple of per-node script output (deletion order) and collected stderr notices (e.g. unmerged-work warnings). """ # validate node if not self.exists(): raise RuntimeError( f'Node at {self.node_dir} was not properly' ' initialized and must be deleted manually.' ) # reject user nodes if self.is_user: raise RuntimeError('Cannot delete a user node.') # reconcile a crashed-but-active node so it can be deleted self._reconcile_status() # validate status -- the node itself must not be running if self.status() == 'active': raise RuntimeError('Cannot delete an active node. Stop or kill it first.') if self.status() == 'paused': raise RuntimeError('Cannot delete a paused node. Resume or kill it first.') # collect the subtree: self + every descendant (flat registry); # capture branch + repo dir + central db up front -- they resolve # through self._root, which is torn down below, so they must be # read before any teardown branch = self.branch repo_dir = self.repo_dir db = self.db descendant_branches = [row['node'] for row in self.child_list()] subtree_branches = [branch, *descendant_branches] # refuse if the caller stands inside any worktree in the subtree -- git # cannot remove a worktree the caller occupies cwd = pathlib.Path.cwd().resolve() for subtree_branch in subtree_branches: if subtree_branch == branch: worktree_dir = self._root.resolve() else: worktree_dir = fractal.util.git.find_worktree(repo_dir, subtree_branch) if worktree_dir: worktree_dir = worktree_dir.resolve() else: worktree_dir = None if worktree_dir and (cwd == worktree_dir or worktree_dir in cwd.parents): raise RuntimeError( 'Cannot delete the current worktree from inside it.' ' Run from the repo root or another worktree.' ) # reconcile crashed descendants so a dead child doesn't wedge the delete for _, descendant in self._live_descendants(status='active'): descendant._reconcile_status() # refuse if any descendant is still active or paused -- recursive # teardown must not yank a running node's worktree out from under it, # nor discard a paused one's frozen mid-step work for row, _ in self._live_descendants(): if row['status'] in ('active', 'paused'): raise RuntimeError( 'Cannot delete a node with an active or paused descendant.' ' Stop, resume, or kill the subtree first.' ) # pre-flight every subtree worktree for the lock delete.sh rejects (a # locked worktree can't be removed): recursive teardown is non-atomic, so # a lock found mid-tear would strand a half-deleted subtree -- check the # whole subtree up front and abort before touching anything for subtree_branch in subtree_branches: if subtree_branch == branch: worktree_dir = self._root else: found = fractal.util.git.find_worktree(repo_dir, subtree_branch) if not found: continue worktree_dir = found git_dir = fractal.util.git.run( ['rev-parse', '--absolute-git-dir'], cwd=worktree_dir, check=False, ) if git_dir and (pathlib.Path(git_dir) / 'locked').is_file(): raise RuntimeError( f'Cannot delete: worktree is locked: {worktree_dir}' f' (unlock with: git -C "{repo_dir}"' f' worktree unlock "{worktree_dir}").' ) # tear down descendants deepest first (each live worktree via delete.sh; # worktree-less registry rows are deregistered below), then the node ordered_branches = sorted( descendant_branches, key=lambda x: x.count('.'), reverse=True, ) # thread the deletion root's surviving merge target (its base config, # else its dotted parent -- delete.sh's own fallback) into each # descendant's delete.sh: a descendant's self-derived parent dies in # this same teardown, so its unmerged warning must name a survivor merge_target = self.config.get('base') or '' if not merge_target and '.' in branch: merge_target, *_ = branch.rsplit('.', 1) target_args = [f'--merge-target={merge_target}'] if merge_target else [] # collect each delete.sh's stdout and stderr notices (e.g. unmerged-work # warnings) separately so every node's removal is echoed and warnings # ride the caller's stderr, not vanish behind a silent force-delete outputs = [] notices = [] # serialize the worktree teardown against concurrent inits/teardowns -- # git worktree remove is not parallel-safe (the .worktrees flock child_add # takes around git worktree add) with worktree.lock(repo_dir): for descendant_branch in ordered_branches: worktree_dir = fractal.util.git.find_worktree( repo_dir=repo_dir, branch=descendant_branch, ) if worktree_dir: child = self.__class__(worktree_dir) child_result = child._run_script( 'delete.sh', f'{worktree_dir}', *target_args, ) child_output = child_result.stdout.strip() if child_output: outputs.append(child_output) notice = child_result.stderr.strip() if notice: notices.append(notice) else: # phantom descendant (worktree already gone): delete.sh cannot run, # so prune its branch + project-cache entry directly to avoid a leak worktree.prune_branch(repo_dir, descendant_branch) result = self._run_script('delete.sh', f'{self._root}') # deregister the whole subtree from the central registry self._deregister_subtree(db, repo_dir, branch, descendant_branches) # surface delete.sh stderr on success too -- the unmerged-work warning lives # there and is otherwise swallowed (only a failure surfaces stderr by default) output = result.stdout.strip() if output: outputs.append(output) notice = result.stderr.strip() if notice: notices.append(notice) return '\n'.join(outputs), '\n'.join(notices)
[docs] def deregister(self: Node, name: str) -> str: """Deregister an orphaned (worktree-less) node from the registry. For a node whose worktree was removed out of band, ``delete`` cannot run -- it needs the worktree. ``name`` is the orphan's branch or its bare short name (trailing segment), matched against this node's registered subtree. This prunes the orphan's branch and project-cache entry (plus any descendants the flat registry still lists) and clears the whole subtree from the central registry. ``self`` must be an ancestor (e.g. the user node) that still lists the orphan. Args: name: Branch (or bare short name) of the orphaned node to deregister. Returns: Confirmation message. Raises: LookupError: If ``name`` matches no registered node, or matches more than one (the candidates are listed). """ # match the full branch or its bare short name against the registry rows = self.child_list() or [] matches = [] for row in rows: *_, short = row['node'].rsplit('.', 1) if row['node'] == name or short == name: matches.append(row['node']) if not matches: raise LookupError(f'No registered node matches {name!r}.') if len(matches) > 1: options = ', '.join(sorted(matches)) raise LookupError( f'Ambiguous node name {name!r} (matches: {options}).' f' Use the full branch.' ) (branch,) = matches # alias git root repo_dir = self.repo_dir # the orphan plus any descendants the flat registry still lists descendant_branches = [] for row in rows: if row['node'].startswith(f'{branch}.'): descendant_branches.append(row['node']) # a live (on-disk) worktree anywhere in the subtree means this is not an # orphan prune -- deregister deletes branches and clears rows, so a live # descendant would be torn out from under a running node; remove the # worktree first (`fractal node delete` without --force does that job) for subtree_branch in (branch, *descendant_branches): if fractal.util.git.find_worktree(repo_dir, subtree_branch): raise RuntimeError( f'{subtree_branch} still has a worktree; remove it first' f' (`fractal node delete {subtree_branch}`).' ) # prune each branch's git branch + project-cache entry, then deregister for subtree_branch in (branch, *descendant_branches): worktree.prune_branch(repo_dir, subtree_branch) self._deregister_subtree(self.db, repo_dir, branch, descendant_branches) # a worktree rm-rf'd out of band lingers in git's porcelain as prunable # (its branch ref then resists deletion) -- point at the one-shot cleanup message = f'Deregistered orphan node {branch}.' if fractal.util.git.prunable(repo_dir): message += ' Run `git worktree prune` to clear stale worktree metadata.' return message
[docs] def reconcile(self: Node) -> list[str]: """Record orphaned descendants (worktree removed out of band) as events. Cleaning up a node's worktree/branch with plain git instead of ``delete`` legitimately leaves its registry rows behind, but nothing records the removal (``list`` flags such rows display-only). Logs one ``orphan`` event per newly observed orphan, giving out-of-band cleanup an audit trail. Registry rows are kept -- ``delete --force`` (deregister) remains the removal path. Returns: Branches newly recorded as orphaned. """ # scan the cached registry against one batched worktree probe rows = self.child_list() or [] worktrees = fractal.util.git.worktree_map(self.repo_dir) recorded = [] for row in rows: if row['node'] in worktrees: continue # skip a branch whose orphaning is already on the events log where = {'event': 'orphan', 'metadata': row['node']} if self.db.read('events', where=where): continue # log the observation (point-in-time: start plus immediate end) event_id = self.record.event_start('orphan', metadata=row['node']) self.record.event_end(event_id=event_id, status='completed') recorded.append(row['node']) return recorded
[docs] def retire(self: Node, reason: Optional[str] = None) -> str: """Mark the node as retired. Retired nodes are hidden from ``list()`` by default and cannot be started. The current status rides the retire event (with the reason appended, when given) so ``unretire`` can restore it. Args: reason: Optional reason for retiring. Returns: Confirmation message. """ # reject user nodes if self.is_user: raise RuntimeError('Cannot retire a user node.') # reconcile a crashed-but-active node so it can be retired self._reconcile_status() # the guard re-read and the retired flip stay atomic under the # .worktrees flock, so a rival verb cannot land between them; # retire.sh runs outside the lock with worktree.lock(self.repo_dir): # re-read under the lock -- a rival verb that won the race has # already moved this node's status if self.status() == 'active': raise RuntimeError( 'Cannot retire an active node. Stop or kill it first.' ) if self.status() == 'paused': raise RuntimeError( 'Cannot retire a paused node. Resume or kill it first.' ) # set status and log event -- the pre-retire status rides the # event metadata (ahead of any ': <reason>' suffix) so unretire # can restore it instead of dropping it prior = self.status() metadata = f'{prior}: {reason}' if reason else prior event_id = self.record.event_start('retire', metadata=metadata) self.status_set('retired') self._run_script('retire.sh', f'{self._root}') self.record.event_end(event_id=event_id, status='completed') return 'Node retired'
[docs] def unretire(self: Node) -> str: """Remove retired status from the node. Restores the status the node held before it was retired (recorded on the retire event); when no retire event recorded one (e.g. a ``.status`` file set by hand) the node resets to ``idle``. An ``idle`` restore re-enters the unsettled pool, so it re-checks the width/descendant gates (:meth:`_enforce_rearm_limits`) under the ``.worktrees`` flock; a settled restore holds no slot and passes ungated. Returns: Confirmation message. """ # reject user nodes if self.is_user: raise RuntimeError('Cannot unretire a user node.') # validate status if self.status() != 'retired': raise RuntimeError('Cannot unretire: node is not retired.') # an idle restore re-enters the unsettled pool, so the gate re-check # and the flip stay atomic under the .worktrees flock (init's # check+register atomicity); a settled restore holds no slot with worktree.lock(self.repo_dir): # re-read under the lock -- a concurrent unretire that won the # race has already restored this node out of retired if self.status() != 'retired': raise RuntimeError('Cannot unretire: node is not retired.') # restore the pre-retire status from the latest retire event -- # resolved under the lock too, so a rival unretire -> re-retire # cycle cannot hand this caller a stale prior; fall back to idle # when nothing usable was recorded (a re-retired node stored # 'retired'; 'active' can only be a stale hand-edit) rows = self.db.read( 'events', where={'node': self.branch, 'event': 'retire'}, limit=1, ) prior = rows[0]['metadata'] if rows else '' # the prior rides ahead of any ': <reason>' suffix retire added prior, *_ = prior.split(': ', 1) if prior not in STATUSES or prior in ('active', 'retired'): prior = 'idle' if prior == 'idle': self._enforce_rearm_limits() # set status and log event event_id = self.record.event_start('unretire') self.status_set(prior) self._run_script('unretire.sh', f'{self._root}') self.record.event_end(event_id=event_id, status='completed') return 'Node unretired'
[docs] @staticmethod def destroy(path: pathlib.Path) -> str: """Destroy the repo's fractal -- the full inverse of ``fractal init``. Tears down every node worktree and local branch, removes ``.worktrees/``, deletes the user node's data directory, and strips fractal's block from the repo's ``info/exclude``. Committed artifacts (the project wiki, baseline commits) and remote branches are left in place. Refuses while any node's tmux session is alive; paused nodes are killed as part of the teardown -- the caller's confirmation authorized discarding the frozen mid-step work their parked worktrees hold. Args: path: Git repository root. Returns: Script output. """ # anchor on the user node by config, not the checkout: on a non-init # branch a bare Node(path) reads uninitialized, skipping the pre-flight # and paused settle, and the script would key the data dir off the # wrong branch, leaving the config and central DB behind node = Node.resolve_user(path) or Node(path) # snapshot the registry before the teardown: the script removes the # central DB with the user node's data dir, so the phantom prune # below must read the branch list while it still exists registry = node.db.read('nodes') if node.exists() else [] # refuse if the caller stands inside a node worktree -- git cannot # remove a worktree the caller occupies cwd = pathlib.Path.cwd().resolve() for _, worktree_path in fractal.util.git.worktree_map(node.repo_dir).items(): worktree_dir = worktree_path.resolve() if worktree_dir == node.repo_dir.resolve(): continue if cwd == worktree_dir or worktree_dir in cwd.parents: raise RuntimeError( 'Cannot destroy the current worktree from inside it.' ' Run from the repo root.' ) # reconcile crashed nodes so their orphaned process groups reap while # the .pgid records still exist (the script removes them with the # worktrees; a headless agent would otherwise keep spending unseen) if node.exists(): for _, descendant in node._live_descendants(status='active'): descendant._reconcile_status() result = Node._guarded_teardown( node, 'destroy.sh', path, f'--branch={node.branch}', ) # prune every snapshot branch: a no-op for the worktrees the script # tore down, and the cleanup destroy.sh cannot do for a phantom (its # worktree rm -rf'd out of band) -- a stale branch would resurrect # old history under a later re-init of the name for branch in sorted({row['node'] for row in registry}): worktree.prune_branch(node.repo_dir, branch) # strip fractal's block from the shared info/exclude (the inverse of # exclude_update: same whole-line markers, all other content preserved) worktree.exclude_strip(path) return result.stdout.strip()
[docs] @staticmethod def reset(path: pathlib.Path) -> str: """Remove every node worktree, keeping the project and its history. The middle rung between ``delete`` (one subtree) and ``destroy`` (the full inverse of init): tears down all node worktrees and local branches and clears the node registry, while the user node's data -- config, memory, and the central database with every history row -- plus the wiki and baseline commits survive, so fresh nodes spawn immediately after. Refuses while any node's tmux session is alive; paused nodes are killed as part of the teardown -- the caller's confirmation authorized discarding the frozen mid-step work their parked worktrees hold. Args: path: Git repository root. Returns: Script output. """ # anchor on the user node by config, not the checkout: on a non-init # branch a bare Node(path) reads uninitialized, skipping the registry # snapshot (orphaning its rows), the reconcile, and the latch cleanup node = Node.resolve_user(path) or Node(path) repo_dir = node.repo_dir # snapshot the registry before the teardown: the deregistration below # must sweep exactly the rows that predate the script, never a node a # concurrent init registers afterward registry = node.db.read('nodes') if node.exists() else [] # refuse if the caller stands inside a node worktree -- git cannot # remove a worktree the caller occupies cwd = pathlib.Path.cwd().resolve() for _, worktree_path in fractal.util.git.worktree_map(repo_dir).items(): worktree_dir = worktree_path.resolve() if worktree_dir == repo_dir.resolve(): continue if cwd == worktree_dir or worktree_dir in cwd.parents: raise RuntimeError( 'Cannot reset the current worktree from inside it.' ' Run from the repo root.' ) # reconcile crashed nodes so their open history rows close before # their worktrees vanish (the rows persist; the worktrees do not) if node.exists(): for _, descendant in node._live_descendants(status='active'): descendant._reconcile_status() result = Node._guarded_teardown(node, 'reset.sh', path) # prune every snapshot branch: a no-op for the worktrees the script # tore down, and the cleanup delete.sh cannot do for a phantom (its # worktree rm -rf'd out of band) -- a stale branch or .project entry # would resurrect old history under a later re-init of the name branches = sorted({row['node'] for row in registry}) for branch in branches: worktree.prune_branch(repo_dir, branch) # clear the snapshot's registry rows and subscriptions, grouped into # maximal subtrees so a delete event lands on the user node's log per # subtree; history rows persist roots = [ branch for branch in branches if not any(branch.startswith(f'{other}.') for other in branches) ] for root_branch in roots: descendant_branches = [ branch for branch in branches if branch.startswith(f'{root_branch}.') ] Node._deregister_subtree( db=node.db, repo_dir=repo_dir, branch=root_branch, descendant_branches=descendant_branches, ) # a tree-wide pause latch would outlive the (now gone) nodes it froze # and refuse every future init/start -- the brake goes with the tree # it applied to if node.exists(): node._tree_latch_file.unlink(missing_ok=True) return result.stdout.strip()
@staticmethod def _guarded_teardown( node: Node, script: str, path: pathlib.Path, *args: str, ) -> subprocess.CompletedProcess[str]: """Run a tree-teardown script behind the pre-flight, settle, and flock. The shared ``destroy``/``reset`` shape. Pre-flights the script's own refusals first -- a live tmux session or a locked worktree anywhere in the tree -- because the paused settle below is irreversible (the kills close the parked runs), so a teardown the script would refuse must abort here with nothing touched. Then settles frozen work: a paused node has no session for the liveness refusal to catch, and the confirmed teardown already authorized discarding its frozen mid-step work, so each parked node is killed -- pure bookkeeping with no loop alive: the open rows close ``killed`` and the attribution names the verb -- rather than bouncing the operator through a manual kill sweep. Then runs the script under the ``.worktrees`` flock so its worktree remove/prune does not race a concurrent init/delete (the same lock ``child_add`` takes) -- but only when ``.worktrees`` exists; creating it would defeat the script's nothing-to-tear-down check, which keys off that directory. The script re-checks every guard under the lock, backstopping a session, lock, or pause that lands after the pre-flight. Args: node: The repo-root node handle. script: The teardown script (``destroy.sh``/``reset.sh``); its stem names the verb in the kill attribution. path: Git repository root, passed through to the script. *args: Extra script arguments, passed through after the path. Returns: Completed process result. """ verb, *_ = script.split('.') if node.exists(): # pre-flight the script's refusals before the irreversible settle # (the user node runs no loop, so only descendants can hold a # session): one pass per guard, mirroring the script's ordering descendants = node._live_descendants() # probe each node's recorded socket, never the ambient one alone: # a session alive on the socket the loop recorded at boot is # invisible to a shell resolving a different server (see # _tmux_session_exists); an inconclusive probe means the node may # still be running, so the irreversible teardown refuses rather # than tearing down blind for _, descendant in descendants: alive = descendant._tmux_session_exists() if alive is None: raise RuntimeError( f'Cannot {verb}: the tmux probe failed (tmux list-sessions' ' gave no answer), so nodes may still be running. Restore' ' tmux visibility and retry.' ) if alive: raise RuntimeError( f'Cannot {verb}: node is still running in tmux' f' ({descendant.tmux_session}). Kill it first with:' f' fractal node kill {descendant.branch}.' ) for _, descendant in descendants: git_dir = fractal.util.git.run( ['rev-parse', '--absolute-git-dir'], cwd=descendant._root, check=False, ) if git_dir and (pathlib.Path(git_dir) / 'locked').is_file(): raise RuntimeError( f'Cannot {verb}: worktree is locked: {descendant._root}' f' (unlock with: git -C "{node.repo_dir}"' f' worktree unlock "{descendant._root}").' ) # settle frozen work: kill each parked node so its open rows # close -- before the lock (each kill takes the same flock); # best-effort per node (mirrors kill's sweep), the script's # paused re-check backstops for _, descendant in node._live_descendants(status='paused'): try: descendant._kill(f'{verb} teardown', fan_out=True) except Exception: node.log( message=f'Warning: failed to kill {descendant._root}', level=logging.WARNING, ) # run the script under the flock, only when .worktrees exists worktrees = node.repo_dir / WORKTREES_FOLDER if worktrees.is_dir(): with worktree.lock(node.repo_dir): return node._run_script(script, f'{path}', *args) return node._run_script(script, f'{path}', *args)
[docs] def status(self: Node) -> str: """Return the node's current status. Reads the node's ``.status`` file -- lifecycle state is kept out of ``config.json`` so config is purely user settings. Returns: Status string (``idle`` when no ``.status`` file exists). """ status_file = self._status_file if status_file.exists(): return status_file.read_text(encoding='utf-8').strip() return 'idle'
[docs] def status_display(self: Node) -> str: """Return the status decorated with a pending signal or end reason. ``active (pausing)`` / ``active (stopping)`` / ``active (finishing)`` when a pause/stop/finish signal is pending on an active node; ``exited (<reason>)`` when the latest run row recorded why the run ended (the run row is the single source -- a reconcile-healed crash records no reason and stays bare); else the bare status. The suffix is display-only -- the stored status (and the value status filters match on, via the first space-delimited chunk) stays bare -- but a crashed-but-active node is reconciled (persisted) first: reads are where staleness is observed, and the probe is a no-op unless the stored status is ``active``. Returns: Status string, possibly with a pending-signal or end-reason suffix. """ self._reconcile_status() status = self.status() if status == 'active': if self.record.signal_get('pause') is not None: return 'active (pausing)' if self.record.signal_get('stop') is not None: return 'active (stopping)' if self.record.signal_get('finish') is not None: return 'active (finishing)' if status == 'exited': # the latest run row records why the loop ended (budget landing, # timeout, setup abort); a crash healed by reconcile closes its # rows reason-less and keeps the bare status rows = self.record.runs(limit=1) if rows and rows[0]['status'] == 'exited' and rows[0]['metadata']: reason = rows[0]['metadata'] return f'exited ({reason})' return status
[docs] def status_set(self: Node, status: str) -> None: """Set the node's status. Validates against the set of known status values, writes the node's ``.status`` file, and updates the node's row in the central ``nodes`` registry. Args: status: Status value to set. """ # validate status if status not in STATUSES: raise _invalid_status(status) # write the status file atomically, so a concurrent status() never # reads a torn value fractal.util.filesystem.write_atomic(self._status_file, status + '\n') # update the node's registry row (the user node has none -- no-op) self.db.update({'status': status}, 'nodes', where={'node': self.branch})
[docs] def title_set(self: Node, title: str) -> None: """Set the node's human-readable title. The display label consumers show in place of the branch slug. It lives in both the node's ``config.json`` and its central registry row (init seeds them together), so both are written -- a registry-only update would leave the config stale and the two out of sync. On the user node, which has no registry row, the config write is the whole effect. Args: title: The display name to store. """ # write the node's config.json first (the failure-prone step), then # the nodes table, so a config-write failure can't desync the two self.config.set('title', title) self.db.update({'title': title}, 'nodes', where={'node': self.branch})
[docs] def list( self: Node, *, all_nodes: bool = False, retired_only: bool = False, max_depth: Optional[int] = None, status: Optional[str] = None, live: bool = False, decorated: bool = False, ) -> list[dict]: """List registered child nodes. Queries the ``nodes`` table with optional depth and status filters. A crashed-but-active row (worktree present, no live tmux session) is reconciled -- persisted via :meth:`_reconcile_status`, not just relabeled -- before listing, so the fleet's default steering read never echoes a dead loop as ``active``. Cap columns render each present child's live config values; a gone worktree keeps the registry caps. The ``last`` column renders each row's newest activity instant as a compact age, flagged (``12m!``) when an active node has sat quiet past ``max(step_timeout, 5m)``. Args: all_nodes: Include retired nodes in output. retired_only: Show only retired nodes. max_depth: Maximum depth relative to this node. status: Filter to a status, or several comma-separated (overrides the retired/all default). live: Reconcile each row against the child's real ``.status()``, dropping descendants whose worktree is gone, relabeling a crashed ``active`` node (no live tmux session) to ``exited``, and a booting ``idle`` node (live session, the loop not yet stamped) to ``active`` (the authoritative view). Read-only -- it does not persist the relabel. decorated: Append each active descendant's pending stop/finish signal (``active (stopping)``) and each exited one's recorded end reason (``exited (<reason>)``) to its displayed status; display-only, gated off for hot paths such as ``--count``. Returns: List of child node records. """ # read children -- authoritative (live) or the cached registry if live: # _live_descendants reconciles each row to the child's real status and # drops gone worktrees; additionally relabel a crashed-but-active node # ('active' with no live tmux session) to 'exited' and a booting idle # node (live session) to 'active' -- display-only, mirroring the TUI # snapshot reconcile, so --live is the authoritative # settled-vs-crashed view; the batched probe only nominates # candidates, and each relabel confirms on the node's recorded # socket (a tmux answer is evidence about one server only -- see # _tmux_session_exists); an inconclusive probe (no tmux answer) # proves nothing, so the stored status stands sessions = fractal.util.tmux.probe() rows = [] for row, node in self._live_descendants(max_depth=max_depth): if sessions is not None: if _base_status(row.get('status')) == 'active': unlisted = node.tmux_session not in sessions if unlisted and node._tmux_session_exists() is False: row = {**row, 'status': 'exited'} # a started child holds 'idle' until its loop stamps # 'active' after preflight, but its session is already # live -- read the boot window as 'active', so a finishing # ancestor's drain never completes over a child started # seconds earlier; a sessionless idle node (spawned, never # started) stays idle and never blocks a drain elif _base_status(row.get('status')) == 'idle': if node.tmux_session in sessions or node._tmux_session_exists(): row = {**row, 'status': 'active'} rows.append(row) else: rows = self.child_list(max_depth=max_depth) if rows is None: return [] worktrees = fractal.util.git.worktree_map(self.repo_dir) # heal a crashed-but-active row by persisting the child's # reconcile -- the plain list is the fleet's default steering read pairs = [] for row in rows: node = None if row['node'] in worktrees: node = self.__class__(worktrees[row['node']]) pairs.append((row, node)) rows = [row for row, _ in self._heal_crashed(pairs)] # flag a registry row whose worktree is gone (removed out of band) # rather than reporting a vanished node as healthy: a settled/kept # status keeps an '(orphaned)' suffix (filters match on the bare # first chunk), while a live-ish row turns 'orphan' outright -- # artifacts vanishing mid-life is an anomaly, not cleanup settled = ('completed', 'stopped', 'exited', 'killed', 'retired') flagged = [] for row in rows: if row['node'] not in worktrees: stored = _base_status(row.get('status')) if stored in settled: row = {**row, 'status': f'{stored} (orphaned)'} else: row = {**row, 'status': 'orphan'} flagged.append(row) rows = flagged # one fleet-wide read of each node's newest activity instant backs # the 'last' column merged in the overlay pass below recent = { entry['node']: entry['timestamp'] for entry in self.db.read( query='SELECT node, MAX(timestamp) AS timestamp' ' FROM activity GROUP BY node', ) } # overlay each present child's config caps (display-only): a # post-spawn config edit (the rescue top-up) is live enforcement # truth the registry row's spawn-time values miss; a gone worktree # keeps the registry caps (the only surviving source) worktrees = fractal.util.git.worktree_map(self.repo_dir) capped = [] for row in rows: worktree_dir = worktrees.get(row['node']) node = self.__class__(worktree_dir) if worktree_dir else None if node is not None and not node.exists(): node = None # render the newest activity instant as the 'last' age; an # active node quiet past max(step_timeout, 5m) is flagged stale # ('12m!'), step_timeout from the child's live config (a gone # worktree falls back to the floor alone) last = None timestamp = recent.get(row['node']) if timestamp is not None: age = fractal.util.time.elapsed(timestamp) last = fractal.util.format_age(age) if _base_status(row.get('status')) == 'active': step_timeout = None if node is not None: step_timeout = fractal.util.parse_duration_seconds( node.config.get('step_timeout') or '' ) if age > max(step_timeout or 0.0, _STALE_AGE_FLOOR_SECONDS): last += '!' if node is None: capped.append({**row, 'last': last}) continue drifted = {} for key in ('max_cost', 'max_depth', 'max_children', 'max_descendants'): config_value = node.config.get(key) if config_value is not None and config_value != row[key]: drifted[key] = config_value capped.append({**row, **drifted, 'last': last}) rows = capped # decorate the displayed status with each active descendant's pending # stop/finish signal (display-only); the filters below match on the bare # first chunk, so e.g. status='active' still selects 'active (stopping)' if decorated: worktrees = fractal.util.git.worktree_map(self.repo_dir) rows = [self._decorate_status(row, worktrees) for row in rows] # filter by an explicit status (one or comma-several), else apply # the retired/all default if status is not None: wanted = {chunk.strip() for chunk in status.split(',') if chunk.strip()} rows = [row for row in rows if _base_status(row.get('status')) in wanted] elif retired_only: rows = [row for row in rows if _base_status(row.get('status')) == 'retired'] elif not all_nodes: rows = [row for row in rows if _base_status(row.get('status')) != 'retired'] return rows
def _decorate_status( self: Node, row: dict, worktrees: dict[str, pathlib.Path], ) -> dict: """Append a descendant's pending graceful signal or end reason. Display helper for ``list``: for a descendant whose own stored status still matches the row's (``active`` or ``exited``), replaces the row's status with its :meth:`status_display` (``active (pausing)`` / ``active (stopping)`` / ``active (finishing)`` / ``exited (<reason>)``). Decoration only -- a diverged row (a stale registry value, or ``--live``'s display-only relabel of a crashed ``active`` node) keeps its value without consulting ``status_display``, whose reconcile would otherwise persist a heal from a read-only listing. ``worktrees`` is a branch->path map (one ``git worktree list``) so the listing resolves worktrees without a subprocess per row. Best-effort -- a row whose worktree is gone keeps its cached status. """ stored = _base_status(row.get('status')) if stored not in ('active', 'exited'): return row worktree_dir = worktrees.get(row['node']) if worktree_dir: node = self.__class__(worktree_dir) if node.exists() and node.status() == stored: display = node.status_display() if _base_status(display) == stored: return {**row, 'status': display} return row def _heal_crashed( self: Node, pairs: list[tuple[dict, Optional[Node]]], ) -> list[tuple[dict, Optional[Node]]]: """Persist the reconcile for crashed-but-active rows (the shared pass). Reads are where staleness is observed: a row still ``active`` with no live tmux session is healed through the child's own :meth:`_reconcile_status` (persisted, not just relabeled) and re-read. The tmux probe is paid only while something reads ``active`` (one batched probe for the whole set); a row without a live node (worktree gone) passes through untouched. An inconclusive probe (no answer from tmux) heals nothing -- stamping live loops ``exited`` on a blind host would reap them. Args: pairs: ``(row, node)`` per registry row -- ``node`` is ``None`` when the row's worktree is gone. Returns: The pairs, each crashed-active row's status healed. """ if not any(_base_status(row.get('status')) == 'active' for row, _ in pairs): return pairs sessions = fractal.util.tmux.probe() if sessions is None: return pairs healed = [] for row, node in pairs: if _base_status(row.get('status')) == 'active' and node is not None: if node.exists() and node.tmux_session not in sessions: node._reconcile_status() row = {**row, 'status': node.status()} healed.append((row, node)) return healed def _count_unsettled( self: Node, *, max_depth: Optional[int] = None, ) -> int: """Count slot-holding descendants: active, paused, or idle awaiting start. The width/descendant gates bind on UNSETTLED nodes only -- a settled (completed/stopped/exited/killed) or retired node frees its slot automatically, so the caps bound concurrency, not lifetime spawn count. Crashed-but-active descendants are healed first (persisted via :meth:`_reconcile_status`) so a dead loop's phantom ``active`` cannot wedge the gate; the tmux probe is paid only while something reads ``active`` (one batched probe for the whole set). Args: max_depth: Maximum depth relative to this node. Returns: The number of unsettled live descendants. """ live = self._live_descendants(max_depth=max_depth) # heal crashed-but-active rows before counting (persisting the settle) live = self._heal_crashed(live) # statuses that hold a spawn slot: active, paused mid-work (it will # return), or idle awaiting start unsettled = ('active', 'paused', 'idle') return sum(1 for row, _ in live if row['status'] in unsettled) def _enforce_spawn_limits( self: Node, *, child_max_cost: Optional[float], ) -> None: """Reject a child spawn that would exceed a live subtree or budget cap. ``self`` is the parent. Checks the caps that depend on live state (:meth:`_check_caps`) -- the parent's ``max_children`` (width), every ancestor's ``max_depth`` and ``max_descendants`` (subtree), and the child's ``max_cost`` against the parent's remaining run budget. Args: child_max_cost: The child's requested ``--max-cost`` (USD), or ``None``. Raises: ValueError: If any live subtree or budget cap would be exceeded. """ self._check_caps(depth=self.branch.count('.') + 1, budget=child_max_cost) def _enforce_rearm_limits(self: Node) -> None: """Reject a re-arm that would exceed a live width or descendant cap. ``self`` is the node about to re-arm to ``idle``. A re-arm returns one unsettled node to the tree exactly as a spawn adds one, so it re-checks the two concurrency caps the spawn gate enforces (:meth:`_check_caps`) -- the parent's ``max_children`` (width) and every ancestor's ``max_descendants`` (subtree); out of the pool here, this node holds no slot, so spawn-to-cap -> settle -> respawn -> re-arm would otherwise land the subtree over cap. ``max_depth`` is structural (the node already sits at its spawn-time depth) and the budget bound is spawn-time only (every run re-arms the node's own ``max_cost``), so neither re-runs here. Called by :meth:`start` (continue) and :meth:`unretire` (idle restore); like the spawn gate, there is no override flag. Raises: ValueError: If a width or descendant cap would be exceeded. """ self._check_caps(depth=None, budget=None) def _check_caps( self: Node, *, depth: Optional[int], budget: Optional[float], ) -> None: """Reject adding one unsettled node over a live width/subtree/budget cap. The shared gate behind :meth:`_enforce_spawn_limits` and :meth:`_enforce_rearm_limits`. The width and descendant counts bind on unsettled nodes only (:meth:`_count_unsettled`), bounding concurrency, not lifetime spawn count; every ancestor's config is checked so limits hold without agent cooperation. Each cap is re-read here rather than at the caller's top so the read is current: both callers run under the ``.worktrees`` flock, just before registering the child or re-arming the slot, so concurrent fan-out is serialized and the descendant counts are authoritative -- a TOCTOU race that checked before the lock could let several inits each pass and blow past the cap (the just-registered child lands idle, so it holds its slot for the next serialized check). Args: depth: The incoming child's absolute depth (spawn), or ``None`` for a re-arm -- which also skips the node's own caps (they bound its subtree, which the re-arm leaves unchanged) and the budget bound. budget: The child's requested ``--max-cost`` (USD), if any. Raises: ValueError: If any live subtree or budget cap would be exceeded. """ # the incoming slot's direct parent by name -- width binds there: the # spawning node itself, or the re-arming node's dotted parent if depth is not None: parent_branch = self.branch else: parent_branch, *_ = self.branch.rsplit('.', 1) for ancestor in self._self_and_ancestors(): # a re-arm skips the node itself -- its own caps bound its # subtree, which the re-arm leaves unchanged if depth is None and ancestor is self: continue # max-children (width): the direct parent only, counting the # slot's unsettled siblings if ancestor.branch == parent_branch: max_children = ancestor.config.get('max_children') if max_children is not None: direct = ancestor._count_unsettled(max_depth=1) if direct >= max_children: raise ValueError( f'Max children reached on {ancestor.branch!r}' f' (limit {max_children},' f' {direct} unsettled direct children).' ) # max-depth: child's depth relative to ancestor (structural -- # settled nodes still occupy their place in the tree) if depth is not None: ancestor_depth = ancestor.branch.count('.') ancestor_max_depth = ancestor.config.get('max_depth') if ancestor_max_depth is not None: if depth - ancestor_depth > ancestor_max_depth: raise ValueError( f'Max depth reached on {ancestor.branch!r}' f' (limit {ancestor_max_depth}, child would be' f' at relative depth {depth - ancestor_depth}).' ) # max-descendants: unsettled descendants vs ancestor's budget ancestor_max_descendants = ancestor.config.get('max_descendants') if ancestor_max_descendants is not None: existing = ancestor._count_unsettled() if existing >= ancestor_max_descendants: raise ValueError( f'Max descendants reached on {ancestor.branch!r}' f' (limit {ancestor_max_descendants},' f' {existing} unsettled descendants).' ) # enforce the child's max_cost against the parent's remaining run # budget (spawn only -- every run re-arms the node's own max_cost) if depth is None: return max_cost = self.config.get('max_cost') if max_cost is not None: if budget is None: raise ValueError('Parent has max_cost; child must also set --max-cost.') # bound the child by the budget the run it joins will have: against an # active run, the parent's per-run remaining (subtree-aware, max_cost # minus the whole subtree's spend); with no active run the next run # starts fresh, so the parent's configured max_cost -- not the drained # remaining of a most-recent run that the child won't share _, _, run_id = self.record.resolve_context(active=True) if run_id is not None: remaining = self.cost.remaining(run_id=run_id) else: remaining = float(max_cost) if budget > remaining: raise ValueError( f'Max cost ${budget:.2f} exceeds remaining ${remaining:.2f}.' )
[docs] def child_add( self: Node, name: str, *, title: Optional[str] = None, max_cost: Optional[float] = None, max_depth: Optional[int] = None, max_children: Optional[int] = None, max_descendants: Optional[int] = None, ) -> int: """Register a child node. Caps land on the row verbatim, ``None`` included: a ``--reset`` re-init upserts over the old row, and an omitted cap must clear there just as it does in the reseeded ``config.json`` -- reconcile leaves config-absent keys alone, so a stale registry cap would otherwise survive every future heal and misreport an uncapped node as capped. Args: name: Child node name. title: Child's display name. max_cost: Child's maximum cost in USD. max_depth: Child's maximum nesting depth. max_children: Child's maximum direct child nodes. max_descendants: Child's maximum total descendant nodes. Returns: Node ID. """ branch = f'{self.branch}.{name}' data = { 'node': branch, 'status': 'idle', 'max_cost': max_cost, 'max_depth': max_depth, 'max_children': max_children, 'max_descendants': max_descendants, } if title is not None: data['title'] = title result = self.db.merge(data, 'nodes', conflict=['node']) # auto-subscribe to the child's readable channels (seeded by the # child's radio.init before registration, so validation always # resolves) -- unless this parent is blind: a blind node holds no # subscriptions of its own, so it must not start reading a child it # spawns mid-run (the child's own parent-watch is separate) if not self.config.get('blind'): self.radio.subscribe(branch) return result
def _child_not_found(self: Node, name: str) -> ValueError: """Build the missing-child error.""" return ValueError(f'Child not found: {name!r}') def _child_worktree_not_found(self: Node, branch: str) -> ValueError: """Build the missing-child-worktree error.""" return ValueError(f'Child worktree not found: {branch!r}')
[docs] def child_update( self: Node, name: str, *, title: Optional[str] = None, max_cost: Optional[float] = None, max_iter_cost: Optional[float] = None, max_step_cost: Optional[float] = None, reserve_budget: Optional[float] = None, step_timeout: Optional[str] = None, max_depth: Optional[int] = None, max_children: Optional[int] = None, max_descendants: Optional[int] = None, ) -> None: """Update a child node's configuration. Updates both the parent's ``nodes`` table and the child's ``config.json``. Args: name: Child node name. title: New display name. max_cost: New maximum cost in USD. max_iter_cost: New per-iteration cost cap in USD; lives only in the child's ``config.json`` (the ``nodes`` table has no column). max_step_cost: New per-step cost cap in USD; lives only in the child's ``config.json`` (the ``nodes`` table has no column). reserve_budget: New cleanup reserve in USD; lives only in the child's ``config.json`` (the ``nodes`` table has no column). step_timeout: New per-step time budget; lives only in the child's ``config.json`` (the ``nodes`` table has no column). max_depth: New maximum nesting depth. max_children: New maximum direct child nodes. max_descendants: New maximum total descendant nodes. """ # initialize updates -- the iter/step caps, the reserve, and the # step timeout are config-only data = {} if title is not None: data['title'] = title if max_cost is not None: data['max_cost'] = max_cost if max_depth is not None: data['max_depth'] = max_depth if max_children is not None: data['max_children'] = max_children if max_descendants is not None: data['max_descendants'] = max_descendants config_only = {} if max_iter_cost is not None: config_only['max_iter_cost'] = max_iter_cost if max_step_cost is not None: config_only['max_step_cost'] = max_step_cost if reserve_budget is not None: config_only['reserve_budget'] = reserve_budget if step_timeout is not None: config_only['step_timeout'] = step_timeout if not data and not config_only: return # verify child exists branch = f'{self.branch}.{name}' if not self.db.exists('nodes', where={'node': branch}): raise self._child_not_found(name) # require a live worktree -- updating only the nodes table would leave # the child's config.json stale and the two out of sync child_worktree_dir = fractal.util.git.find_worktree(self.repo_dir, branch) if child_worktree_dir is None: raise self._child_worktree_not_found(branch) # write the child's config.json first (the failure-prone step -- # a malformed/locked config or vanished worktree raises here), then # the nodes table, so a config-write failure can't desync the two child_config = self.__class__(child_worktree_dir).config for key, value in {**data, **config_only}.items(): child_config.set(key, value) if data: self.db.update(data, 'nodes', where={'node': branch})
[docs] def child_retune( self: Node, name: str, *, title: Optional[str] = None, max_cost: Optional[float] = None, max_iter_cost: Optional[float] = None, max_step_cost: Optional[float] = None, reserve_budget: Optional[float] = None, step_timeout: Optional[str] = None, max_depth: Optional[int] = None, max_children: Optional[int] = None, max_descendants: Optional[int] = None, ) -> dict[str, tuple[Any, Any]]: """Retune a child's configuration, validating the merged result. The policy half over :meth:`child_update` (which writes the registry row and the child's ``config.json`` together): a default-mode reserve (equal to what init's default fraction materialized for the old cap) retunes to track a new cap instead of going stale; per-iter/step caps are validated against the *effective* cap; the merged config is validated as init would (:meth:`Config.validate`); priors are captured before the write. Args: name: Child node name. title: New display name. max_cost: New maximum cost in USD. max_iter_cost: New per-iteration cost cap in USD. max_step_cost: New per-step cost cap in USD. reserve_budget: New cleanup reserve in USD, already resolved -- the CLI's ``N%`` grammar never enters core. step_timeout: New per-step time budget (a running loop picks it up at its next iteration top). max_depth: New maximum nesting depth. max_children: New maximum direct child nodes. max_descendants: New maximum total descendant nodes. Returns: ``{key: (old, new)}`` for every PROVIDED key -- ``old == new`` included, and an implicitly retuned reserve counts as provided -- so a confirmation echo shows every requested write. Raises: ValueError: On a violated cap invariant (user-facing flag spellings), or when the child is missing. """ # resolve the child's live config for priors and the merged validation branch = f'{self.branch}.{name}' if not self.db.exists('nodes', where={'node': branch}): raise self._child_not_found(name) child_worktree_dir = fractal.util.git.find_worktree(self.repo_dir, branch) if child_worktree_dir is None: raise self._child_worktree_not_found(branch) child_config = self.__class__(child_worktree_dir).config # resolve the reserve before validating; a default-mode reserve (equal # to what init's default fraction materialized for the old cap) retunes # to track a new cap instead of going stale old_max_cost = child_config.get('max_cost') old_reserve = child_config.get('reserve_budget') effective_max_cost = max_cost if max_cost is not None else old_max_cost if reserve_budget is None and old_reserve is not None: # a degenerate cap skips the retune -- the merged-config validation # below owns that rejection and its wording if max_cost is not None and max_cost > 0 and old_max_cost is not None: # both sides round at RESERVE_PRECISION, matching # parse_reserve_budget's materialization -- an unrounded # product would break the default-detection equality and # persist float noise into config.json and the retune echo default_reserve = round( DEFAULT_RESERVE_FRACTION * old_max_cost, RESERVE_PRECISION, ) if old_reserve == default_reserve: reserve_budget = round( DEFAULT_RESERVE_FRACTION * max_cost, RESERVE_PRECISION, ) # a per-iter/step cap on an effectively uncapped child mirrors init's # rejection -- unenforceable once the per-iter budget drains if effective_max_cost is None: if max_iter_cost is not None: raise ValueError('--max-iter-cost requires --max-cost.') if max_step_cost is not None: raise ValueError('--max-step-cost requires --max-cost.') # validate the merged config the way init/config set do -- a bare # non-negative boundary check still admits max_cost==0 and # step<=iter<=run orderings broken from either side (a lowered cap or # a raised sub-cap) effective_iter_cost = max_iter_cost if effective_iter_cost is None: effective_iter_cost = child_config.get('max_iter_cost') effective_step_cost = max_step_cost if effective_step_cost is None: effective_step_cost = child_config.get('max_step_cost') effective_reserve_budget = reserve_budget if effective_reserve_budget is None: effective_reserve_budget = old_reserve effective_step_timeout = step_timeout if effective_step_timeout is None: effective_step_timeout = child_config.get('step_timeout') merged = { 'max_cost': effective_max_cost, 'max_iter_cost': effective_iter_cost, 'max_step_cost': effective_step_cost, 'reserve_budget': effective_reserve_budget, 'step_timeout': effective_step_timeout, } child_config.validate(merged) # the confirmation echo needs each provided key's stored value from # before the write -- a retuned reserve counts even without its flag updates = { 'title': title, 'max_cost': max_cost, 'max_iter_cost': max_iter_cost, 'max_step_cost': max_step_cost, 'step_timeout': step_timeout, 'max_depth': max_depth, 'max_children': max_children, 'max_descendants': max_descendants, 'reserve_budget': reserve_budget, } provided = {key: value for key, value in updates.items() if value is not None} priors = {key: child_config.get(key) for key in provided} # write both stores through the update surface self.child_update( name=name, title=title, max_cost=max_cost, max_iter_cost=max_iter_cost, max_step_cost=max_step_cost, reserve_budget=reserve_budget, step_timeout=step_timeout, max_depth=max_depth, max_children=max_children, max_descendants=max_descendants, ) return {key: (priors[key], value) for key, value in provided.items()}
[docs] def child_list( self: Node, *, max_depth: Optional[int] = None, ) -> Optional[list[dict]]: """List registered children. Args: max_depth: Maximum depth relative to this node. ``1`` lists direct children only, ``2`` includes grandchildren, ``None`` lists all descendants. Returns: List of child records, or ``None`` if the node is not initialized. """ if not self.exists(): return None # the registry is tree-wide -- scope to this node's subtree by prefix # (substr, not LIKE: a branch name may contain LIKE-significant bytes) # and bound depth by dot count, keeping the built-SELECT row order prefix = f'{self.branch}.' query = 'SELECT * FROM nodes WHERE substr(node, 1, ?) = ?' params: tuple[Any, ...] = (len(prefix), prefix) if max_depth is not None: query += " AND length(node) - length(replace(node, '.', '')) <= ?" params += (max_depth + self.branch.count('.'),) query += ' ORDER BY rowid DESC' return self.db.read(query=query, params=params)
[docs] def child_approve( self: Node, child: Node, *, step_id: Optional[int] = None, ) -> int: """Approve a gated step in a child, recording ``approve`` on both event logs. ``self`` is the parent. Enforces direct parentage, writes the approval to the child's step (the source of truth), and logs an ``approve`` event on the parent (naming the child + step) and on the child (its own step). The events are best-effort audit. Args: child: The direct child whose step to approve. step_id: The child's step to approve; defaults to the child's active (gated) step. Returns: The approved step's id. Raises: PermissionError: If ``child`` is not a direct child of ``self``. ValueError: If there is no active step to approve, or the step does not exist or does not require approval. """ # enforce direct parentage -- only the parent may approve its child parent_branch, *_ = child.branch.rsplit('.', 1) if '.' not in child.branch or parent_branch != self.branch: raise PermissionError( f'Only the parent ({parent_branch}) can approve steps of' f' {child.branch}; this node is {self.branch}.' ) # default to the child's active step awaiting approval -- during an # approval wait the periodic SYNC opens a second active step row, so # resolve_context's newest-active pick can land on the SYNC bookkeeping # row (approval NULL); select the gated row (approval set) instead if step_id is None: active = child.db.read( 'steps', where={'node': child.branch, 'status': 'active'}, ) gated = [row for row in active if row.get('approved') is not None] if not gated: raise ValueError(f'No active step on {child.branch} to approve.') step_id = gated[0]['step_id'] # validate the child's step up front so a doomed approval never logs an # event -- both the missing-step and does-not-require-approval guards # must fail before event_start (the read also yields the label); the # node pin keeps a foreign node's step id from being approved rows = child.db.read( 'steps', where={'step_id': step_id, 'node': child.branch}, limit=1, ) if not rows: raise ValueError(f'Step {step_id} not found.') if rows[0].get('approved') is None: raise ValueError(f'Step {step_id} does not require approval.') step = rows[0]['step'] step_name = rows[0]['step_name'] label = f'step {step} ({step_name})' # log on the parent (run lineage only when it's mid-run, else NULL for # a manual approval), write it, then dual-log on the child (active step) metadata = f'{child.branch}: {label}' parent_event_id = self.record.event_start('approve', metadata=metadata) try: child.record.step_approve(step_id=step_id) except Exception: if parent_event_id is not None: self.record.event_end(event_id=parent_event_id, status='failed') raise # the approval landed (the source of truth); dual-log on the child, then # close the parent event in a finally so a child-side audit failure can # never leave it orphaned (the event rows are best-effort) try: child_event_id = child.record.event_start('approve', metadata=label) if child_event_id is not None: child.record.event_end(event_id=child_event_id, status='completed') finally: if parent_event_id is not None: self.record.event_end(event_id=parent_event_id, status='completed') return step_id
[docs] def child_pending(self: Node) -> list[dict]: """List direct children's steps awaiting this node's approval. One row per direct-child step awaiting approval -- ``approved=''`` on an ``active`` or ``paused`` step row -- as ``{'branch', 'step_id', 'step', 'step_name'}``. Only direct children are listed -- the steps this node can actually approve. Returns: Pending-approval rows across the direct children. """ result: list[dict] = [] for row in self.child_list(max_depth=1) or []: branch = row['node'] for step in self.db.read('steps', where={'node': branch, 'approved': ''}): # a gate releases only into a live wait: 'active' is mid-wait # and 'paused' is parked (approving while parked is the resume # flow) -- any other terminal stranded its gate (a kill, a # crash reconcile, a reserve wind-down), so listing it would # offer an approval nothing will ever read if step['status'] not in ('active', 'paused'): continue pending = { 'branch': branch, 'step_id': step['step_id'], 'step': step['step'], 'step_name': step['step_name'], } result.append(pending) return result
[docs] def commit( self: Node, message: Optional[str] = None, *, init: bool = False, check: bool = False, ignore_scope: bool = False, force: bool = False, ) -> str: """Commit the current iteration's work. Delegates to the ``core.commit`` pipeline (scope check, lint, stage, commit, and push unless ``local`` is set). Args: message: Short description appended to the commit message. init: Use the ``init`` label instead of ``iteration <run>.<iter>``. check: Error if uncommitted changes exist instead of committing. ignore_scope: Commit out-of-scope changes but still lint (a narrower escape hatch than ``force``). force: Bypass scope and lint checks and git hooks. Returns: Commit output and notices. Raises: RuntimeError: If called on a user node without ``init``. ValueError: If flags conflict or ``message`` is missing without ``check``. """ # user nodes take only the --init baseline (commits fractal's own # artifacts on the base branch so a node worktree can branch from a # committed tree); reject an ordinary commit if self.is_user: if not init: raise RuntimeError( 'Cannot commit from a user node (only --init is supported).' ) if not message: raise ValueError('Message is required.') if ignore_scope: raise ValueError('--init cannot be used with --ignore-scope.') if force: raise ValueError('--init cannot be used with --force.') return commit.commit_user_init(self, message) # handle mutually exclusive configurations if init and ignore_scope: raise ValueError('--init cannot be used with --ignore-scope.') if check and ignore_scope: raise ValueError('--check cannot be used with --ignore-scope.') if init and force: raise ValueError('--init cannot be used with --force.') if check and force: raise ValueError('--check cannot be used with --force.') if ignore_scope and force: raise ValueError('--ignore-scope cannot be used with --force.') if init and check: raise ValueError('--init cannot be used with --check.') # validate message if message or check: message = message or '' else: raise ValueError('Message is required unless --check is set.') # run the pipeline (scope check, lint, stage, commit, push); the loop # invokes the pipeline directly with its live iteration and lineage, # so an agent/operator commit resolves the iteration from the open # iteration row (else 0) and the commit event pins to the # active run/iter/step context return commit.commit( node=self, message=message, init=init, check=check, ignore_scope=ignore_scope, force=force, )
def _default_agent(self: Node) -> Optional[str]: """Return the node's default agent (the base of its ``agent`` command).""" if agent := self.config.get('agent'): agent, *_ = agent.split() return agent return None
[docs] def agent( self: Node, command: Optional[str] = None, provider: Optional[str] = None, ) -> Agent: """Return this node's agent backend bound to this node. The accessor is a resolver over a keyed family, not a bound singleton: per-step ``agent:`` overrides mean one node may run several agents in one iteration. Args: command: Full agent command (e.g. ``'claude --some-flag'``); defaults to the node's effective configured agent (own config else the nearest ancestor's, :meth:`agent_effective`). provider: Provider-route override (per-step frontmatter); defaults to the node's effective configured provider. Returns: The registered backend, bound to this node and ``command``. Raises: ValueError: If no agent is configured and none is given, or the base command has no registered backend. """ from .agent import command_base, resolve # resolve the command, refusing an agentless node command = command or self.agent_effective() if command is None: raise ValueError( 'No agent configured; set one with `fractal init --agent`.' ) # resolve the base command against the seam's module-level registry, # threading the tree root so a deployment hook file's subclasses win # across processes (imported at call time -- node.py never imports # agent.py at runtime, keeping the node<->seam boundary one-way) name = command_base(command) if provider is None: provider = self.provider_effective() return resolve(name, root=self.db.path.parent)(self, command, provider)
[docs] def chat( self: Node, prompt: str, *, session: Optional[str] = None, current: bool = False, resume: bool = False, model: Optional[str] = None, render: Optional[Callable[[StreamEvent], None]] = None, ) -> Optional[str]: """Send one prompt to the node's agent and stream the reply. Nothing is inferred by default, so a bare chat is always **fresh** -- a brand-new session whose prompt is seeded with the node's ``NODE.md`` and ``modes/CHAT.md``. ``current`` forks the node's live loop session; ``session`` forks a given id (or, with ``resume``, continues it in place). Forking leaves the source session untouched, so a running loop is never perturbed. Codex can resume in place but cannot fork. Streams with no cost or session side effects and returns the resulting id. Args: prompt: The prompt to send. session: A session id to fork (or continue in place with ``resume``). current: Fork the node's live loop session (mutually exclusive with ``session``/``resume``). resume: Continue ``session`` in place (same id) instead of forking. model: Model override; defaults to the node's configured model. render: Presentation callback receiving every parsed stream event (the CLI passes its ANSI renderer). Returns: The agent's session id, or ``None`` if the stream carried none. Raises: ValueError: No agent configured; incompatible flags; ``current`` with no live session; resuming the live loop session; or forking a codex session. RuntimeError: The agent exited with a non-zero status. """ backend = self.agent() command = self.chat_command( prompt, session=session, current=current, resume=resume, model=model, ) # spawn the agent and stream with no cost/session writes (no step_id), # capturing the resulting session id from the stream proc = backend.spawn(command) # stream, reaping the child even if streaming raises (a codex error # stream raises after draining stdout) so the process is never left # unwaited; a raise MID-drain (parser fault, Ctrl-C) leaves the child # writing into a now-unread pipe, so kill it before the wait -- a # writer blocked on the full pipe would deadlock proc.wait(). chat # spawns a single process (no start_new_session), so kill the PID # directly, not its group try: result = backend.stream(proc.stdout, render=render) except BaseException: if proc.poll() is None: try: proc.kill() except (ProcessLookupError, PermissionError): pass raise finally: returncode = proc.wait() if returncode != 0: raise RuntimeError(f'{command.agent} exited with a non-zero status.') return result.session
[docs] def chat_command( self: Node, prompt: str, *, session: Optional[str] = None, current: bool = False, resume: bool = False, model: Optional[str] = None, ) -> Invocation: """Resolve and validate one chat turn into its agent invocation. The build half of ``chat``: the same validation (incompatible flags, no live session, codex cannot fork) and the same prompt seeding (``NODE.md`` + ``CHAT.md`` fresh, ``CHAT.md`` on a fork, nothing on a resume) -- without spawning anything. A caller that streams the agent output itself (the TUI) spawns the returned invocation. Args: prompt: The prompt to send. session: A session id to fork (or continue in place with ``resume``). current: Fork the node's live loop session (mutually exclusive with ``session``/``resume``). resume: Continue ``session`` in place (same id) instead of forking. model: Model override; defaults to the node's configured model. Returns: The spawnable agent invocation. Raises: ValueError: No agent configured; incompatible flags; ``current`` with no live session; resuming the live loop session; or forking a codex session. """ # resolve the agent backend = self.agent() if model is None: model = self.config.get('model') # apply the node's configured reasoning effort too, so a chat turn runs # at the same depth as the loop's steps (the loop threads this same # config value into every invocation) effort = self.config.get('effort') # the loop's woven session (while running or parked) -- never continued # in place: that would perturb the running loop's turn, or the session # a paused run resumes with live = None if self.status() in ('active', 'paused'): live = self.sessions.get(backend.name) # validate the request: --current forks the live session and is mutually # exclusive with --session/--resume; nothing else is inferred if current and (session is not None or resume): raise ValueError('--current cannot be combined with --session or --resume.') # --current forks the node's live loop session (forking agents only) if current: if not backend.can_fork: # the refusal outranks the live check; route it through the # backend's one no-fork raise site (its message carries the # remedy and citations), surfaced as today's error type -- the # placeholder session steers the doomed build past # the fork-without-a-session guard try: backend.invocation(prompt, session=live or '-', fork=True) except NotImplementedError as e: raise ValueError(str(e)) from e if live is None: raise ValueError( '--current: the node has no live session to fork (it is not' ' running, or has not started a session yet).' ) session = live # --resume requires a session and continues it in place; refuse on the # live loop session (it would perturb the running loop) if resume and session is None: raise ValueError('--resume requires --session (the session to continue).') if resume and session == live: raise ValueError( 'Refusing to resume the loop session in place (it would' ' perturb the running loop, or the session a paused run' ' resumes with); use --current to fork it instead.' ) # a given/current session is forked by default; --resume continues it fork = session is not None and not resume # seed the prompt with chat framing: a fresh chat also gets the node's # NODE.md charter; a fork (the agent was executing the loop) gets CHAT.md # so it knows it is now chatting; a resume continues an already-framed # session and adds nothing if session is None: seed = render.chat_seed(self, fresh=True) elif fork: seed = render.chat_seed(self, fresh=False) else: seed = '' if seed: prompt = f'{seed}\n\n{prompt}' # build the agent invocation (the full configured command, like the # loop); a non-forking backend's NotImplementedError surfaces as # today's error type at the chat surface try: return backend.invocation( prompt, session=session, fork=fork, model=model, effort=effort, ) except NotImplementedError as e: raise ValueError(str(e)) from e
[docs] def render_template( self: Node, template: str, *, overrides: Optional[dict[str, str]] = None, ) -> str: """Substitute the node's ``$VAR`` placeholders into ``template``. The variable map is :func:`render.variables` (``overrides`` win). Substitution matches GNU ``envsubst`` (``$NAME``/``${NAME}`` only; unknown placeholders and ``$$`` pass through verbatim) -- the grammar the renderer is pinned against. Args: template: The text to render. overrides: Variable values layered over (and winning against) the derived map -- the loop passes live run state; a chat passes chat sentinels. Returns: The rendered text. """ return render.render(template, render.variables(self, overrides))
[docs] def build_prompt( self: Node, step_file: str, *, overrides: Optional[dict[str, str]] = None, ) -> str: """Assemble and render a step's full prompt (:func:`render.prompt`). Args: step_file: The step markdown file. overrides: Run-scoped variable values (see :meth:`render_template`). Returns: The rendered prompt. """ step_text = pathlib.Path(step_file).read_text(encoding='utf-8') return render.prompt(self, step_text, overrides)
def _run_script( self: Node, script: str, *args: str, ) -> subprocess.CompletedProcess[str]: """Run a bundled script (delegates to :func:`worktree.run_script`). Args: script: Script filename in ``_scripts/``. *args: Arguments to pass to the script. Returns: Completed process result. """ return worktree.run_script(self._package_dir, script, *args) def _live_descendants( self: Node, *, status: Optional[str] = None, max_depth: Optional[int] = None, ) -> list[tuple[dict, Node]]: """Return ``(row, node)`` for each descendant with a live worktree. The ``nodes`` table is a flat registry of every descendant, but its ``status`` is best-effort (push-updated). This reads each descendant's own ``.status()`` into the row and drops any whose worktree is gone -- the authoritative view of the subtree. Args: status: Include only descendants whose real status matches. max_depth: Maximum depth relative to this node. Returns: A ``(row, node)`` pair per live descendant, the row's ``status`` reconciled to the node's real status. """ result = [] if rows := self.child_list(max_depth=max_depth): for row in rows: worktree_dir = fractal.util.git.find_worktree( repo_dir=self.repo_dir, branch=row['node'], ) if not worktree_dir: continue node = self.__class__(worktree_dir) if not node.exists(): continue real_status = node.status() if status is not None and real_status != status: continue result.append(({**row, 'status': real_status}, node)) return result def _self_and_ancestors(self: Node) -> Iterator[Node]: """Yield this node and each existing ancestor up to the root. Walks the branch name (``a.b.c`` -> ``a.b`` -> ``a``) rather than hopping worktree to worktree, so a pruned intermediate node does not cut the walk short and skip limit enforcement on every ancestor above it -- a missing ancestor is simply skipped while the walk continues toward the root. """ yield self branch = self.branch while '.' in branch: parent_branch, *_ = branch.rsplit('.', 1) parent_worktree_dir = fractal.util.git.find_worktree( repo_dir=self.repo_dir, branch=parent_branch, ) if parent_worktree_dir: parent = self.__class__(parent_worktree_dir) if parent.exists(): yield parent branch = parent_branch
[docs] def agent_effective(self: Node) -> Optional[str]: """Return the effective agent command: own config, else an ancestor's. The spawn-time inheritance walk (:meth:`init`) read back at steering time -- what the node's loop actually runs when its own config names no agent. Returns: The effective agent command, or ``None`` when no node up the chain configures one. """ for ancestor in self._self_and_ancestors(): if agent := ancestor.config.get('agent'): return agent return None
[docs] def provider_effective(self: Node) -> Optional[str]: """Return the effective provider route: own config, else an ancestor's. The spawn-time inheritance walk (:meth:`init`) read back at steering time -- the route a routed backend binds when the node's own config names none; ``None`` means the vendor's own endpoint. Returns: The effective provider route, or ``None`` when no node up the chain configures one. """ for ancestor in self._self_and_ancestors(): if provider := ancestor.config.get('provider'): return provider return None
def _mark_active_killed( self: Node, *, skip: Optional[int] = None, metadata: Optional[str] = None, ) -> None: """Mark every still-open lifecycle row ``killed``. Delegates to :meth:`Record.close_open`: entity rows close first-writer-wins (``killed`` is exit 1), and any stray active event is closed by status, skipping the in-flight kill event (``_kill`` finalizes that one via ``event_end``). Args: skip: An ``events`` row to leave untouched -- the in-flight kill event (avoids a redundant killed-then-completed write). metadata: Attribution stamped on the closing run row. """ self.record.close_open('killed', skip_event=skip, metadata=metadata) @classmethod def _deregister_subtree( cls: type[Node], db: Database, repo_dir: pathlib.Path, branch: str, descendant_branches: list[str], ) -> None: """Clear a torn-down subtree from the central registry. Shared by ``delete`` (worktrees already removed) and ``deregister`` (an orphan with no worktree): removes the subtree's ``nodes`` rows and its subscriptions in both directions. Everything else -- runs, steps, events, messages -- persists, so history outlives the node. The ``delete`` event is logged on the parent when it is still reachable; a missing parent (e.g. a hand-removed ``.fractal``) is warned about, not fatal -- the teardown already happened, so crashing would leave a half-deleted tree. Args: db: The central database (captured before any teardown). repo_dir: Main repo root (captured before any teardown). branch: The subtree root's branch. descendant_branches: Every descendant branch in the subtree. """ # clear the subtree's registry rows and subscriptions (both directions) subtree_branches = [branch, *descendant_branches] for subtree_branch in subtree_branches: db.delete('nodes', where={'node': subtree_branch}) db.delete('subs', where={'node': subtree_branch}) db.delete('subs', where={'target': subtree_branch}) # log the delete on the parent when it is still reachable (run lineage # only when mid-run, else NULL for a manual delete) parts = branch.rsplit('.', 1) if len(parts) != 2: return parent_branch, _ = parts parent_worktree_dir = fractal.util.git.find_worktree(repo_dir, parent_branch) parent = cls(parent_worktree_dir) if parent_worktree_dir else None if parent is not None and parent.exists(): event_id = parent.record.event_start('delete', metadata=branch) if event_id is not None: parent.record.event_end(event_id=event_id, status='completed') else: logger.warning( f'Warning: parent {parent_branch!r} of {branch} is missing;' f' the subtree was removed and deregistered, but no delete' f' event could be logged on its parent.' )
# ------ helper functions
[docs] def node_dir(worktree: PathLike, project: str, branch: str) -> pathlib.Path: """Return the node data directory for explicit inputs (the one derivation). Pure path arithmetic -- no git, no subprocess -- so the TUI's poll path composes it with cached inputs. Under a sub-project the directory nests at ``<worktree>/<project>/.fractal/<branch>``; a repo-root project (``'.'``) puts it at ``<worktree>/.fractal/<branch>``. Args: worktree: The node's worktree path. project: Project sub-path within the worktree (``'.'`` for repo-root). branch: The node's branch. Returns: The node data directory. """ base = pathlib.Path(worktree) if project not in ('', '.'): base = base / project return base / FRACTAL_FOLDER / branch
[docs] def tmux_session_name(repo_dir: PathLike, branch: str) -> str: """Return the tmux session name for a branch. Format is ``<repo_name> (<branch>)``. tmux munges ``.`` and ``:`` in session names (target syntax), so the repo name flattens both to dashes and the branch flattens dots (git refs forbid ``:``) -- must match ``start.sh``. Pure string arithmetic (no git, no subprocess) for the TUI's poll path. Args: repo_dir: Main repo root. branch: The node's branch. Returns: The tmux session name. """ repo_name = pathlib.Path(repo_dir).name.replace('.', '-').replace(':', '-') branch_name = branch.replace('.', '-') return f'{repo_name} ({branch_name})'
def _base_status(status: Optional[str]) -> str: """Return the bare lifecycle status -- the first space-delimited chunk. A status may carry a pending-signal suffix for display (e.g. ``active (stopping)``); filters and comparisons match on the base token. Args: status: Status string, possibly decorated (``None`` tolerated). Returns: The undecorated status token (empty for ``None``). """ result, *_ = (status or '').partition(' ') return result def _recorded_group(pgid: int, recorded_at: float) -> bool: """Return whether a live process group is the one its record named. A group id is its leader's pid, and the leader is already running when the loop records it -- so a leader ``ps`` dates *after* the record is a recycled pid fronting an unrelated group. A group that outlived its leader still matches: the OS cannot re-issue the id while any member survives. No answer to arbitrate with (``ps`` failed, an unparseable instant) reads as not the recorded group -- sparing a stranger beats reaping a maybe-orphan. Args: pgid: A live process group id (its leader's pid). recorded_at: Epoch instant the group was recorded. Returns: Whether the group can be treated as the recorded one. """ # ask ps for the leader's start instant (LC_ALL pins the format) env = {**os.environ, 'LC_ALL': 'C'} try: result = subprocess.run( ['ps', '-p', f'{pgid}', '-o', 'lstart='], capture_output=True, text=True, env=env, ) except OSError: return False lstart = result.stdout.strip() # no process wears the leader's pid: the live group outlived its # leader, which pins its identity if result.returncode != 0 or not lstart: return True try: started = time.mktime(time.strptime(lstart, '%a %b %d %H:%M:%S %Y')) except ValueError: return False # a second of slack: lstart floors to the second, and the record # follows the leader's spawn within the same one return started <= recorded_at + 1.0