Files
settled-reach/client/tests/atlas_agent_driver.gd
T
jpmschweitzerandClaude Fable 5 6f63cb6f70 fix(ui): agent driver honors SR_PORT — the eyeball's own finding (T-971)
The channel eyeball caught the committed reference driver silently
relying on SimBridge's hardcoded default port, unlike every sibling
real-render driver — a caller starting the server on a chosen port got
20 silent connect retries against the wrong port and a hollow session
whose results had valid shapes but no data. Mirrors visual_capture.gd's
convention exactly: SR_LIVE=1 without SR_PORT is a hard error; SR_PORT
sets SimBridge.server_port before boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:30:29 +02:00

255 lines
11 KiB
GDScript

## Reference in-process Atlas agent driver (T-971, D-226 item 4) — the
## SANCTIONED way to drive the Atlas headlessly via AtlasAgentInterface,
## replacing the five hand-rolled scratch eyeball drivers the T-1183/T-1157
## harness rounds produced ad hoc. Every real navigation call below goes
## through AtlasAgentInterface.act()/observe() — this file does not touch
## `_gui_input`, does not synthesize pixel events, and does not reach into
## any screen's private fields.
##
## Folds in two of the T-1157 harness-techniques inventory patterns
## (pql ticket show T-1157):
## 1. InputSwallower — a root-level Node, added first, low process_priority,
## whose _input()/_unhandled_input() both call
## get_viewport().set_input_as_handled() unconditionally. Neutralizes
## real desktop input reaching this `-s` SceneTree driver's window
## (X11/XWayland can deliver pointer events to an unfocused window under
## the cursor on a shared desktop) — this driver runs real production
## code via method calls, but the WINDOW still exists and can still
## receive stray input, so the guard applies here too.
## 4. Full-run-restart-on-anomaly (documented, not code-enforced — see
## _run() header note below): any assertion failure here should discard
## the whole run rather than retrying mid-sequence, matching the
## inventory's "partial retries silently corrupt the state the
## comparison depends on" finding. This driver's own job list is short
## and idempotent per invocation (fresh process each run), so the
## discipline is "don't loop-retry a failed step in place" rather than a
## literal restart mechanism.
##
## Technique 5 (fixed-center revisit) is what `jump_to_center` (this file's
## own `_run_job("jump_to_center", ...)` dispatch) exists to exercise —
## AtlasAgentInterface's production seam for it, not reimplemented here.
##
## PR #209 review (Hoshe finding 2) — settle discipline after a mutating
## intent: a FIXED `for i in range(4)` frame count (the pre-fix shape) is a
## regression against the proven is_pending()-aware pattern every eyeball
## driver round already established (visual_capture.gd's own
## _wait_for_atlas_layers_ready()) — a slow live server round-trip leaves the
## NEXT job's observe() reading a mid-fetch viewer, exactly the class of bug
## the whole T-1157 "settle-until-ready" technique exists to prevent.
## _settle_after_intent() below polls StepCanvasRequest.is_pending() (via the
## regional viewer's own get_request()) when "regional" is the current screen
## — the only screen a step-canvas fetch could be in flight for — with a
## bounded max-frame fallback (SETTLE_MAX_FRAMES, matching how the eyeball
## drivers waited: a generous multi-second bound, never an unbounded await).
## Every OTHER screen-targeted intent (reach/system) has no request to wait
## on, so it keeps the small fixed settle (SETTLE_FIXED_FRAMES) for its own
## panel-rebuild/queue_redraw() to apply.
##
## Usage (JSON job list on stdin path, matching visual_capture.gd's own
## `-- --flag value` CLI convention):
## godot --rendering-driver opengl3 --path client \
## -s res://tests/atlas_agent_driver.gd -- \
## --jobs res://tests/fixtures/atlas_agent_smoke_jobs.json \
## --output /abs/path/observe_log.json
##
## Each job is {"intent": "...", "params": {...}}; "observe" is a
## pseudo-intent this driver special-cases to call
## AtlasAgentInterface.observe() instead of act() (observe takes no params).
## The full sequence of act()/observe() results is written to --output as a
## JSON array — the smoke test (test_atlas_agent_driver_smoke.gd) reads this
## back to assert the reference driver actually completes a real session.
extends SceneTree
## Fixed settle for a mutating intent with no in-flight request to poll
## (reach/system screen intents — a panel rebuild/queue_redraw() needs at
## most a couple of frames, never a network round-trip).
const SETTLE_FIXED_FRAMES: int = 4
## Bounded fallback for the is_pending()-aware settle on "regional" —
## generous, matching how the T-1157 eyeball drivers waited (multi-second
## bound at 60fps), never an unbounded await. A request that's STILL pending
## after this many frames is a real timeout, not "give it a bit longer" —
## the driver logs it and moves on rather than hanging the whole job list.
const SETTLE_MAX_FRAMES: int = 600
var _jobs_path: String = ""
var _output_path: String = ""
var _results: Array = []
func _init():
_run.call_deferred()
func _run() -> void:
_parse_args()
if _jobs_path.is_empty():
push_error("atlas_agent_driver: --jobs PATH is required")
quit(1)
return
if _output_path.is_empty():
push_error("atlas_agent_driver: --output PATH is required")
quit(1)
return
var jobs: Array = _load_jobs(_jobs_path)
if jobs.is_empty():
push_error("atlas_agent_driver: no jobs loaded from %s" % _jobs_path)
quit(1)
return
# SR_PORT wiring (PR #209 eyeball finding) — mirror visual_capture.gd's
# live-mode convention exactly: every sibling real-render driver
# (visual_capture, atlas_standalone, locomotion_sandbox) reads SR_PORT
# rather than silently relying on SimBridge's hardcoded default port;
# without this, a caller who started the server on a chosen port (the
# run-visual --port pattern) gets 20 silent connect retries against the
# wrong port and a hollow session whose act() results still have valid
# SHAPES but no data behind them. SR_LIVE=1 without SR_PORT is a hard
# error, same as visual_capture.gd.
if OS.get_environment("SR_LIVE") == "1":
var port_env := OS.get_environment("SR_PORT")
if port_env.is_empty():
push_error("atlas_agent_driver: SR_LIVE=1 but SR_PORT not set")
quit(1)
return
var sim_bridge := root.get_node("/root/SimBridge")
sim_bridge.server_port = int(port_env)
print("atlas_agent_driver: live mode — server port %d" % sim_bridge.server_port)
var main_scene = load("res://scenes/main.tscn")
var main_node = main_scene.instantiate()
# Technique 1 (T-1157 inventory) — InputSwallower, added FIRST so its low
# process_priority still runs before anything that might otherwise react
# to stray desktop input reaching this unfocused/off-screen window.
var swallower := _InputSwallower.new()
swallower.process_priority = -1000
root.add_child(swallower)
root.add_child(main_node)
# Settle frames — camera smoothing, fog uniform init, UI layout (same
# rationale as visual_capture.gd's own settle-frame pass).
for i in range(10):
await process_frame
var atlas_agent_interface := load("res://ui/implant/apps/atlas/atlas_agent_interface.gd")
var atlas_agent_bridge: Node = root.get_node("/root/AtlasAgentBridge")
for job: Dictionary in jobs:
var intent: String = str(job.get("intent", ""))
var params: Dictionary = job.get("params", {})
var result: Dictionary
if intent == "observe":
result = atlas_agent_interface.observe(atlas_agent_bridge.current_app)
else:
result = atlas_agent_interface.act(atlas_agent_bridge.current_app, intent, params)
await _settle_after_intent(atlas_agent_bridge.current_app)
_results.append({"intent": intent, "params": params, "result": result})
_write_output(_output_path, _results)
quit()
## PR #209 review (Hoshe finding 2) — see this file's own header doc for the
## full rationale. `app` may be null (e.g. after close_atlas) — a null app
## has nothing to poll, so this falls through to the fixed settle only. The
## bounded-fallback DECISION (keep waiting vs stop) is split out into the
## pure, no-await should_keep_waiting() below specifically so it's testable
## without a real SceneTree frame loop (test_atlas_agent_driver.gd drives it
## directly against a fake pending-state + frame counter).
func _settle_after_intent(app: Variant) -> void:
var viewer: Variant = _regional_viewer_if_current(app)
if viewer == null:
for i in range(SETTLE_FIXED_FRAMES):
await process_frame
return
var request: Variant = viewer.get_request()
var waited := 0
while should_keep_waiting(request.is_pending(), waited, SETTLE_MAX_FRAMES):
await process_frame
waited += 1
if request.is_pending():
print(
"atlas_agent_driver: settle timed out after %d frames — request still pending"
% SETTLE_MAX_FRAMES
)
## The pure bounded-fallback decision: keep waiting only while the request is
## STILL pending AND the frame budget isn't exhausted. Static + no SceneTree
## dependency — `is_pending`/`waited`/`max_frames` are plain values a test can
## supply directly, exercising the exact boundary conditions (pending forever
## past the bound stops; resolving early stops immediately) without needing
## a real request object or a real frame loop.
static func should_keep_waiting(is_pending: bool, waited: int, max_frames: int) -> bool:
return is_pending and waited < max_frames
## `app` may be null; "regional" may not even be registered yet this early in
## a session (e.g. before the first open_body). Returns null in either case
## rather than erroring — the caller's fixed-settle fallback covers it.
func _regional_viewer_if_current(app: Variant) -> Variant:
if app == null:
return null
if app.current_screen_id() != "regional":
return null
var regional_screen: Variant = app.get_screen("regional")
if regional_screen == null:
return null
return regional_screen.get_viewer()
func _parse_args() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--jobs":
i += 1
if i < args.size():
_jobs_path = args[i]
"--output":
i += 1
if i < args.size():
_output_path = args[i]
i += 1
static func _load_jobs(path: String) -> Array:
var abs_path: String = path
if path.begins_with("res://"):
abs_path = ProjectSettings.globalize_path(path)
var f := FileAccess.open(abs_path, FileAccess.READ)
if f == null:
return []
var text := f.get_as_text()
f.close()
var parsed: Variant = JSON.parse_string(text)
return parsed if parsed is Array else []
static func _write_output(path: String, results: Array) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
push_error("atlas_agent_driver: cannot open output path %s" % path)
return
f.store_string(JSON.stringify(results, " "))
f.close()
## T-1157 inventory item 1 — see this file's own header doc for the full
## rationale. A tiny inner class rather than a separate file: this pattern
## has exactly one consumer (this driver) today: promote to a shared
## `client/tests/` helper the moment a second `-s` driver needs it.
class _InputSwallower:
extends Node
func _input(_event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _unhandled_input(_event: InputEvent) -> void:
get_viewport().set_input_as_handled()