0014 — headless-DX response shape (is_headless-gated guidance)
Context
Under --headless --editor the plugin loads and the vast majority of tools behave identically to a display editor, but a handful silently degrade rather than guiding the caller:
game.startreturnedsuccess:true, runtime_ready:falseeven though the game process cannot launch without a display, so Mode B (the runtime WebSocket) never connects — a false-success (nothing stays playing).editor_get_consolereturnedcount:0for an editor parse-error capture — reads like “no matches” rather than “a headless editor doesn’t revalidate scripts, so parse errors aren’t captured here.”node_call_methodreturnedINVALID_METHODon a reloaded node’s freshly-added method on 4.4+ headless — reads like a bug, not “a headless editor never re-instantiates the live node.”
A full-suite headless probe (41n-quater, Godot 4.5) found 15 of 531 checks failing this way, and 41n-quater shipped CI workarounds (smoke --skip 10,14, flows --skip 2) to stay green. editor.screenshot was already the exemplar — an early is_headless() guard returning HEADLESS_UNSUPPORTED + “requires a display server.”
The real defect is misleading, not non-deterministic: the degraded responses were already deterministic; what they lacked was honesty — a code and/or a hint telling an LLM (or a CI job) why the tool is degraded and what to do instead. See the plan-repo iteration 41n-quater-bis-headless-dx-determinism.md and Insights/stale-live-instance-method-hazard.md.
Decision
Bring every headless-divergent tool up to the editor.screenshot bar — a deterministic, self-explaining response — under one controlling invariant, plus a wire signal that lets the server branch its headless assertions.
- Per-tool guidance (each an early guard / gated branch):
game.start— earlyis_headless()guard returnsHEADLESS_UNSUPPORTED(the existing code, reused — not a new one) with a redirect toscript_check/ scene inspection /editor_get_console.editor_get_console— an additiveheadless_hintattached whenever error capture is requested (level_filterincludes"error"OR atext_filteris set), regardless of match count, steering toscript_check. Buffer/file capture mechanics are untouched.node_call_method— the existing reactive stale-instance hint is widened to also fire on 4.4+ headless (a display editor hot-reloads, sohas_methodwould already be true; a headless editor never re-instantiates the reloaded node), with a headless-specific message distinct from the< 4.4engine-cache wording.editor.screenshot— the exemplar guard is enriched with a headless-accurate redirect (script_checkprimary;editor_get_console= runtime output only, since its own parse-capture is headless-degraded).
-
Controlling invariant —
is_headless-gated, display path byte-identical. Every headless-divergent branch is gated onis_headless()(DisplayServer.get_name() == "headless", viaModules.VersionUtils.is_headless()). No headless workaround may alter a display response. Each fix is an early guard or a gated branch, never a refactor of the shared display path (see “the deliberate deviation” below). - Server-side signal — the handshake
headlessfield. The Mode-A auth ack (_build_auth_ack) carries"headless": is_headless()alongsidegodot_version, so the bridge exposesbridge.isHeadless()and the smoke/flows suites branch their headless assertions off the wire signal instead of a per-suiteeditor.screenshotprobe. The runtime autoload’s bare{authed:true}is unchanged. The field is additive and follows thegodot_versionprecedent — it gets a contract-ledger entry + adata-verifiedbump, not a standalone ADR; this policy ADR records it.
The deliberate deviation — why the guards stay scattered
A future contributor could reasonably want to unify the scattered is_headless() early guards into a shared helper on the common path. That is deliberately not done here. The display responses are byte-identical and battle-tested by the full smoke + flows suites; a shared-path refactor risks perturbing them for no behavioral gain. Keeping each guard local to its tool makes the display path provably untouched (grep for is_headless( shows every divergence at its call site). This ADR exists so that locality is read as intentional, not as duplication to be “cleaned up.”
Consequences
- An LLM (or CI) running headless gets deterministic, actionable guidance from every divergent tool instead of a false-success, an empty result, or a bare error that reads like a bug.
- The 41n-quater
smoke --skip 10,14/flows --skip 2workarounds are removed; the full suite runs headless, asserting the new deterministic responses (including the positiveheadless_hintproof). - Display behavior is unchanged — the gated branches never fire on a display editor.
- The pure
StaleInstanceHintdecision gains aheadlessparameter and is unit-tested editor-free across the (headless × version) axis; the reactive caller feeds itModules.VersionUtils.is_headless(). - The handshake gains one additive boolean;
bridge.isHeadless()isundefinedpre-auth (mirroringgetGodotVersion()), never a defaultedfalsethat would falsely claim “display.”
Considered and rejected
- Make Mode-B playtest work headless (spawn a
--headlesschild game so the runtime WS binds). The runtime autoload is display-agnostic (mcp_runtime_server.gdgates only editor /--check-only/!has_feature("editor")), so this is likely feasible — but the blocker is the upstream editor play path, not the toolkit. Filed PostRelease [High] (Plan/Ideas/PostRelease/2026-07-02-headless-mode-b-playtest.md). Until then, deterministic-unavailable is the honest response. - Make headless hot-reload actually re-instantiate (4.4+). Likely async-scan / idle timing, not an architectural block, but it must prove reliably green (10/10 on 4.4–4.7, both editors) before it could gate CI. Filed PostRelease [High] (
Plan/Ideas/PostRelease/2026-07-03-headless-hot-reload-reinstantiation.md). Until then, the stale hint is the honest response. - A headless re-emit affordance so editor parse errors ARE captured (force
filesystem.update_file→ capture via the 4.5+OS.add_loggersink). Engine proposal #13479 blockssource="file"forever, so this is the only future path to add headless capture — but it is its own design. Filed PostRelease [Low] (Plan/Ideas/PostRelease/2026-07-03-headless-editor-parse-capture-reemit.md). Until then,headless_hintsteers toscript_check. - A dedicated new error code for headless-unsupported playtest. Rejected:
HEADLESS_UNSUPPORTEDalready exists and is the exemplar; reusing it keeps the code vocabulary tight (one code per condition). - Unify the guards on the shared path. Rejected — see “the deliberate deviation” above.