Source code for fractal.core.config

"""Implements ``Config`` class."""

from __future__ import annotations

import fcntl
import json
import math
import pathlib
import typing
from typing import Any, Optional

import fractal.util
from fractal.constants import CONFIG_FILE, LOCK_FILE

if typing.TYPE_CHECKING:
    from .node import Node

__all__ = []

# configuration schema (single source): every key a node config may hold
KEYS = (
    'title',
    'user',
    'project',
    'root',
    'scope',
    'base',
    'meta',
    'agent',
    'provider',
    'model',
    'effort',
    'max_iters',
    'max_depth',
    'max_children',
    'max_descendants',
    'timeout',
    'iter_timeout',
    'step_timeout',
    'step_retries',
    'step_retry_backoff',
    'interval',
    'sleep',
    'wait',
    'max_cost',
    'max_iter_cost',
    'max_step_cost',
    'reserve_budget',
    'sync',
    'detached',
    'local',
    'blind',
)

# keys whose value must be a plain bool (true/false/null)
BOOL_KEYS = (
    'user',
    'sync',
    'detached',
    'local',
    'blind',
)

# keys whose value must be a non-negative integer cap
INT_KEYS = (
    'max_iters',
    'max_depth',
    'max_children',
    'max_descendants',
    'step_retries',
)

# keys whose value must be a non-negative USD amount
COST_KEYS = (
    'max_cost',
    'max_iter_cost',
    'max_step_cost',
    'reserve_budget',
)

# keys whose value must be a duration with a unit suffix
DURATION_KEYS = (
    'timeout',
    'iter_timeout',
    'step_timeout',
    'step_retry_backoff',
    'interval',
    'sleep',
    'wait',
)

# keys fixed at init: 'root' anchors the central database for the whole
# tree, 'user' marks node identity (flipping it would let a root branch be
# started as a loop), 'project' fixes the on-disk layout the immutable
# .worktrees/.project cache mirrors (track/untrack, lint.sh, and merge.sh
# all read it -- a post-init change desyncs them from the real paths)
IMMUTABLE_KEYS = ('root', 'user', 'project')

# default cleanup reserve as a fraction of max_cost
DEFAULT_RESERVE_FRACTION = 0.10

# decimal places a reserve amount materializes to -- a percent product
# rounds here so no binary float noise persists into config.json, and
# child_retune's default-detection equality matches what parse_reserve_budget
# stored; the two sites MUST round identically, so both quote this constant
RESERVE_PRECISION = 4


[docs] class Config: """Read/write surface for a node's ``config.json``. Reads from disk on every access -- nothing is cached -- so writes from other processes (CLI commands, lifecycle scripts) are immediately visible. """ def __init__(self: Config, node: Node) -> None: """Initialize ``Config``. Args: node: The owning ``Node`` instance. """ self._node = node @property def node(self: Config) -> Node: """Return the owning node.""" return self._node @property def path(self: Config) -> pathlib.Path: """Return the path to the node's ``config.json``.""" return self._node.node_dir / CONFIG_FILE
[docs] def load(self: Config) -> dict[str, Any]: """Read and parse the node's ``config.json``. A hand-corrupted config otherwise yields a context-free ``Expecting value: line 1 column 1`` from every command that touches it; this re-raises with the file path so the error points at what to fix. Returns: The parsed config mapping. Raises: ValueError: When ``config.json`` does not hold valid JSON. """ path = self.path try: return json.loads(path.read_text(encoding='utf-8')) except json.JSONDecodeError as e: raise ValueError(f'{path} is not valid JSON: {e}') from e
[docs] def get(self: Config, key: str, default: Any = None) -> Any: """Read a config value. Args: key: Config key. default: Value to return when the key or config file is missing. Returns: Config value, or ``default`` if missing. """ if self.path.exists(): config = self.load() value = config.get(key, default) # scope is stored as a JSON list; tolerate a string value # holding the roots space-joined if key == 'scope' and isinstance(value, str): value = value.split() return value return default
[docs] def set(self: Config, key: str, value: Any) -> None: """Read-merge-write one config key. The read-merge-write holds a per-config kernel flock, so setters in concurrent processes never revert each other's keys. Args: key: Config key. value: Value to write. Raises: ValueError: When ``key`` is fixed at init (:data:`IMMUTABLE_KEYS`) and ``value`` would change its stored value. """ # immutable keys admit their initial write but never a change (see # the IMMUTABLE_KEYS rationale) if key in IMMUTABLE_KEYS: current = self.get(key) if current is not None and value != current: raise ValueError(f'{key} is fixed at init and cannot be changed.') # serialize the read-merge-write across processes with a kernel flock # on a config sidecar (auto-released if the holder dies) -- concurrent # setters (a parent retune racing the child's own title write) # otherwise load the same snapshot and the loser's key silently # reverts; a sidecar, not the tree-wide .worktrees flock: init holds # that lock across init.sh, whose `config _set` lands here in a # subprocess, so re-taking it would deadlock every init lock_path = self.path.parent / (CONFIG_FILE + LOCK_FILE) with open(lock_path, 'a', encoding='utf-8') as lock_file: fcntl.flock(lock_file, fcntl.LOCK_EX) # read existing config config = {} if self.path.exists(): config = self.load() # merge value config[key] = value # write config -- atomic, so a kill mid-retune cannot tear the file # a later node load would refuse to parse text = json.dumps(config, indent=2) fractal.util.filesystem.write_atomic(self.path, text + '\n')
[docs] def validate(self: Config, config: Optional[dict[str, Any]] = None) -> None: """Validate the launch-time config invariants the loop depends on. The one launch validator. Checks only the keys present (and not ``None``) in ``config``, so it can be called with a node's effective (merged) config; ``None`` validates the stored ``config.json`` -- the :meth:`Node.start` re-check, since the documented steering path edits the file directly, bypassing the init/update setters' checks. Rejects a non-numeric or non-finite cost (NaN/Infinity slip past every comparison), a non-positive ceiling (which makes the subtree check finish the node at $0), a per-iter/step cap with no ceiling (which the loop cannot enforce once the per-iter budget drains), an out-of-range reserve, a broken ``step <= iter <= run`` cost ordering, a non-integer or degenerate integer cap (a non-positive ``max_iters`` reads as unlimited in the loop), a non-bool mode flag (the loop's ``bool()`` coercion reads a hand-edited ``"false"`` string as ``True``), a bare-number or zero-truncating duration (which bricks the loop at launch or at its mid-run re-reads), and an absolute or ``..`` scope root (which never matches the commit pipeline's relative prefix check, bricking every scoped commit). Args: config: The config mapping to validate; the stored ``config.json`` if omitted. Raises: ValueError: On any violated invariant. """ # load the stored config when none is given if config is None: config = self.load() # cost values must be numeric -- a hand-edited file can hold any JSON # type, and bool subclasses int, so check the exact type -- and # finite: NaN/Infinity slip past every comparison below (all False # for non-finite floats), so reject both up front for cost_key in COST_KEYS: cost_value = config.get(cost_key) if cost_value is None: continue if type(cost_value) not in (int, float): raise ValueError(f'{cost_key} must be a number.') if not math.isfinite(cost_value): raise ValueError(f'{cost_key} must be a finite number.') # integer caps must be plain ints (bool excluded, as above) and # non-negative; max_iters must be positive (a non-positive cap reads # as unlimited in the loop) for int_key in INT_KEYS: int_value = config.get(int_key) if int_value is None: continue if type(int_value) is not int: raise ValueError(f'{int_key} must be an integer.') if int_key == 'max_iters': if int_value <= 0: raise ValueError('max_iters must be greater than 0.') elif int_value < 0: raise ValueError(f'{int_key} must be >= 0.') # mode flags must be plain bools -- the loop coerces them with # bool(), so a hand-edited "false" string reads as True and launches # the exact mode the operator wrote the file to disable for bool_key in BOOL_KEYS: bool_value = config.get(bool_key) if bool_value is None: continue if type(bool_value) is not bool: raise ValueError(f'{bool_key} must be a boolean.') # alias cost ceilings max_cost = config.get('max_cost') max_iter_cost = config.get('max_iter_cost') max_step_cost = config.get('max_step_cost') reserve_budget = config.get('reserve_budget') # a ceiling must be positive: 0/negative degenerates the subtree check, # and a non-positive per-iter/step cap floors the step leash at 0 so # every step skips 'over budget' -- an empty-iteration spin that books # a dishonest 'over budget' on a run that never spent for cap_key, cap in ( ('max_cost', max_cost), ('max_iter_cost', max_iter_cost), ('max_step_cost', max_step_cost), ): if cap is not None and cap <= 0: raise ValueError(f'{cap_key} must be greater than 0.') # a per-iter/step cap with no run ceiling can't be enforced (once the # per-iter budget drains, later steps run unbounded) -- init rejects # the combination, so the file re-check must too if 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.') # reserve must sit in [0, 99% of max_cost) if reserve_budget is not None: if reserve_budget < 0: raise ValueError('reserve_budget must be >= 0.') if max_cost is not None and reserve_budget >= 0.99 * max_cost: raise ValueError('reserve_budget must be < 99% of max_cost.') # cost ordering: step <= iter <= run if max_iter_cost is not None and max_cost is not None: if max_iter_cost > max_cost: raise ValueError( f'max_iter_cost ${max_iter_cost:.2f}' f' exceeds max_cost ${max_cost:.2f}.' ) if max_step_cost is not None and max_iter_cost is not None: if max_step_cost > max_iter_cost: raise ValueError( f'max_step_cost ${max_step_cost:.2f}' f' exceeds max_iter_cost ${max_iter_cost:.2f}.' ) if max_step_cost is not None and max_cost is not None: if max_step_cost > max_cost: raise ValueError( f'max_step_cost ${max_step_cost:.2f}' f' exceeds max_cost ${max_cost:.2f}.' ) # durations must carry a unit suffix (a bare number bricks the loop) # and a whole second -- the loop truncates to integral seconds and # rejects zero, so a stored 0s/0.5s would crash its mid-run re-reads durations: dict[str, float] = {} for key in DURATION_KEYS: value = config.get(key) if value is None: continue seconds = fractal.util.parse_duration_seconds(str(value)) if seconds is None: raise ValueError( f'{key} must be a duration with a unit suffix' ' (e.g. 30s, 10m, 1.5h).' ) if int(seconds) <= 0: raise ValueError(f'{key} must be at least 1 second.') durations[key] = seconds # pacing invariants the loop enforces at construction -- checked here # too so a bad combination is refused at init/start, not deep inside # the tmux pane where it strands the node idle with no run row interval = durations.get('interval') if interval is not None: # interval and sleep are rival pacing keys (fixed cadence vs # fixed gap) -- the loop rejects both set if durations.get('sleep') is not None: raise ValueError('interval and sleep are mutually exclusive.') # interval caps the per-iteration timeout (an iteration cannot run # past its slot) iter_timeout = durations.get('iter_timeout') if iter_timeout is not None and iter_timeout > interval: iter_timeout_value = config.get('iter_timeout') interval_value = config.get('interval') raise ValueError( f'iter_timeout ({iter_timeout_value}) exceeds' f' interval ({interval_value}).' ) # scope roots must be repo-relative subdirectories, exactly as init # enforces -- an absolute or '..' root (any JSON type can land here # via a hand-edit, as above) never matches the commit pipeline's # relative prefix check, bricking every scoped commit scope = config.get('scope') if scope is not None: # tolerate the space-joined string form the read path splits if isinstance(scope, str): scope = scope.split() scope_is_list = isinstance(scope, list) if not scope_is_list or not all(isinstance(root, str) for root in scope): raise ValueError('scope must be a list of strings.') for root in scope: rel = pathlib.PurePosixPath(root) if rel.is_absolute() or '..' in rel.parts: raise ValueError( f'scope must be a repo-relative subdirectory, not' f' {root!r} (no absolute or ".." paths).' )
[docs] def reconcile(self: Config) -> dict[str, tuple[Any, Any]]: """Reconcile the registry cap row to this node's config file. The loop enforces the caps in ``config.json``, so a post-spawn config edit is live budget truth -- but the ``nodes`` registry row keeps the spawn-time values, and a registry reader can kill a node at a stale cap. Config wins: drifted caps are pushed over the registry row and reported. Keys absent from the config are left alone (a registry-only cap is its own problem, not drift); ``fractal node update`` writes both sides in one step and remains the supported retune path. Returns: Mapping of drifted key to ``(config, registry)`` values -- empty when nothing drifted or the node has no registry row (user/root node). """ # skip nodes without a registry row (user/root node) branch = self._node.branch rows = self._node.db.read('nodes', where={'node': branch}, limit=1) if not rows: return {} row, *_ = rows # collect config caps that drifted from the registry row drifted = {} for key in ('max_cost', 'max_depth', 'max_children', 'max_descendants'): config_value = self.get(key) if config_value is not None and config_value != row[key]: drifted[key] = (config_value, row[key]) if not drifted: return {} # config wins: heal the registry row to the config caps updates = {key: values[0] for key, values in drifted.items()} self._node.db.update(updates, 'nodes', where={'node': branch}) return drifted