Repository navigation
feat(gateway): computers follow the clock through a watch, and clock writes wait for them (FD-5471) - #614
Conversation
…writes wait for them (FD-5471) The gateway's clock gets one opaque version, a per-boot id and a count, that every set_time, advance and clear changes. A computer follows it by long-polling /clock/watch with the version it shows, which is its acknowledgement; the answer carries the clock and the gateway's received and sent stamps. The gateway provider declares the computers that follow it in CLOCK_FOLLOWER_TOKENS, each by its read token (its control token for now), and a write given wait_followers_s waits for each and answers 504 naming those behind, the change standing. set_time without a virtual_time changes an armed clock's rate from its time at the write, so a rate change no longer steps it back by a stale read. A restarted gateway is unarmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the URL map's (FD-5471) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… late watch never moves its computer back (FD-5471) set_time without virtual_time read the clock twice, for its t0 and for the new arm's anchor, losing the moment between them at the old rate. An acknowledgement now only moves a computer's shown version forward, a count too long to be one the clock had names nothing rather than failing the watch, and the boot id's size is a named constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…owers (FD-5471) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…needs a rate (FD-5471) set_time given only virtual_seconds_per_real_second now changes an armed clock's rate the way advance moves it: from its time at the write, keeping t0 and generation, so time triggers keep their marks and their waits, which the change wakes to run at the new rate. set_time with neither a virtual_time nor a rate is refused with the 400 it got before, instead of re-arming at rate 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…own method; the watch contract in the card (FD-5471) - Every clock change goes through Gateway._clock_changed (triggers re-evaluated when time moved, held watches answered, the followers waited for), and the four write routes share _clock_write's body handling, which also gives set-time the JSON-object check the others had. - A rate change is the clock extension's set_rate (PUT /clock/set-rate), so set-time takes a virtual_time as it always has. - The route resolves a watch's follower from its token (ClockFollowers.follower_for), so the watch itself only acknowledges and holds. - The card advertises the watch's request and its hold (hold_s, CLOCK_WATCH_HOLD_S) and bounds wait_followers_s (CLOCK_MAX_WAIT_FOLLOWERS_S). - The clock's own tests move to clock_version_test.py, and the route tests wait for a follower to be seen rather than sleeping, with bounds a loaded runner can't miss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1-hb11-gateway-clock-one-version-a # Conflicts: # AGENTS.md # src/agent_env/env/envs/_multi_computers.py
… (FD-5471) math.isfinite raises on an int too large for a float, and the limit messages formatted the value as one; only a float is checked for finiteness, and the messages name the limit alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review. Context from a sibling open PR rather than from this diff: #566 ("a step that runs an assistant over days of task time, with a simulated user") independently adds a They disagree on the wire path for the same method name:
A caller reads the endpoint out of the card, so whichever lands second settles what The row that won't show up as a merge conflict is the last one. #566 predates the opaque version introduced here, so its Probably just agreeing on one path and keeping this PR's body for Generated by Claude Code |
…tion; followers named alike (FD-5471) - Gateway._change_clock applies a change and does what every one owes: it wakes the time triggers when the change kept the clock's generation (an advance or a rate change), so no route or later writer chooses it, then answers the held watches and waits for the followers, returning the followers still behind beside the state rather than in it. - ClockFollowers keeps each follower's count alone (Clock.version_of formats it); count_of and parse_wait read plainly; the docstrings, card and errors say follower throughout, and the card calls state's followers block diagnostic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tory, not a Launchpad PPA (FD-5471) The computer app tests composed the notes app with fastfetch from a Launchpad PPA, so every one of them failed whenever Launchpad was down, as it was on 2026-11-11 (about 85% uptime over 30 days). The app now takes docker-compose-plugin from Docker's apt repository (download.docker.com, its armored key beside the app), which Ubuntu doesn't ship either and which needs no other package, so the test still proves an outside source and its key and checks a ~noble version and the binary's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| """Each follower's last shown version and how long ago it last watched, for ``/clock/state``.""" | ||
| now, clock = time.monotonic(), self._clock() | ||
| return {name: {"version": None if shown is None else clock.version_of(shown), | ||
| "seen_s_ago": round(now - self._seen[name], 3) if name in self._seen else None} |
There was a problem hiding this comment.
ClockFollowers.state() uses an inline 3 to choose the precision of seen_s_ago. The repository requires magic numbers to be stored as class or instance variables with descriptive names. Move this value into a constant such as SEEN_SECONDS_DECIMALS. This repository requirement must be satisfied before merging.
Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)
Learned From
scaleapi/scaleapi#126388
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/env/gateway/clock_followers.py
Line: 92
Comment:
**Time precision lacks a name**
`ClockFollowers.state()` uses an inline `3` to choose the precision of `seen_s_ago`. The repository requires magic numbers to be stored as class or instance variables with descriptive names. Move this value into a constant such as `SEEN_SECONDS_DECIMALS`. This repository requirement must be satisfied before merging.
**Rule Used:** Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. ([source](https://app.greptile.com/-/switch-tenant?tenant=scale-ai&next=%2Fcustom-context%3Fmemory%3D002e0051-41ad-46c1-9098-47433c580150))
**Learned From**
[scaleapi/scaleapi#126388](https://github.com/scaleapi/scaleapi/pull/126388)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
#614's clock followers and M8.12's site names were added beside each other in the gateway's entrypoint and constructor; both are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of FD-5420: a computer's own clock follows the gateway's universe clock. This is FD-5471, the gateway side. MCP servers read the gateway's
get_timeon every call, but a computer can't, because every program on it reads its own clock. So the computer follows the gateway through a long poll, and a clock change doesn't return until every computer shows it.What changes
set_time,advanceandclearchanges it, so a version never repeats across gateway restarts./clock/stateand every write's answer carry it.GET /clock/watch?after=<version>&follower=<name>. It answers at once when the clock's version isn'tafter, else at the next change or after 25 s. The answer is the clock's state with the gateway's wall-clockreceivedandsentstamps, which a follower uses for an NTP-style offset. A watch is its computer's acknowledgement of the version it names. It counts only when it carries that computer's read token, and never for a version the clock hasn't had.clock/v1sync_timeinCLOCK_FOLLOWER_TOKENS(name=read token), on the VM and container paths alike. For now the read token is the computer's control token, a slot a separate token can fill later with no protocol change.TOKENin the name keeps the tokens out of logged compose files.set_time,advanceandcleartakewait_followers_s(0 to 30). Given one, the write waits for every declared computer to show its version. It answers 504 naming the computers still behind, and the change itself stands. A write without one, or to a gateway with no declared computers, answers as before.set_rate(PUT /clock/set-rate). It changes an armed clock's rate from its time as of the write. Likeadvance, it keepst0and the clock's generation, so time triggers keep their relative marks and their waits, and it wakes the trigger engine, so a wait runs at the new rate.set_timestill requires avirtual_time, as before.set_timewith a time computed from a state read a moment earlier stepped the universe back by the gap. A computer, which never steps back, then ran 65–85 ms ahead.Gateway._clock_changed: it re-evaluates the triggers when time moved, answers every held watch, and waits for the followers. Future writers, such as restoring the clock after a restart (FD-5433) or a snapshot reload, can't skip a step._clock_write's body handling.watchmethod carries its request (after,follower) andhold_s(25), which a follower's request timeout must exceed. Everywait_followers_sis bounded 0–30 in the schema.Nothing on
maindeclares a follower yet: computers start servingsync_timein FD-5476. Until thenCLOCK_FOLLOWER_TOKENSis never rendered, and every clock route answers as before.Also in this PR, a test-only fix for a required check:
fastfetchfrom a Launchpad PPA to prove that an app's outside apt source and its key work.computer_app_testfailed whenever Launchpad was down: from about 12:10 UTC on 2026-11-11 here and on other PRs, and at roughly 85% uptime over 30 days.docker-compose-pluginfrom Docker's apt repository instead. Ubuntu doesn't ship that package either, and the repo's armored key (9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88) sits beside the app.~nobleversion and the binary's ownversionoutput.Not in this PR:
wait_followers_s.Docs
The clock extension's page should cover
version, thewatchmethod,wait_followers_swith its 504, andset_timewithoutvirtual_timeas a rate change.Testing
Unit:
tst/unit/env/gateway/clock_watch_test.pyandclock_version_test.pycover:set_ratecontinuous with the clock before it, keepingt0, the generation and a relative trigger's mark;set_timewithout a time andset_ratewithout a rate refused;Provider and multi-env tests cover rendering and redacting
CLOCK_FOLLOWER_TOKENS. The full unit suite passes locally (14,387 tests), apart from one Windows disk-recipe test that fails only on macOS.E2E (
071322a2, an EC2 host in our AWS account):CLOCK_FOLLOWER_TOKENS. Each computer is a real follower process long-polling/clock/watchwith its token.wait_followers_s: 2answered 200, each acknowledged by both computers, in order. The write took 1.46 ms at p50 and 1.87 ms at p95; the prototype measured an acknowledgement wait of about 1.3 ms at p50.SIGSTOP): the write answered 504stale: ["laptop"]after 2,002 ms, and the change stood. AfterSIGCONT, the next write was acknowledged in 2.3 ms.docker restart): both followers' next watches answered unarmed on a new boot id within 46 ms of the gateway coming up, and re-arming with a wait answered 200.clear: with a wait, it answered 200 once both computers showed it unarmed.set_timewithoutwait_followers_s,clearwith no body).Remote providers (
261462b6, the CI's Modal keys):modal(a container on the private network) andmodal_vm(a container in the VM). It declared two computers, each followed through the gateway's public HTTPS tunnel:desktopfrom a Modal sandbox of its own,laptopfrom outside Modal.modaland 149 ms onmodal_vm, about two round trips through the tunnel.set_ratekeptt0, and both computers picked up ×60.clearwith a wait answered 200.Integration on the same host (local sandboxes):
gateway_test.py's clock tests pass:test_gateway_clock_extension,test_gateway_clock_rate_accuracy,test_gateway_time_trigger_extension,test_gateway_autonomous_time_triggerandtest_sync_env_clock_step.test_advance_to_next_event_hook_local,test_next_env_event_hook_local,test_register_env_triggers_localandtest_prompt_agent_notifications_local.What the e2e caught: the entrypoint parsed
CLOCK_FOLLOWER_TOKENSwith the URL-map parser, so a gateway with declared computers didn't start (fixed in91428dc1, with a unit test). It also caughtgateway_test.py's exact unarmed-state assertion (updated in071322a2).🤖 Generated with Claude Code
The clock changes appear sound, but the existing repository requirement for a named precision constant still needs to be met before merging.
Fix with agent prompt
Summary
The gateway now publishes clock versions and lets computers follow changes through long polls.
Diagram
sequenceDiagram participant Client participant Gateway participant Computer Computer->>Gateway: Watch with shown version and read token Client->>Gateway: Change clock with wait_followers_s Gateway-->>Computer: New clock state Computer->>Computer: Apply clock state Computer->>Gateway: Watch naming applied version Gateway-->>Client: 200 when all have acknowledged, otherwise 504Reviews (17) · Last reviewed commit: "Merge branch 'main' into edgararakelyan/..." · Reviewed by Greptile