## 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()