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>
255 lines
11 KiB
GDScript
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()
|