From 4324ae04063baeeb3aff440f8dee93b312876d8e Mon Sep 17 00:00:00 2001 From: scosman Date: Mon, 12 Oct 2026 21:56:24 +0000 Subject: [PATCH 1/2] Add advance_clock to move an instance's clock forward (#47) A harness can now move virtual time forward deterministically, to reach a retry window or a due date without writing to Clock._start. - Clock keeps a private, millisecond-cut offset that every mode but `wall` adds to its reading: fixed is start + advanced, tick adds a second per call on top, running adds elapsed real time. The public Clock API stays read-only, so world code cannot move time. - Instance.advance_clock(by=None, *, to=None) takes exactly one of a timedelta or an instant (either form now= takes) and answers the new reading as canonical text. It runs under the instance lock, is not a call (no ordinal, no tick, no call-log entry), and every node, inspect() and the next freeze read the moved clock. It is refused, as WorldBug, for a backward move, a wall clock, a move past year 9999, a wrong argument type, and inside a tool call (transaction=False included), bulk() or a state formatter. - New control tool controller_advance_clock(seconds, to), behind the existing --include-control-tools flag and never listed, answering {"now", "clock_mode"}. Every refusal reaches the harness as a ToolError coded clock_not_advanced, in the wire's argument names, rather than as a WorldBug the OpenEnv layer would scrub to "internal error". Its name joins CONTROL_TOOL_NAMES; it is not deprecated. - Docstrings, CLI help and docs that described a single deprecated control tool now describe the control tools. clock.md gains a short "Moving the clock forward" section with a runnable example, and serving_and_openenv.md a "Moving the clock" subsection; api.md notes that a comment-led statement on inspect() can keep a pre-advance reading under fixed, as it already can under tick. - Tests drive the feature through world.instance(...), the control tool, an OpenEnv step and the real wire, with a test per refusal guard. The docs rule against naming control tools now applies only to deprecated ones, and the reference world's registry test includes the new name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QtPPDeDagBgpFidJmRVQzz --- src/seahaven/cli/serve.py | 5 +- src/seahaven/clock.py | 38 ++- src/seahaven/control.py | 101 ++++++-- src/seahaven/db.py | 2 +- src/seahaven/docs/authoring.md | 6 +- src/seahaven/docs/clock.md | 37 ++- src/seahaven/docs/composition.md | 4 +- src/seahaven/docs/extensions.md | 6 +- src/seahaven/docs/index.md | 2 +- src/seahaven/docs/reference/api.md | 29 ++- src/seahaven/docs/reference/cli.md | 2 +- src/seahaven/docs/serving_and_openenv.md | 30 ++- src/seahaven/docs/testing.md | 2 +- src/seahaven/instances.py | 104 ++++++++- src/seahaven/openenv/__init__.py | 13 +- src/seahaven/tool.py | 2 +- src/seahaven/world.py | 50 ++-- tests/test_clock.py | 46 ++++ tests/test_control.py | 120 +++++++++- tests/test_docs.py | 23 +- tests/test_env.py | 57 ++++- tests/test_instances.py | 241 ++++++++++++++++++++ tests/test_server.py | 20 ++ tests/test_world.py | 14 +- worlds/projecttracker/tests/test_package.py | 7 +- 25 files changed, 822 insertions(+), 139 deletions(-) diff --git a/src/seahaven/cli/serve.py b/src/seahaven/cli/serve.py index 750bf0d..490df7b 100644 --- a/src/seahaven/cli/serve.py +++ b/src/seahaven/cli/serve.py @@ -51,8 +51,9 @@ def add_parser(subcommands: argparse._SubParsersAction[argparse.ArgumentParser]) parser.add_argument( "--include-control-tools", action="store_true", - help="make the control tool callable in each session; without the flag its name is " - "an unknown tool, and it is never listed either way", + help="make the control tools, such as controller_advance_clock, callable in each " + "session; without the flag their names are unknown tools, and they are never listed " + "either way", ) parser.add_argument( "--no-console", diff --git a/src/seahaven/clock.py b/src/seahaven/clock.py index b2d6255..130ea34 100644 --- a/src/seahaven/clock.py +++ b/src/seahaven/clock.py @@ -1,11 +1,12 @@ """An instance's clock, and the SQLite date and time functions that read it. The clock has a mode, chosen when the instance is made and kept for its life: -`fixed` reads the start instant for ever, `tick` adds a second per call, -`running` adds the real time elapsed since the clock was made, and `wall` reads -the host's clock. Every reading is computed when it is asked for, so one `Clock` -object shared by reference is the single source of time for every door of an -instance. +`fixed` reads the start instant, `tick` adds a second per call, `running` adds +the real time elapsed since the clock was made, and `wall` reads the host's +clock. Every mode but `wall` also adds however far the instance has advanced it +(`Instance.advance_clock`). Every reading is computed when it is asked for, so +one `Clock` object shared by reference is the single source of time for every +door of an instance. Nothing inside a world may read the wall clock directly, and SQL is the door that is easy to forget: `CURRENT_TIMESTAMP` and friends are parsed as @@ -46,6 +47,7 @@ CLOCK_MODES: tuple[ClockMode, ...] = ("fixed", "tick", "running", "wall") DEFAULT_CLOCK_MODE: ClockMode = "running" TICK = timedelta(seconds=1) +_MILLISECOND = timedelta(milliseconds=1) # The `id` the per-statement trace is registered under. APSW keeps one trace per # id on a connection, so another trace added under a different id leaves this one @@ -146,16 +148,20 @@ class Clock: The framework makes one when it creates an instance, in the instance's mode, and every connection and context of the instance reads that one object. - `Clock(datetime)` built directly is a `fixed` clock at that instant, which is - also the way to render a datetime as canonical text. Building a `Clock` does - not choose or change any instance's mode: that is the `clock_mode` reset - option, and `World(default_clock_mode=...)` for the world's default. + World code reads it and cannot move it; a harness moves an instance's clock + forward with `inst.advance_clock(...)`. `Clock(datetime)` built directly is a + `fixed` clock at that instant, which is also the way to render a datetime as + canonical text. Building a `Clock` does not choose or change any instance's + mode: that is the `clock_mode` reset option, and + `World(default_clock_mode=...)` for the world's default. """ def __init__(self, now: datetime, mode: ClockMode = "fixed") -> None: self._start = _truncated(now) self._mode = check_clock_mode(mode) self._calls = 0 + # How far the instance has moved the clock forward, in whole milliseconds. + self._advanced = timedelta() self._origin_ns = _monotonic_ns() @classmethod @@ -180,12 +186,12 @@ def now(self) -> datetime: """The current reading. Reading the clock never moves it.""" match self._mode: case "fixed": - return self._start + return self._start + self._advanced case "tick": - return self._start + self._calls * TICK + return self._start + self._advanced + self._calls * TICK case "running": elapsed_ms = (_monotonic_ns() - self._origin_ns) // 1_000_000 - return self._start + timedelta(milliseconds=elapsed_ms) + return self._start + self._advanced + timedelta(milliseconds=elapsed_ms) case "wall": return _truncated(_wall_now()) @@ -198,6 +204,14 @@ def _call_started(self) -> None: """Count a call. The instance calls this as a call takes its ordinal.""" self._calls += 1 + def _advance(self, by: timedelta) -> None: + """Move the reading forward by `by`, cut to whole milliseconds. + + The instance calls this from `advance_clock`, which has already refused + a `wall` clock and a negative `by`. + """ + self._advanced += by // _MILLISECOND * _MILLISECOND + def __repr__(self) -> str: return f"Clock({self.iso()}, {self._mode})" diff --git a/src/seahaven/control.py b/src/seahaven/control.py index ff75227..2287268 100644 --- a/src/seahaven/control.py +++ b/src/seahaven/control.py @@ -1,10 +1,10 @@ -"""The framework's own tool: what an eval asks an instance, not what an agent does. +"""The framework's own tools: what an eval does to an instance, not what an agent does. -`controller_run_sql` is registered on every world, is never in its tool list, and +Each control tool is registered on every world, is never in its tool list, and is dispatched straight from `Instance.call` -- around the middleware chain, the world's error handler, the per-call transaction and the concurrency gate. An eval -asking what an instance holds wants the real answer and the real message, and it -must not have to wait behind the agent's queue to get one. +driving an instance wants the real answer and the real message, and it must not +have to wait behind the agent's queue to get one. Registration is not permission. Only an instance made with `world.instance(..., control_tools=True)` can call one; on every other instance @@ -12,23 +12,27 @@ earns. The registration is what lets `World._add` keep refusing a world that tries to register the name itself. -It is deprecated: `inst.state()` is what an eval reads now, and every call -through here warns. It is a thin wrapper -- a read-only handle on the instance is -what it reads through -- and it writes no SQL of its own. It covers a composite -instance's every node without a line here that knows it, because the handle -carries each added node attached under its own schema. +`controller_advance_clock` is `Instance.advance_clock` for a harness at the other +end of the OpenEnv wire, where nothing else reaches one session's instance: a +`reset` only sets where the clock starts. + +`controller_run_sql` is deprecated: `inst.state()` is what an eval reads now, and +every call of it warns. It is a thin wrapper -- a read-only handle on the +instance is what it reads through -- and it writes no SQL of its own. It covers a +composite instance's every node without a line here that knows it, because the +handle carries each added node attached under its own schema. A control call takes the instance lock like any call, so it never interleaves with a step on the same instance, and it asks the instance for what it needs with that lock already held, which is why the lock is an `RLock`. -The handle it reads through is the instance's control handle and not the -`inspect()` one an eval holds. Same file, same read-only opener, different -connection: `sandbox.run_statement` borrows connection-level state -- the -authorizer, the value limit -- for the length of a statement, and `inspect()` -reads do not take the instance lock, so a control read on that connection would -be a second thread changing it under a cursor that is stepping. See -`Instance._control_db`. +The handle `controller_run_sql` reads through is the instance's control handle +and not the `inspect()` one an eval holds. Same file, same read-only opener, +different connection: `sandbox.run_statement` borrows connection-level state -- +the authorizer, the value limit -- for the length of a statement, and +`inspect()` reads do not take the instance lock, so a control read on that +connection would be a second thread changing it under a cursor that is +stepping. See `Instance._control_db`. """ import dataclasses @@ -37,28 +41,40 @@ import os import warnings from collections.abc import Callable +from datetime import timedelta from types import FunctionType -from typing import Any +from typing import Annotated, Any import apsw +from pydantic import Field from seahaven import sandbox from seahaven.call import serialise from seahaven.ctx import Ctx from seahaven.db import SqlValue -from seahaven.errors import DbError, WorldBug +from seahaven.errors import DbError, ToolError, WorldBug from seahaven.helpers.run_sql import showing_sqlite_text, to_result from seahaven.instances import Instance from seahaven.sandbox import Authorizer from seahaven.tool import Tool -__all__ = ["DEPRECATED", "TOOLS", "controller_run_sql", "dispatch"] +__all__ = [ + "CLOCK_NOT_ADVANCED", + "DEPRECATED", + "TOOLS", + "controller_advance_clock", + "controller_run_sql", + "dispatch", +] # The control tools whose calls warn. `functional_spec.md` §11 keeps # `controller_run_sql` working and schedules no removal for it; `state()` is what # replaced it. DEPRECATED = frozenset({"controller_run_sql"}) +# The code of every refusal `controller_advance_clock` answers. +CLOCK_NOT_ADVANCED = "clock_not_advanced" + # Every frame inside the framework, so that `warnings.warn` walks past all of # them and reports the caller's own line. A `stacklevel` cannot do this: a # control tool is reached through `Instance.call` in process, through the OpenEnv @@ -67,6 +83,44 @@ _FRAMEWORK = (os.path.dirname(os.path.abspath(__file__)) + os.sep,) +def controller_advance_clock( + instance: Instance, + ctx: Ctx, + seconds: Annotated[float, Field(ge=0, allow_inf_nan=False)] | None = None, + to: str | None = None, +) -> dict[str, str]: + """Move the instance's clock forward, by `seconds` or to the instant `to`. + + Give exactly one. `to` is a timestamp in any form `reset` takes for `now`. + Answers the clock's new reading as `now`, and its mode as `clock_mode`. A + refusal is a tool error coded `clock_not_advanced`: a `to` earlier than the + clock's reading, a `wall` clock, or both arguments or neither. + """ + # Refused here rather than by `advance_clock`, whose refusal names its own + # `by=`: the caller sent `seconds`. + if (seconds is None) == (to is None): + raise ToolError( + CLOCK_NOT_ADVANCED, "controller_advance_clock takes exactly one of seconds and to" + ) + try: + reading = instance.advance_clock(None if seconds is None else _duration(seconds), to=to) + except WorldBug as error: + # A `WorldBug` reaches an OpenEnv harness as "internal error" and nothing + # more (`SeahavenEnv._call`), and every refusal here is the harness's to + # read and act on. + raise ToolError(CLOCK_NOT_ADVANCED, str(error)) from error + return {"now": reading, "clock_mode": instance.clock.mode} + + +def _duration(seconds: float) -> timedelta: + try: + return timedelta(seconds=seconds) + except OverflowError: + # More than a `timedelta` holds is further than any clock can move, and + # `advance_clock` refuses that in its own words. + return timedelta.max + + def controller_run_sql( instance: Instance, ctx: Ctx, sql: str, params: list[SqlValue] | None = None ) -> dict[str, Any]: @@ -219,6 +273,9 @@ def _control_tool(fn: FunctionType) -> Tool: return dataclasses.replace(tool, fn=fn, control=True) -# Registered on every `World` at construction. Built once, at import: it carries -# no world and no instance, so one tool serves every world in the process. -TOOLS: tuple[Tool, ...] = (_control_tool(controller_run_sql),) +# Registered on every `World` at construction. Built once, at import: they carry +# no world and no instance, so one of each serves every world in the process. +TOOLS: tuple[Tool, ...] = ( + _control_tool(controller_run_sql), + _control_tool(controller_advance_clock), +) diff --git a/src/seahaven/db.py b/src/seahaven/db.py index d136d18..5de58ff 100644 --- a/src/seahaven/db.py +++ b/src/seahaven/db.py @@ -295,7 +295,7 @@ def open_inspection( """Open a second, read-only view of an instance for looking at its state. `stream` names which read-only door this is -- `INSPECTION_STREAM` for the - handle a caller reads state through, `CONTROL_STREAM` for the control tool's + handle a caller reads state through, `CONTROL_STREAM` for `controller_run_sql`'s own -- so that two doors onto one instance do not hand out the same random values. See `ids.register_random_functions`. One door is one stream whatever it is attached to: a composite instance's read-only handles each draw from a diff --git a/src/seahaven/docs/authoring.md b/src/seahaven/docs/authoring.md index bc69355..b0766a4 100644 --- a/src/seahaven/docs/authoring.md +++ b/src/seahaven/docs/authoring.md @@ -308,8 +308,8 @@ All of it happens at import, with a message naming the tool and the parameter. A when it: - duplicates another tool's name; -- is named `reset`, `step`, `state` or `close` (OpenEnv reserves those), or the control tool that - [reference/cli.md](reference/cli.md) names; +- is named `reset`, `step`, `state` or `close` (OpenEnv reserves those), or has the name of a + control tool ([reference/cli.md](reference/cli.md) names them); - is an `async def`, a generator, or an async generator; - has no first parameter, or one that is not positional and either annotated `seahaven.Ctx` or left unannotated; @@ -509,7 +509,7 @@ Common uses: together. - **What it wraps.** Middleware runs for every tool call, including the helpers' and an extension's. It does not run for `UnknownTool`, a tool listing, startup hooks or the control - tool. + tools. ## Instance startup diff --git a/src/seahaven/docs/clock.md b/src/seahaven/docs/clock.md index bad10de..770b687 100644 --- a/src/seahaven/docs/clock.md +++ b/src/seahaven/docs/clock.md @@ -2,7 +2,8 @@ Every instance has a clock of its own, and everything in the world reads it: world code through `ctx.clock`, and SQL through SQLite's own date and time functions. This page explains how the clock -starts, the four modes that say how it moves, and what they mean for the timestamps a world writes. +starts, the four modes that say how it moves, how a harness moves it forward, and what that means +for the timestamps a world writes. ## Modes @@ -12,11 +13,14 @@ how the clock moves from there: | Mode | What the clock reads | Replays | |---|---|---| -| `fixed` | The start instant, for the instance's whole life | Yes | +| `fixed` | The start instant | Yes | | `tick` | The start instant plus one second per tool call dispatched so far | Yes | | `running` | The start instant plus the real time elapsed since the instance was created | No | | `wall` | The host's current UTC time. The start instant is not used | No | +Under every mode but `wall`, a harness can also move the clock forward. See +[Moving the clock forward](#moving-the-clock-forward). + ## Choosing a mode `running` is the default. A world sets its own default with `World(default_clock_mode=...)`. One @@ -98,6 +102,35 @@ with world.instance("small_startup", now="2026-06-04T09:00:00.000Z", clock_mode= A `now=` earlier than the fixture's `now` is refused. The fixture's rows are dated by its clock, so an earlier start would give the instance rows created in its future. +## Moving the clock forward + +A harness can move an instance's clock forward between calls, so that a retry window or a due date +arrives without waiting for it. `inst.advance_clock(by)` moves the clock by a `timedelta`, and +`inst.advance_clock(to=...)` moves it to an instant, in either form `now=` takes. Both answer the +new reading: + +```python +from datetime import timedelta + +import projecttracker + +world = projecttracker.world + +with world.instance("small_startup", clock_mode="tick") as inst: + assert inst.advance_clock(timedelta(days=2)) == "2026-06-03T09:00:00.000Z" + inst.call("list_teams") + assert inst.clock.iso() == "2026-06-03T09:00:01.000Z" + assert inst.advance_clock(to="2026-06-04T09:00:00.000Z") == "2026-06-04T09:00:00.000Z" +``` + +The clock keeps its mode and goes on from the new reading: `tick` still adds a second per call, and +`running` still runs. An advance is not a call, so it does not tick and is not in the call log. +The clock only moves forward, and a `wall` clock cannot be moved. World code reads the clock and +cannot move it. + +Over a server, a harness calls the `controller_advance_clock` control tool instead. See +[serving_and_openenv.md](serving_and_openenv.md#moving-the-clock). + ## Timestamps are not a complete order Design around one consequence: **a timestamp is not a complete order.** Under `fixed`, every row diff --git a/src/seahaven/docs/composition.md b/src/seahaven/docs/composition.md index 263ae32..40894eb 100644 --- a/src/seahaven/docs/composition.md +++ b/src/seahaven/docs/composition.md @@ -171,8 +171,8 @@ observes**, so a host that cares orders its `add_world` calls to match the clien Three rules fall out of that: - After filtering and prefixing, every name must be unique across the whole tree, and must not be - one of the reserved names (`reset`, `step`, `state`, `close`, and the control tool). A - collision is a seal error, never resolved silently. + one of the reserved names (`reset`, `step`, `state`, `close`, and the names of the control + tools). A collision is a seal error, never resolved silently. - A prefixed name must still be a valid tool name (`^[A-Za-z0-9_-]{1,128}$`), so a `tool_prefix` of `"payments."` is refused rather than published. - Control tools are never contributed. Every world registers them, but a call by that name resolves diff --git a/src/seahaven/docs/extensions.md b/src/seahaven/docs/extensions.md index 8898b5a..363a99e 100644 --- a/src/seahaven/docs/extensions.md +++ b/src/seahaven/docs/extensions.md @@ -78,9 +78,9 @@ errors (`WorldBug`, `ToolError`, `ArgumentError`, `DbError`, `UnknownTool`). - **Register itself.** The world registers what it wants, by name, in its own `__init__`. An extension that registered itself on import would make a world's tool surface depend on its import order. -- **Set `control`.** That flag is Seahaven's own, for its control tool, which is listed nowhere and - is callable only on an instance made with `control_tools=True`. `Tool.from_function` cannot set - it. +- **Set `control`.** That flag is Seahaven's own, for its control tools, which are listed nowhere + and are callable only on an instance made with `control_tools=True`. `Tool.from_function` cannot + set it. ## The worked example: XML-RPC diff --git a/src/seahaven/docs/index.md b/src/seahaven/docs/index.md index c0ef980..ae50cde 100644 --- a/src/seahaven/docs/index.md +++ b/src/seahaven/docs/index.md @@ -98,7 +98,7 @@ Read these in order the first time. Each page assumes the ones above it. | [concepts.md](concepts.md) | The nine words the rest of the docs use: world, tool, schema, fixture, instance, context, clock, reproducibility, change log | | [authoring.md](authoring.md) | Writing a world: tools, arguments, transactions, errors, middleware, startup hooks | | [db_schema_and_fixtures.md](db_schema_and_fixtures.md) | The database, the schema rules, and how fixtures are built and kept | -| [clock.md](clock.md) | The instance's clock: the four modes, choosing one, and what they mean for timestamps | +| [clock.md](clock.md) | The instance's clock: the four modes, choosing one, moving it forward, and what they mean for timestamps | | [testing.md](testing.md) | The pytest plugin, and what is worth testing in a world | | [state.md](state.md) | The document an eval grades on: the change log, the formats, and the fold | | [serving_and_openenv.md](serving_and_openenv.md) | `seahaven serve`, driving a world over the network, publishing it, and `seahaven mcp` | diff --git a/src/seahaven/docs/reference/api.md b/src/seahaven/docs/reference/api.md index 8ec2ffe..19f2dc4 100644 --- a/src/seahaven/docs/reference/api.md +++ b/src/seahaven/docs/reference/api.md @@ -120,11 +120,11 @@ a NUL, an accent, a non-Latin script. A **fixture id** follows the same rule, an | `world.add_world(other, *, name=None, store=None, tool_prefix=None, tool_allow_list=None, tool_block_list=None, startup=None)` | add another world: its tools join this world's surface, its store becomes a node of every instance. Call it; there is nothing to decorate | | `world.state_format(name)` | register a state format of this world's own, as a decorator. The name is `/` and may not begin with `seahaven.` | | `world.resolve_state_format(name)` | the formatter a name answers to: a built-in, or one this world registered. Asked of the root of an instance and of nothing else | -| `world.instance(fixture=None, *, seed=None, now=None, clock_mode=None, state_format=None, control_tools=False, startup=None, setup_sql=None)` | make an instance; a context manager. `now` is where the clock starts, no earlier than the fixture's `now`. `clock_mode` is how the clock moves, `None` for `world.default_clock_mode`. `state_format` answers in another of this world's formats, in place of the pin. `control_tools=True` makes the framework's own control tool callable on the instance. `startup` is the world's own keywords, passed to the startup hooks that name them. `setup_sql` is SQL run on the new instance before the startup hooks; see [`setup_sql`](#setup_sql) | +| `world.instance(fixture=None, *, seed=None, now=None, clock_mode=None, state_format=None, control_tools=False, startup=None, setup_sql=None)` | make an instance; a context manager. `now` is where the clock starts, no earlier than the fixture's `now`. `clock_mode` is how the clock moves, `None` for `world.default_clock_mode`. `state_format` answers in another of this world's formats, in place of the pin. `control_tools=True` makes the framework's own control tools callable on the instance. `startup` is the world's own keywords, passed to the startup hooks that name them. `setup_sql` is SQL run on the new instance before the startup hooks; see [`setup_sql`](#setup_sql) | | `world.fixtures()` | every fixture in the fixtures directory, as a list sorted by id. A world with no fixtures directory has none, which is not an error | | `copy.copy(world)` | this world with the same registrations and its own instances: set `fixtures_dir` on the copy to freeze somewhere else without moving the imported world's | | `world.tools` | the registry, in registration order. Read-only, and this world's **own** tools: the composite surface an agent sees is `inst.tools()`, or `world.composition().tools` | -| `world.tools_by_fn` | the registry by the function each tool was built from, multi-valued because one function may be registered as two tools. This world's own tools only, contributed or not. The control tool is not in it | +| `world.tools_by_fn` | the registry by the function each tool was built from, multi-valued because one function may be registered as two tools. This world's own tools only, contributed or not. Control tools are not in it | | `world.middlewares` | the middleware, outermost first | | `world.startup_hooks` | the hooks, in registration order | | `world.accepted_startup_kwargs` | every startup keyword some hook names | @@ -183,21 +183,24 @@ Made by `world.instance(...)`, never by hand. A context manager; leaving the blo | `inst.state(format=None)` | the state document as a plain dict: the envelope, and `state` from this instance's format. `format` answers in another of the world's formats instead. Refused inside `bulk()` or a tool call | | `inst.composition()` | what this instance is running against: one `NodeReport` per store, root first | | `inst.freeze(id, description)` | mint a fixture from the current state; returns the `Fixture`. Refuses inside `bulk()` | +| `inst.advance_clock(by=None, *, to=None)` | move the clock forward by a `timedelta`, or to an instant in either form `now=` takes, and answer the new reading as canonical text. Refused under `wall`, for an instant earlier than the reading, and inside `bulk()`, a tool call or a state formatter. See [../clock.md](../clock.md#moving-the-clock-forward) | | `inst.bulk()` | a context manager yielding the root node's `Ctx`, with one transaction per node, for loading rows fast. See [composition.md](../composition.md#one-instance-many-stores) for a composite | | `inst.destroy()` | close everything and remove the working directory. Idempotent, and waits for a call in flight | | `inst.id`, `inst.fixture`, `inst.seed` | the instance id, the fixture id (or `None`), the derived seed bytes | | `inst.state_format` | the format `inst.state()` answers in, fixed for the instance's life | | `inst.setup_sql` | the `setup_sql` the instance was created with, as given, or `None` | -| `inst.control_tools` | whether the framework's own control tool is callable on this instance, fixed for the instance's life | +| `inst.control_tools` | whether the framework's own control tools are callable on this instance, fixed for the instance's life | | `inst.clock`, `inst.world`, `inst.state_path` | the clock, the world, and the instance's own database file | The tool name is positional-only, so a world may have a tool argument called `name`. -The framework's own control tool is registered on every world and callable only on an instance made -with `control_tools=True`. On every other instance its name raises `UnknownTool`, in the same words -a name the world does not have raises, and no instance lists it either way. The tool is deprecated: -read `inst.state()` instead. Over a server, `serve --include-control-tools` is what makes each -session's instance one that can call it, and [cli.md](cli.md) names the tool. +The framework's own control tools are registered on every world and callable only on an instance +made with `control_tools=True`. On every other instance their names raise `UnknownTool`, in the same +words a name the world does not have raises, and no instance lists them either way. +`controller_advance_clock` is `inst.advance_clock(...)` for a harness over a server. The other +control tool is deprecated: read `inst.state()` instead. Over a server, +`serve --include-control-tools` is what makes each session's instance one that can call them, and +[cli.md](cli.md) names them. ### `setup_sql` @@ -315,7 +318,8 @@ one object. Each call to `now()` or `iso()` is one reading, and reading never mo | `running` | the start instant plus the time elapsed since the instance was made, on a monotonic clock | | `wall` | the host's current UTC time | -Every reading is truncated to milliseconds. [../clock.md](../clock.md) says what the +Under every mode but `wall`, the reading also adds how far `inst.advance_clock(...)` has moved the +clock forward. Every reading is truncated to milliseconds. [../clock.md](../clock.md) says what the start instant is and which modes replay. `Clock(datetime)` built directly is a `fixed` clock at that instant, which is also the way to render @@ -328,8 +332,9 @@ Every connection overrides SQLite's `current_timestamp`, `current_date`, `curren to return the clock's reading, so SQL sees the same time world code does. One SQL statement takes one reading, and a trigger it fires shares that reading. Two rare cases take another statement's reading: a statement whose text starts with a `-- ` comment, and a statement whose rows are read -while another statement runs on the same connection. Neither happens under `fixed`, and under `tick` -only on `inst.inspect()` and the control tool. Under `running` and `wall`, either can happen on any +while another statement runs on the same connection. Under `fixed`, neither happens until the clock +is advanced. Under `tick`, and under `fixed` after an advance, they happen only on `inst.inspect()` +and the deprecated SQL control tool. Under `running` and `wall`, either can happen on any connection. The overrides are registered as innocuous, so schema objects may reference them. What they return compares and sorts correctly against the canonical text a world stores. @@ -383,7 +388,7 @@ class Tool: ``` `from_function` is what a tool factory builds its tool with, whether that factory is one of -Seahaven's helpers or an extension's. `control` is Seahaven's own flag for its control tool and +Seahaven's helpers or an extension's. `control` is Seahaven's own flag for its control tools and cannot be set through it. `validate` is strict in one of two modes, chosen for the whole call. Arguments that are plain JSON diff --git a/src/seahaven/docs/reference/cli.md b/src/seahaven/docs/reference/cli.md index 1c9265b..3a8b0db 100644 --- a/src/seahaven/docs/reference/cli.md +++ b/src/seahaven/docs/reference/cli.md @@ -211,7 +211,7 @@ Runs the OpenEnv server for this world: one world, many sessions, one worker pro | `--max_concurrent_envs N` | `500` | how many sessions may be open at once | | `--concurrency N` | `min(cpus, 16)` | how many tool calls run at once; `0` for no gate | | `--session-timeout SECONDS` | off | seconds of idleness before a session is reaped; `0` turns the reaper off | -| `--include-control-tools` | off | make each session's instance one that can call `controller_run_sql`; without the flag the name is an unknown tool. It is never listed either way, and it is deprecated in favour of the state document -- run Python with `-W default::DeprecationWarning` to see the warning | +| `--include-control-tools` | off | make each session's instance one that can call the control tools: `controller_advance_clock`, which moves the clock forward ([../serving_and_openenv.md](../serving_and_openenv.md#moving-the-clock)), and `controller_run_sql`, which is deprecated in favour of the state document -- run Python with `-W default::DeprecationWarning` to see its warning. Without the flag their names are unknown tools, and they are never listed either way | | `--no-console` | off | do not serve the web console at `/console`; the address is otherwise printed when the server starts | ```sh diff --git a/src/seahaven/docs/serving_and_openenv.md b/src/seahaven/docs/serving_and_openenv.md index 9c8f0b6..601f5c8 100644 --- a/src/seahaven/docs/serving_and_openenv.md +++ b/src/seahaven/docs/serving_and_openenv.md @@ -23,7 +23,7 @@ A world that copies a product's HTTP API can also serve that API as a test serve |---|---| | [Running the server](#running-the-server) | Installing the extra, the app file, the command and its options | | [The web console](#the-web-console) | The page at `/console`, and what it does | -| [Sessions and instances](#sessions-and-instances) | What a connection holds, and what `reset` does | +| [Sessions and instances](#sessions-and-instances) | What a connection holds, what `reset` does, and moving the clock | | [Driving a world from Python](#driving-a-world-from-python) | `SeahavenClient`, synchronous and asynchronous | | [Calls, results and errors](#calls-results-and-errors) | What comes back from a tool call | | [Grading a run](#grading-a-run) | The state document over the wire, and choosing its format | @@ -82,7 +82,7 @@ seahaven serve --host 0.0.0.0 --port 9000 | `--max_concurrent_envs` | `500` | how many sessions may be open at once. Over capacity, OpenEnv answers `CAPACITY_REACHED` and closes the connection | | `--concurrency` | `min(cpus, 16)` | how many tool calls run at once; `0` for no gate | | `--session-timeout` | off | seconds of idleness before a session is reaped; `0` turns the reaper off. See [the idle reaper](#running-it-in-production) | -| `--include-control-tools` | off | make each session's instance one that can call the deprecated control tool; without the flag the name is an unknown tool. [reference/cli.md](reference/cli.md) names it | +| `--include-control-tools` | off | make each session's instance one that can call the control tools, such as `controller_advance_clock` ([Moving the clock](#moving-the-clock)); without the flag their names are unknown tools. [reference/cli.md](reference/cli.md) names them | | `--no-console` | off | do not serve the web console at `/console` | | `--world module:attr` | the convention | which world to serve | @@ -142,9 +142,9 @@ The list above is every key that changes what a reset does. A key outside the li the session is touched: the error names the stray keys and the keys `reset` does take, the session keeps the instance it already had, and it can reset again. A reset whose keys are all known but whose instance then fails to build leaves the session fresh instead, as above. One key outside the -list is accepted and then ignored rather than refused: `control_tools`, because the control tool is -the operator's to enable with `--include-control-tools` and a client sending the key must not turn -it on. A startup keyword goes inside `startup`; a message that sends one at the top level is +list is accepted and then ignored rather than refused: `control_tools`, because the control tools +are the operator's to enable with `--include-control-tools` and a client sending the key must not +turn them on. A startup keyword goes inside `startup`; a message that sends one at the top level is refused rather than delivered: ```jsonc @@ -170,6 +170,24 @@ every observation. Listing the tools does not need a `reset`. The tool list belongs to the world rather than to the episode, so a client may ask for it before it starts. +### Moving the clock + +`reset` sets where the clock starts. To move it forward during an episode, start the server with +`--include-control-tools` and call the control tool `controller_advance_clock` with one of two +arguments: `seconds`, how far to move the clock, or `to`, the instant to move it to, in any form +`now` takes. It answers the new reading and the clock's mode. It is never in the tool list, so an +agent does not see it. + +```py +env.reset(fixture="small_startup", clock_mode="tick") +observation = env.call("controller_advance_clock", seconds=3600) +print(observation.result) # {'now': '2026-06-01T10:00:00.000Z', 'clock_mode': 'tick'} +``` + +It follows the rules of `inst.advance_clock(...)` ([clock.md](clock.md#moving-the-clock-forward)). +A refusal, such as a `to` earlier than the clock's reading, is a tool error with the code +`clock_not_advanced`. + ## Driving a world from Python `seahaven.openenv.SeahavenClient` drives every Seahaven world. Every world speaks the same wire @@ -496,7 +514,7 @@ observation too, and raises only on a framework or protocol failure. The gate bounds how many tool calls run at once. It never bounds admission: calls queue, and nothing is rejected. A call takes the gate before the instance lock, so a queued call cannot block a -`destroy` or a `freeze`. Instance creation, tool listing and the control tool bypass it entirely. +`destroy` or a `freeze`. Instance creation, tool listing and the control tools bypass it entirely. `inst.bulk()` bypasses it too, and so does any call made inside a call or a `bulk()` block on the same thread. diff --git a/src/seahaven/docs/testing.md b/src/seahaven/docs/testing.md index ced91e9..c30df13 100644 --- a/src/seahaven/docs/testing.md +++ b/src/seahaven/docs/testing.md @@ -51,7 +51,7 @@ def test_a_report_is_the_same_every_time(instance: seahaven.Instance) -> None: . | `fixture="agency", clock_mode="tick"` | a clock that moves one second per call, so timestamps are exact ([clock.md](clock.md)) | | `fixture="agency", seed=7` | a fixed seed, so ids repeat | | `fixture="agency", startup={"user_id": "u_12"}` | a startup keyword of this world's, passed to its hooks | -| `fixture=None, control_tools=True` | an instance the framework's own control tool is callable on | +| `fixture=None, control_tools=True` | an instance the framework's own control tools are callable on | Everything but `fixture` is passed straight through to `world.instance(...)`, so `seed`, `now`, `clock_mode` and the `startup` keywords your world accepts work exactly as they do in process. A diff --git a/src/seahaven/instances.py b/src/seahaven/instances.py index b620db1..888f38e 100644 --- a/src/seahaven/instances.py +++ b/src/seahaven/instances.py @@ -18,13 +18,14 @@ Three rules hold the concurrency together. *One lock per instance.* `call`, `change_log`, `call_log`, `state`, `freeze`, -`bulk`, `destroy`, a nested call through a handle and the opens inside -`inspect()` and `_control_db()` take it, so calls into one instance serialise and -a destroy waits for the call in flight. Reads through the `inspect()` handle -afterwards do not take it: that handle is the caller's, to read from whatever -thread it likes. The lock is an `RLock` because a control tool is called with it -already held and then asks the instance for something -- its control handle -- -that takes it again on the same thread. +`advance_clock`, `bulk`, `destroy`, a nested call through a handle and the opens +inside `inspect()` and `_control_db()` take it, so calls into one instance +serialise and a destroy waits for the call in flight. Reads through the +`inspect()` handle afterwards do not take it: that handle is the caller's, to +read from whatever thread it likes. The lock is an `RLock` because a control +tool is called with it already held and then asks the instance for something -- +its control handle, or `advance_clock` -- that takes it again on the same +thread. *The gate before the lock, and never under it.* The concurrency gate bounds how many tool calls run at once across the process. A call takes it before the @@ -34,7 +35,7 @@ covers a nested call through a handle, an `inst.call(...)` inside `bulk()`, and a call on another instance from inside either, because by then the thread may hold an instance lock that a queued caller is waiting for. `bulk()`, instance creation -and the control tool take no slot. +and the control tools take no slot. *The manager's lock is never held while an instance lock is.* The registry is touched only in short moments that take nothing else. @@ -54,7 +55,7 @@ from collections.abc import Callable, Iterable, Iterator, Mapping from contextlib import AbstractContextManager, ExitStack, closing, contextmanager, suppress from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING, Any, Concatenate, Self, overload @@ -125,6 +126,9 @@ _log = logging.getLogger(__name__) +# The last instant a `datetime` can hold, and so the furthest a clock can be advanced to. +_LATEST_INSTANT = datetime.max.replace(tzinfo=UTC) + def default_concurrency() -> int: """How many tool calls run at once unless an operator says otherwise. @@ -607,6 +611,58 @@ def freeze(self, id: str, description: str) -> Fixture: ) return freeze(self, id, description, fixtures_dir=self.world.fixtures_dir) + def advance_clock( + self, by: timedelta | None = None, *, to: str | datetime | None = None + ) -> str: + """Move this instance's clock forward, and answer its new reading as canonical text. + + `by` is how far to move it, and `to` is the instant to move it to, in + either form `now=` takes; pass exactly one. The clock keeps its mode + and moves on from the new reading, so `tick` still adds a second per + call and `running` still runs. Every node, `inspect()` and the next + `freeze` read the moved clock. An advance is not a call: it takes no + ordinal, does not tick, and is not in the call log. + + Forward only, for the reason a `now=` earlier than a fixture's is + refused: rows already written are dated by the clock. Refused under + `wall`, whose reading is the host's, and inside a tool call, `bulk()` or + a state formatter, because the clock moves between calls and never + during one. + """ + self._refuse_if_formatting() + request = _advance_request(by, to) + if calling(): + raise WorldBug( + "advance_clock cannot run inside a tool call: world code reads the clock and " + "never moves it. Advance it between calls" + ) + with self._held(): + # Any node, as in `freeze`: `bulk()` opens a transaction on every one. + if any(runtime.db.in_transaction for runtime in self._runtime.values()): + raise WorldBug( + "advance_clock cannot run inside bulk(): leave the bulk() block first, and " + "advance the clock between blocks" + ) + if self.clock.mode == "wall": + raise WorldBug( + "a wall clock reads the host's time and cannot be advanced; make the " + "instance with clock_mode 'fixed', 'tick' or 'running' to move its clock" + ) + current = self.clock.now() + if isinstance(request, datetime): + if request < current: + raise WorldBug( + f"to={Clock(request).iso()} is earlier than {Clock(current).iso()}, the " + "clock's reading: the clock only moves forward" + ) + step = request - current + else: + step = request + if step > _LATEST_INSTANT - current: + raise WorldBug(f"the clock cannot move past the year {_LATEST_INSTANT.year}") + self.clock._advance(step) + return self.clock.iso() + def bulk(self) -> AbstractContextManager[Ctx[Any]]: """Write straight into the instance, under its lock and one transaction per node. @@ -872,7 +928,7 @@ def _current_composition(self) -> Composition: return composition def _control_db(self) -> Db: - """A second read-only handle, opened once, for the control tool alone. + """A second read-only handle, opened once, for `controller_run_sql` alone. Not the `inspect()` handle, though it is opened the same way. A control read runs through `sandbox.run_statement`, which sets the connection's @@ -883,7 +939,7 @@ def _control_db(self) -> Db: and one that takes the interpreter with it, because the thread waiting on the connection is holding the GIL. - Reached only from the control tool, which `Instance.call` runs with the + Reached only from `controller_run_sql`, which `Instance.call` runs with the instance lock held, so this connection has one statement on it at a time and the state `run_statement` borrows is state nobody else can see. """ @@ -988,7 +1044,7 @@ def _close(self) -> None: No session outlives a call -- each one is opened and closed inside `_recording` -- so what is left here is a connection per node. Both - read-only handles, the caller's from `inspect()` and the control tool's + read-only handles, the caller's from `inspect()` and `controller_run_sql`'s own, may never have been opened. """ for handle in (self._inspection, self._control): @@ -1422,6 +1478,30 @@ def _clock_from(now: str | datetime, mode: ClockMode) -> Clock: return Clock.from_iso(now, mode) if isinstance(now, str) else Clock(now, mode) +def _advance_request(by: object, to: object) -> timedelta | datetime: + """What an `advance_clock` asks for: how far to move, or the instant to move to. + + Exactly one of a `timedelta` that is not negative and an instant in either + form `now=` takes, which comes back as an aware UTC datetime cut to the + clock's precision. + """ + if (by is None) == (to is None): + raise WorldBug("advance_clock takes exactly one of by= and to=") + if to is not None: + if not isinstance(to, str | datetime): + raise WorldBug( + f"to= takes a timestamp, as text or as a datetime, not {type(to).__name__}" + ) + return _clock_from(to, "fixed").now() + if not isinstance(by, timedelta): + raise WorldBug( + f"by= takes a timedelta, such as timedelta(minutes=5), not {type(by).__name__}" + ) + if by < timedelta(): + raise WorldBug("by= must not be negative: the clock only moves forward") + return by + + def _check_not_before_fixture(fixture: Fixture, now: str | datetime) -> None: """Refuse a start instant earlier than the fixture's own `now`. diff --git a/src/seahaven/openenv/__init__.py b/src/seahaven/openenv/__init__.py index 681650a..329dd78 100644 --- a/src/seahaven/openenv/__init__.py +++ b/src/seahaven/openenv/__init__.py @@ -526,13 +526,14 @@ def app( `seahaven serve --no-console` turns it off, for a server that should answer the protocol and nothing else. - `include_control_tools` makes `controller_run_sql` callable over the wire: + `include_control_tools` makes the control tools callable over the wire: every session of this app makes its instance with `control_tools=True`, and - without the flag the name is refused as an unknown tool. It is never listed - either way; the flag is for a harness that drives the world itself, and a - server an agent talks to should not have it. The tool is - deprecated -- the `state` message is what an eval reads now -- and a call of - it warns in the server's process. + without the flag their names are refused as unknown tools. They are never + listed either way; the flag is for a harness that drives the world itself, + and a server an agent talks to should not have it. + `controller_advance_clock` moves the session's clock forward. + `controller_run_sql` is deprecated -- the `state` message is what an eval + reads now -- and a call of it warns in the server's process. OpenEnv's plain-HTTP `/reset`, `/step` and `/state` are replaced by a route that refuses them, because upstream cannot hold an episode over HTTP and diff --git a/src/seahaven/tool.py b/src/seahaven/tool.py index 541e1d5..af32619 100644 --- a/src/seahaven/tool.py +++ b/src/seahaven/tool.py @@ -78,7 +78,7 @@ class Tool[**P = ..., R = Any]: params: type[pydantic.BaseModel] schema: dict[str, Any] transaction: bool = True - # The framework's own flag, for `controller_run_sql`: a control tool bypasses + # The framework's own flag, for its control tools: a control tool bypasses # the middleware chain, the transaction and the gate, and is never listed. # `from_function` cannot set it. control: bool = False diff --git a/src/seahaven/world.py b/src/seahaven/world.py index 5b2be8f..45b7a9f 100644 --- a/src/seahaven/world.py +++ b/src/seahaven/world.py @@ -74,11 +74,11 @@ # own verbs, and a tool by one of those names could not be called over the wire. RESERVED_TOOL_NAMES = frozenset({"close", "reset", "state", "step"}) -# The framework's own tool (`control.py`). It is registered on every world, -# bypasses the chain and is never listed; a world registering its name is refused -# whether or not it is registered yet. Registration is not permission: an -# instance calls it only when it was made with `control_tools=True`. -CONTROL_TOOL_NAMES = frozenset({"controller_run_sql"}) +# The framework's own tools (`control.py`). They are registered on every world, +# bypass the chain and are never listed; a world registering one of the names is +# refused whether or not it is registered yet. Registration is not permission: an +# instance calls them only when it was made with `control_tools=True`. +CONTROL_TOOL_NAMES = frozenset({"controller_run_sql", "controller_advance_clock"}) FIXTURES_DIRNAME = "fixtures" SQL_SUFFIX = ".sql" @@ -208,14 +208,14 @@ def __init__( # never touches the working directory at all. self._manager: InstanceManager | None = None self._manager_lock = threading.Lock() - # The framework's own tool, on every world and before anything the world - # registers: an instance made with `control_tools=True` reaches it - # through the registry like any tool, `Instance.tools()` filters it out - # of the listing, and a world that registers its name is refused by - # `_add`. + # The framework's own tools, on every world and before anything the world + # registers: an instance made with `control_tools=True` reaches them + # through the registry like any tool, `Instance.tools()` filters them out + # of the listing, and a world that registers one of their names is + # refused by `_add`. for tool in control.TOOLS: # A world nobody holds yet cannot be in anyone's tree, so registering - # the framework's own tool on it invalidates no sealed + # the framework's own tools on it invalidates no sealed # composition. Without this every `World(...)` anywhere in a process # would reseal every other world on its next use. self._add(tool, invalidates_seals=False) @@ -234,9 +234,9 @@ def tools_by_fn(self) -> Mapping[Callable[..., Any], tuple[Tool, ...]]: surface or filtered out of it, because the allow and block lists shape what an agent sees and host code can call everything. - The framework's own control tool is not in it. It is no part of a + The framework's own control tools are not in it. They are no part of a world's surface -- never contributed, and refused by name by both `call` - paths -- so there is nothing for a reference to it to reach. + paths -- so there is nothing for a reference to one to reach. """ return MappingProxyType(self._tools_by_fn) @@ -531,15 +531,15 @@ def instance( `clock_mode` is the mode the instance's clock runs in, `None` for the world's default. `state_format` answers in another of this world's formats for this instance alone, in place of the world's pin. - `control_tools=True` makes the framework's own tools -- the - deprecated `controller_run_sql` -- callable on this instance; without it - they are not callable at all, and their names answer `UnknownTool` like - any name the world does not have. `startup` is the world's own - namespace: every keyword in it is passed to the startup hooks that named - it, and a keyword no hook names is refused. `setup_sql` is SQL that - reads and writes rows of the new instance before any startup hook runs, - every node's tables in reach. The instance is a context manager and - leaving the block destroys it. + `control_tools=True` makes the framework's own tools -- + `controller_advance_clock` and the deprecated `controller_run_sql` -- + callable on this instance; without it they are not callable at all, and + their names answer `UnknownTool` like any name the world does not have. + `startup` is the world's own namespace: every keyword in it is passed to + the startup hooks that named it, and a keyword no hook names is refused. + `setup_sql` is SQL that reads and writes rows of the new instance before + any startup hook runs, every node's tables in reach. The instance is a + context manager and leaving the block destroys it. Never from inside a tool call: a handler that wants another world reaches it through `ctx.worlds`, and a world that made its own instance would be @@ -656,9 +656,9 @@ def _add(self, tool: Tool, *, invalidates_seals: bool = True) -> None: f"({', '.join(sorted(RESERVED_TOOL_NAMES))})" ) # Before the duplicate check, which every world would hit instead: the - # control tool is registered here at construction, so a world tool by its - # name is already taken. What is wrong with it is that the name is the - # framework's, and that is what it is told. + # control tools are registered here at construction, so a world tool by + # one of their names is already taken. What is wrong with it is that the + # name is the framework's, and that is what it is told. if tool.name in CONTROL_TOOL_NAMES and not tool.control: raise WorldBug(f"tool {tool.name!r} uses the name of a control tool") if tool.name in self._tools: diff --git a/tests/test_clock.py b/tests/test_clock.py index 2555556..a1f74be 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -393,6 +393,52 @@ def test_clocks_compare_by_identity() -> None: assert Clock(INSTANT) != Clock(INSTANT) +# ----------------------------------------------------------------- advancing + +FIVE_MINUTES = timedelta(minutes=5) + + +def test_an_advance_moves_a_fixed_clock_and_the_advances_add_up() -> None: + fixed = Clock(INSTANT) + + fixed._advance(FIVE_MINUTES) + assert fixed.now() == INSTANT + FIVE_MINUTES + fixed._advance(timedelta(hours=1)) + assert fixed.iso() == "2024-03-05T13:05:00.123Z" + + +def test_an_advance_moves_a_tick_clock_and_it_goes_on_counting_calls() -> None: + tick = Clock(INSTANT, "tick") + tick._call_started() + + tick._advance(FIVE_MINUTES) + assert tick.now() == INSTANT + FIVE_MINUTES + timedelta(seconds=1) + tick._call_started() + assert tick.now() == INSTANT + FIVE_MINUTES + timedelta(seconds=2) + + +def test_an_advance_moves_a_running_clock_and_it_goes_on_running(monotonic: list[int]) -> None: + running = Clock(INSTANT, "running") + monotonic[0] += 1_500_000_000 + + running._advance(FIVE_MINUTES) + assert running.now() == INSTANT + FIVE_MINUTES + timedelta(seconds=1.5) + monotonic[0] += 1_000_000_000 + assert running.now() == INSTANT + FIVE_MINUTES + timedelta(seconds=2.5) + + +@pytest.mark.parametrize("mode", ["fixed", "tick", "running"]) +def test_an_advance_is_cut_to_the_millisecond_a_reading_shows( + mode: ClockMode, monotonic: list[int] +) -> None: + clock = Clock(INSTANT, mode) + + clock._advance(timedelta(microseconds=1_999)) + + assert clock.now() == INSTANT + timedelta(milliseconds=1) + assert clock.iso() == "2024-03-05T12:00:00.124Z" + + # ------------------------------------------------- one reading per SQL statement diff --git a/tests/test_control.py b/tests/test_control.py index 53ecd4b..ba99e43 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -1,9 +1,9 @@ -"""The framework's own tool, driven the way an eval drives it. +"""The framework's own tools, driven the way an eval drives them. Everything here goes through `Instance.call`, because that is the whole of the control path: the generic machinery (validation, serialisation, the bypasses) is -pinned in `test_instances.py` against a stand-in control tool, and what these -tests are about is the real one. +pinned in `test_instances.py` against a stand-in control tool, and these tests +are about the real ones. `controller_run_sql` is deprecated, so every call of it warns. The module filter below silences the calls that are here for another reason; the two tests about @@ -15,6 +15,7 @@ import sys import threading import time +import warnings from pathlib import Path from typing import Any @@ -23,10 +24,11 @@ from seahaven import control, sandbox from seahaven.call import Call, Handler from seahaven.ctx import Ctx -from seahaven.errors import ArgumentError, DbError, UnknownTool, WorldBug +from seahaven.errors import ArgumentError, DbError, ToolError, ToolErrorType, UnknownTool, WorldBug from seahaven.instances import Instance -from seahaven.world import World -from tests.conftest import WAIT, Caller, build_world +from seahaven.world import CONTROL_TOOL_NAMES, World +from tests.conftest import INSTANT_ISO, WAIT, Caller, build_world +from tests.test_instances import after pytestmark = pytest.mark.filterwarnings("ignore:controller_run_sql is deprecated") @@ -103,11 +105,11 @@ def test_asking_for_control_tools_makes_the_tool_callable(tmp_path: Path) -> Non @pytest.mark.parametrize("asked", [True, False]) def test_neither_instance_lists_a_control_tool(tmp_path: Path, asked: bool) -> None: - """The flag makes the tool callable, never advertised.""" + """The flag makes the tools callable, never advertised.""" world: World = build_world(tmp_path, SCHEMA) with world.instance(None, control_tools=asked) as instance: - assert "controller_run_sql" not in {tool["name"] for tool in instance.tools()} + assert not CONTROL_TOOL_NAMES & {tool["name"] for tool in instance.tools()} def test_it_reads_a_table_the_framework_knows_nothing_about(instance: Instance) -> None: @@ -524,3 +526,105 @@ def test_a_control_read_answers_while_another_thread_reads_the_inspection_handle assert done.stdout.split() == ["finished"], done.stderr assert done.returncode == 0 + + +# ------------------------------------------------------------ controller_advance_clock + + +def advance(instance: Instance, **arguments: Any) -> dict[str, str]: + """One call of the control tool that moves the clock.""" + return instance.call("controller_advance_clock", **arguments) + + +def test_controller_advance_clock_moves_the_clock_by_seconds_or_to_an_instant( + control_instance: Instance, +) -> None: + assert advance(control_instance, seconds=90) == {"now": after(90), "clock_mode": "fixed"} + assert advance(control_instance, seconds=0.5)["now"] == after(90.5) + assert advance(control_instance, to=after(3600)) == {"now": after(3600), "clock_mode": "fixed"} + + assert control_instance.call("now") == {"python": after(3600), "sql": after(3600)} + + +def test_controller_advance_clock_is_not_a_call(tmp_path: Path) -> None: + world: World = build_world(tmp_path) + + with world.instance(None, now=INSTANT_ISO, clock_mode="tick", control_tools=True) as instance: + assert advance(instance, seconds=60) == {"now": after(60), "clock_mode": "tick"} + + assert instance.call_count == 0 + assert instance.call_log() == [] + assert instance.call("now")["sql"] == after(61) + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + ({}, "controller_advance_clock takes exactly one of seconds and to"), + ( + {"seconds": 1, "to": after(1)}, + "controller_advance_clock takes exactly one of seconds and to", + ), + ( + {"to": after(-1)}, + f"to={after(-1)} is earlier than {INSTANT_ISO}, the clock's reading: the clock only " + "moves forward", + ), + ({"to": "tomorrow"}, "not a timestamp: 'tomorrow'"), + ({"seconds": 3e11}, "the clock cannot move past the year 9999"), + ({"seconds": 1e300}, "the clock cannot move past the year 9999"), + ], + ids=["neither", "both", "earlier", "not-a-timestamp", "past-9999", "past-a-timedelta"], +) +def test_a_refused_advance_is_a_tool_error_in_the_callers_own_words( + control_instance: Instance, arguments: dict[str, Any], message: str +) -> None: + """A tool error and not a `WorldBug`, which an OpenEnv harness would only see as internal.""" + with pytest.raises(ToolError) as raised: + advance(control_instance, **arguments) + + assert raised.value.to_dict() == { + "code": "clock_not_advanced", + "message": message, + "details": None, + } + assert type(raised.value).error_type is ToolErrorType.EXECUTION_ERROR + assert control_instance.clock.iso() == INSTANT_ISO + + +def test_a_wall_clock_refuses_an_advance_as_a_tool_error(tmp_path: Path) -> None: + world: World = build_world(tmp_path) + + with ( + world.instance(None, clock_mode="wall", control_tools=True) as instance, + pytest.raises(ToolError, match="a wall clock reads the host's time") as raised, + ): + advance(instance, seconds=60) + + assert raised.value.code == "clock_not_advanced" + + +@pytest.mark.parametrize("seconds", [-1, float("inf"), float("nan"), "60"]) +def test_seconds_that_are_not_a_distance_forward_are_an_argument_error( + control_instance: Instance, seconds: Any +) -> None: + with pytest.raises(ArgumentError, match="seconds"): + advance(control_instance, seconds=seconds) + + assert control_instance.clock.iso() == INSTANT_ISO + + +def test_controller_advance_clock_is_not_deprecated(control_instance: Instance) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + advance(control_instance, seconds=1) + + +def test_controller_advance_clock_is_unknown_without_control_tools(tmp_path: Path) -> None: + world: World = build_world(tmp_path) + + with world.instance(None, now=INSTANT_ISO, clock_mode="fixed") as instance: + with pytest.raises(UnknownTool, match="unknown tool: controller_advance_clock"): + advance(instance, seconds=60) + + assert instance.clock.iso() == INSTANT_ISO diff --git a/tests/test_docs.py b/tests/test_docs.py index 7950b4e..e690e6f 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -17,6 +17,7 @@ import pytest import seahaven +from seahaven import control from seahaven.cli.docs import docs_path # The layout, as `index.md` gives it. `components/pytest_and_docs.md` §2 wrote @@ -236,11 +237,10 @@ def test_the_lint_packages_own_table_agrees_with_the_reference() -> None: # `functional_spec.md` §11: `controller_run_sql` is deprecated and leaves the docs # but for the one line of `reference/cli.md` that documents the flag it is behind. -# The prefix is what to search for, because it is how every control tool is -# spelled, and the exception is one line rather than a whole page, so that the -# deprecation line cannot quietly grow a worked example. -_CONTROL_PREFIX = "controller_" -_CONTROL_PAGE = "reference/cli.md" +# The exception is one line rather than a whole page, so that the deprecation line +# cannot quietly grow a worked example. A control tool that is not deprecated is +# documented like anything else. +_DEPRECATED_CONTROL_PAGE = "reference/cli.md" # `Instance.changes()` was removed with the change log. A page that still calls it # is an example that raises `AttributeError` for whoever pastes it, and no fence @@ -249,13 +249,16 @@ def test_the_lint_packages_own_table_agrees_with_the_reference() -> None: @pytest.mark.parametrize("page", PAGES) -def test_no_page_names_a_control_tool_but_the_cli_reference(page: str) -> None: +@pytest.mark.parametrize("tool", sorted(control.DEPRECATED)) +def test_no_page_names_a_deprecated_control_tool_but_the_cli_reference( + page: str, tool: str +) -> None: text = (DOCS / page).read_text(encoding="utf-8") - naming = [line for line in text.splitlines() if _CONTROL_PREFIX in line] - if page != _CONTROL_PAGE: - assert not naming, f"{page} names a control tool: {naming}" + naming = [line for line in text.splitlines() if tool in line] + if page != _DEPRECATED_CONTROL_PAGE: + assert not naming, f"{page} names {tool}: {naming}" else: - assert len(naming) == 1, f"{page} names a control tool on {len(naming)} lines, not one" + assert len(naming) == 1, f"{page} names {tool} on {len(naming)} lines, not one" @pytest.mark.parametrize("page", PAGES) diff --git a/tests/test_env.py b/tests/test_env.py index e1053de..6bb5511 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -22,7 +22,7 @@ import seahaven from seahaven.ctx import Ctx from seahaven.errors import SeahavenError, ToolError, WorldBug -from seahaven.world import World +from seahaven.world import CONTROL_TOOL_NAMES, World from tests.conftest import INSTANT, INSTANT_ISO, build_world # The subpackage and not `openenv`: what this module imports is @@ -411,7 +411,7 @@ def test_list_tools_never_lists_a_control_tool(world: World) -> None: env = SeahavenEnv(world, include_control_tools=include) env.reset() names = [tool.name for tool in listing(env).tools] - assert "controller_run_sql" not in names + assert not CONTROL_TOOL_NAMES & set(names) assert "rows" in names @@ -634,6 +634,59 @@ def test_the_flag_does_not_reach_a_tool_the_world_does_not_have(world: World, na } +def test_controller_advance_clock_moves_the_sessions_clock(world: World) -> None: + env = SeahavenEnv(world, include_control_tools=True) + env.reset(now=INSTANT_ISO, clock_mode="tick") + + moved = call(env, "controller_advance_clock", seconds=60) + assert moved.result == {"now": "2024-03-05T12:01:00.123Z", "clock_mode": "tick"} + assert call(env, "now").result == { + "python": "2024-03-05T12:01:01.123Z", + "sql": "2024-03-05T12:01:01.123Z", + } + assert call(env, "controller_advance_clock", to="2024-03-06T00:00:00Z").result == { + "now": "2024-03-06T00:00:00.000Z", + "clock_mode": "tick", + } + state = env.state + assert (state.now, state.call_count) == ("2024-03-06T00:00:00.000Z", 1) + + +@pytest.mark.parametrize( + ("reset", "arguments", "message"), + [ + ({"clock_mode": "wall"}, {"seconds": 60}, "a wall clock reads the host's time"), + ({"now": INSTANT_ISO}, {"to": "2020-01-01T00:00:00Z"}, "is earlier than"), + ({"now": INSTANT_ISO}, {}, "takes exactly one of seconds and to"), + ], + ids=["wall", "earlier", "neither"], +) +def test_a_refused_advance_reaches_the_harness_as_a_tool_error( + world: World, reset: dict[str, Any], arguments: dict[str, Any], message: str +) -> None: + """Not the error frame a `WorldBug` earns, which would say only "internal error".""" + env = SeahavenEnv(world, include_control_tools=True) + env.reset(**reset) + + observation = call(env, "controller_advance_clock", **arguments) + + assert observation.seahaven_error is not None + assert observation.seahaven_error["code"] == "clock_not_advanced" + assert message in observation.seahaven_error["message"] + assert wire_error(observation) == ("execution_error", observation.seahaven_error["message"]) + + +def test_controller_advance_clock_is_unknown_without_the_flag(env: SeahavenEnv) -> None: + env.reset(now=INSTANT_ISO, clock_mode="fixed") + + assert call(env, "controller_advance_clock", seconds=60).seahaven_error == { + "code": "unknown_tool", + "message": "unknown tool: controller_advance_clock", + "details": {"name": "controller_advance_clock"}, + } + assert env.state.now == INSTANT_ISO + + # --- step: what is not a tool error --------------------------------------- diff --git a/tests/test_instances.py b/tests/test_instances.py index 29dd01b..3c91705 100644 --- a/tests/test_instances.py +++ b/tests/test_instances.py @@ -2630,3 +2630,244 @@ def child_now(ctx: Ctx) -> dict[str, str]: with instance.bulk() as ctx: assert {sql_now(ctx.db), sql_now(ctx.worlds.child.db)} == {after(2)} assert sql_now(instance.inspect()) == after(2) + + +# ---------------------------------------------------------------- advancing the clock + + +def test_advance_clock_moves_a_fixed_clock_for_every_reader(tmp_path: Path) -> None: + with build_world(tmp_path).instance(None, now=INSTANT_ISO, clock_mode="fixed") as instance: + assert instance.advance_clock(timedelta(minutes=5)) == after(300) + + assert instance.clock.iso() == after(300) + assert instance.call("now") == {"python": after(300), "sql": after(300)} + assert sql_now(instance.inspect()) == after(300) + assert instance.state()["now"] == after(300) + + +@pytest.mark.parametrize( + "to", + [after(90), "2024-03-05T14:01:30.123+02:00", INSTANT + timedelta(seconds=90)], + ids=["canonical", "offset", "datetime"], +) +def test_advance_clock_to_moves_the_clock_to_that_instant( + tmp_path: Path, to: str | datetime +) -> None: + with build_world(tmp_path).instance(None, now=INSTANT_ISO, clock_mode="fixed") as instance: + assert instance.advance_clock(to=to) == after(90) + assert instance.call("now")["sql"] == after(90) + + +def test_advance_clock_by_nothing_or_to_the_reading_leaves_the_clock_where_it_is( + instance: Instance, +) -> None: + assert instance.advance_clock(timedelta()) == INSTANT_ISO + assert instance.advance_clock(to=INSTANT_ISO) == INSTANT_ISO + + +def test_advance_clock_cuts_the_distance_to_the_millisecond(instance: Instance) -> None: + assert instance.advance_clock(timedelta(microseconds=1_999)) == after(0.001) + assert sql_now(instance.inspect()) == after(0.001) + + +def test_an_advance_is_not_a_call_and_tick_counts_on_from_it(tmp_path: Path) -> None: + with build_world(tmp_path).instance(None, now=INSTANT_ISO, clock_mode="tick") as instance: + instance.call("now") + + assert instance.advance_clock(timedelta(minutes=1)) == after(61) + assert instance.call_count == 1 + assert [record.tool for record in instance.call_log()] == ["now"] + assert instance.call("now") == {"python": after(62), "sql": after(62)} + + +def test_a_running_clock_runs_on_from_an_advance(tmp_path: Path, monotonic: list[int]) -> None: + with build_world(tmp_path).instance(None, now=INSTANT_ISO, clock_mode="running") as instance: + monotonic[0] += 1_500_000_000 + assert instance.advance_clock(timedelta(minutes=1)) == after(61.5) + assert instance.advance_clock(to=after(120)) == after(120) + + monotonic[0] += 2_000_000_000 + assert instance.call("now") == {"python": after(122), "sql": after(122)} + + +def test_every_node_of_a_composite_reads_the_advanced_clock(tmp_path: Path) -> None: + host = build_world(tmp_path) + child = composable_world("child") + + @child.tool(name="child_now") + def child_now(ctx: Ctx) -> dict[str, str]: + """The instance's clock, as the added world reads it.""" + return {"python": ctx.clock.iso(), "sql": sql_now(ctx.db)} + + host.add_world(child, name="child") + + with host.instance(None, now=INSTANT_ISO, clock_mode="fixed") as instance: + instance.advance_clock(timedelta(hours=1)) + + assert instance.call("child_now") == {"python": after(3600), "sql": after(3600)} + with instance.bulk() as ctx: + assert {sql_now(ctx.db), sql_now(ctx.worlds.child.db)} == {after(3600)} + assert sql_now(instance.inspect()) == after(3600) + + +def test_freeze_after_an_advance_records_the_advanced_reading(world: World) -> None: + with world.instance(None, now=INSTANT_ISO, clock_mode="fixed") as origin: + origin.advance_clock(timedelta(days=3)) + fixture = origin.freeze("later", "Three days in.") + + assert fixture.now == after(3 * 86400) + with world.instance("later", clock_mode="fixed") as forked: + assert forked.clock.iso() == after(3 * 86400) + + +def test_advance_clock_refuses_a_wall_clock(tmp_path: Path, wall: list[datetime]) -> None: + with build_world(tmp_path).instance(None, clock_mode="wall") as instance: + with pytest.raises(WorldBug, match="a wall clock reads the host's time"): + instance.advance_clock(timedelta(minutes=5)) + + assert instance.clock.iso() == "2031-12-25T06:30:00.500Z" + + +@pytest.mark.parametrize( + ("by", "to", "refusal"), + [ + (timedelta(seconds=-1), None, "by= must not be negative"), + (None, after(-0.001), rf"to={after(-0.001)} is earlier than {INSTANT_ISO}"), + ], + ids=["by", "to"], +) +def test_advance_clock_only_moves_forward( + instance: Instance, by: timedelta | None, to: str | None, refusal: str +) -> None: + with pytest.raises(WorldBug, match=refusal): + instance.advance_clock(by, to=to) + + assert instance.clock.iso() == INSTANT_ISO + + +@pytest.mark.parametrize( + ("by", "to"), + [(None, None), (timedelta(seconds=1), after(1))], + ids=["neither", "both"], +) +def test_advance_clock_takes_exactly_one_of_by_and_to( + instance: Instance, by: timedelta | None, to: str | None +) -> None: + with pytest.raises(WorldBug, match="exactly one of by= and to="): + instance.advance_clock(by, to=to) + + assert instance.clock.iso() == INSTANT_ISO + + +@pytest.mark.parametrize( + ("arguments", "refusal"), + [ + ({"by": 60}, "by= takes a timedelta, such as timedelta\\(minutes=5\\), not int"), + ({"to": 1_717_232_400}, "to= takes a timestamp, as text or as a datetime, not int"), + ({"to": "tomorrow"}, "not a timestamp: 'tomorrow'"), + ({"to": datetime(2025, 1, 1)}, "a clock instant must be timezone-aware"), + ], + ids=["by-number", "to-number", "to-text", "to-naive"], +) +def test_advance_clock_refuses_an_argument_that_is_not_a_distance_or_an_instant( + instance: Instance, arguments: dict[str, Any], refusal: str +) -> None: + with pytest.raises(WorldBug, match=refusal): + instance.advance_clock(**arguments) + + +def test_advance_clock_refuses_to_move_past_the_last_instant_a_clock_reads( + instance: Instance, +) -> None: + with pytest.raises(WorldBug, match="the clock cannot move past the year 9999"): + instance.advance_clock(timedelta.max) + + assert instance.clock.iso() == INSTANT_ISO + + +@pytest.mark.parametrize("transaction", [True, False], ids=["transaction", "no-transaction"]) +def test_advance_clock_is_refused_inside_a_tool_call(tmp_path: Path, transaction: bool) -> None: + """A tool that runs without a transaction is still inside a call.""" + world = build_world(tmp_path) + held: dict[str, Instance] = {} + + @world.tool(transaction=transaction) + def move_time(ctx: Ctx) -> str: + """Move the clock from inside a call.""" + return held["instance"].advance_clock(timedelta(minutes=5)) + + with world.instance(None, now=INSTANT_ISO, clock_mode="fixed") as instance: + held["instance"] = instance + + with pytest.raises(WorldBug, match="cannot run inside a tool call"): + instance.call("move_time") + + assert instance.clock.iso() == INSTANT_ISO + + +def test_advance_clock_is_refused_inside_bulk(instance: Instance) -> None: + with instance.bulk(), pytest.raises(WorldBug, match="cannot run inside bulk"): + instance.advance_clock(timedelta(minutes=5)) + + assert instance.clock.iso() == INSTANT_ISO + + +def test_advance_clock_is_refused_inside_a_state_formatter(tmp_path: Path) -> None: + world = build_world(tmp_path) + held: dict[str, Instance] = {} + + @world.state_format("acme.moving/1") + def moving(world: World, instance: Instance | None) -> dict[str, Any]: + """Move the clock from inside a read.""" + held["instance"].advance_clock(timedelta(minutes=5)) + return {} + + with world.instance( + None, now=INSTANT_ISO, clock_mode="fixed", state_format="acme.moving/1" + ) as instance: + held["instance"] = instance + + with pytest.raises(WorldBug, match="never writes to it"): + instance.state() + + assert instance.clock.iso() == INSTANT_ISO + + +def test_advance_clock_on_a_destroyed_instance_is_refused(world: World) -> None: + instance = world.instance(None, clock_mode="fixed") + instance.destroy() + + with pytest.raises(WorldBug, match="has been destroyed"): + instance.advance_clock(timedelta(minutes=5)) + + +def test_advance_clock_waits_for_a_call_in_flight(tmp_path: Path) -> None: + """The clock moves between calls, never during one.""" + world = build_world(tmp_path) + inside = threading.Event() + finish = threading.Event() + + @world.tool + def slow(ctx: Ctx) -> list[str]: + """Read the clock, hold the instance lock until the test lets go, and read it again.""" + first = ctx.clock.iso() + inside.set() + assert finish.wait(WAIT) + return [first, ctx.clock.iso()] + + with world.instance(None, now=INSTANT_ISO, clock_mode="fixed") as instance: + readings: list[list[str]] = [] + caller = Caller(lambda: readings.append(instance.call("slow"))) + caller.start() + assert inside.wait(WAIT) + + mover = Caller(lambda: instance.advance_clock(timedelta(minutes=5))) + mover.start() + mover.join(0.2) + assert mover.is_alive(), "the advance should be waiting for the lock" + + finish.set() + caller.finish() + mover.finish() + assert readings == [[INSTANT_ISO, INSTANT_ISO]] + assert instance.clock.iso() == after(300) diff --git a/tests/test_server.py b/tests/test_server.py index 80f179f..e0d4ba6 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -772,6 +772,26 @@ def test_a_reset_that_asks_for_control_tools_over_the_wire_does_not_get_them( } +def test_controller_advance_clock_moves_the_clock_over_the_wire(world: World) -> None: + with ( + serving(world, include_control_tools=True) as url, + SeahavenClient(base_url=url) as env, + ): + env.reset(now=INSTANT_ISO, clock_mode="fixed") + assert "controller_advance_clock" not in [tool["name"] for tool in env.list_tools()] + + moved = env.call("controller_advance_clock", seconds=3600) + assert moved.result == {"now": "2024-03-05T13:00:00.123Z", "clock_mode": "fixed"} + assert env.call("now").result == { + "python": "2024-03-05T13:00:00.123Z", + "sql": "2024-03-05T13:00:00.123Z", + } + refused = env.call("controller_advance_clock", to=INSTANT_ISO) + assert refused.seahaven_error is not None + assert refused.seahaven_error["code"] == "clock_not_advanced" + assert env.state().now == "2024-03-05T13:00:00.123Z" + + def test_the_control_tool_is_unknown_without_the_flag(world: World) -> None: with serving(world) as url, SeahavenClient(base_url=url) as env: env.reset() diff --git a/tests/test_world.py b/tests/test_world.py index 5b18268..b552da4 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -15,6 +15,7 @@ import pytest import seahaven +from seahaven import control from seahaven.call import Call, Handler from seahaven.composition import epoch from seahaven.ctx import Ctx @@ -611,9 +612,10 @@ def test_the_environment_verbs_are_not_tool_names(world: World, name: str) -> No world.tool(echo, name=name) -def test_a_control_tool_name_is_not_a_tool_name(world: World) -> None: +@pytest.mark.parametrize("name", sorted(CONTROL_TOOL_NAMES)) +def test_a_control_tool_name_is_not_a_tool_name(world: World, name: str) -> None: with pytest.raises(WorldBug, match="control tool"): - world.tool(echo, name="controller_run_sql") + world.tool(echo, name=name) def test_a_tool_from_a_factory_is_registered_as_it_is(world: World) -> None: @@ -816,9 +818,13 @@ def none_at_all() -> None: world.instance_startup(hook) -def test_every_world_carries_the_one_control_tool(world: World) -> None: +def test_every_world_carries_the_control_tools(world: World) -> None: """Registered at construction, flagged, and none of the world's own doing.""" - assert [name for name, tool in world.tools.items() if tool.control] == ["controller_run_sql"] + assert [name for name, tool in world.tools.items() if tool.control] == [ + "controller_run_sql", + "controller_advance_clock", + ] + assert {tool.name for tool in control.TOOLS} == CONTROL_TOOL_NAMES assert registered(world) == [] diff --git a/worlds/projecttracker/tests/test_package.py b/worlds/projecttracker/tests/test_package.py index a2c42ab..01466b5 100644 --- a/worlds/projecttracker/tests/test_package.py +++ b/worlds/projecttracker/tests/test_package.py @@ -93,9 +93,10 @@ def test_the_schema_is_the_sql_files_on_disk_in_filename_order() -> None: ) -def test_the_registry_holds_every_tool_and_the_control_tool() -> None: - """The control tool is on every world; the rest is this world's whole surface.""" - assert set(world.tools) == WORLD_TOOLS | HELPERS | {"controller_run_sql"} +def test_the_registry_holds_every_tool_and_the_control_tools() -> None: + """The control tools are on every world; the rest is this world's whole surface.""" + control_tools = {"controller_run_sql", "controller_advance_clock"} + assert set(world.tools) == WORLD_TOOLS | HELPERS | control_tools def test_the_tool_list_an_agent_sees_is_the_twenty_five_and_the_two_helpers() -> None: From 235868c2de4c19fbd1f669e53850480309d146b7 Mon Sep 17 00:00:00 2001 From: scosman Date: Sat, 17 Oct 2026 17:38:29 +0000 Subject: [PATCH 2/2] Drop "move a clock forward" from what Seahaven does not do concepts.md still listed it as unplanned, which advance_clock now contradicts. Also word the control tool's docstring so its refusals read as examples rather than the complete list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QtPPDeDagBgpFidJmRVQzz --- src/seahaven/control.py | 4 ++-- src/seahaven/docs/concepts.md | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/seahaven/control.py b/src/seahaven/control.py index 2287268..383b1c6 100644 --- a/src/seahaven/control.py +++ b/src/seahaven/control.py @@ -93,8 +93,8 @@ def controller_advance_clock( Give exactly one. `to` is a timestamp in any form `reset` takes for `now`. Answers the clock's new reading as `now`, and its mode as `clock_mode`. A - refusal is a tool error coded `clock_not_advanced`: a `to` earlier than the - clock's reading, a `wall` clock, or both arguments or neither. + refusal, such as a `to` earlier than the clock's reading, a `wall` clock, or + both arguments or neither, is a tool error coded `clock_not_advanced`. """ # Refused here rather than by `advance_clock`, whose refusal names its own # `by=`: the caller sent `seconds`. diff --git a/src/seahaven/docs/concepts.md b/src/seahaven/docs/concepts.md index 2f0af8c..92da253 100644 --- a/src/seahaven/docs/concepts.md +++ b/src/seahaven/docs/concepts.md @@ -268,6 +268,5 @@ turns a log into the net difference an episode made. ## What Seahaven does not do Seahaven does not put a time limit on a tool call, filter or project tools per instance, mock a -tool's response, move a clock forward, or generate data for you. It contains agent-written SQL and -nothing else. None of these is planned. If your eval needs one, build it into the harness around the -world. +tool's response, or generate data for you. It contains agent-written SQL and nothing else. None of +these is planned. If your eval needs one, build it into the harness around the world.