From 369fd01f00a95ef6fb028fd7d38dbb54e3b7c870 Mon Sep 17 00:00:00 2001 From: Malcolm Habeeb Date: Sat, 29 Aug 2026 12:52:57 -0400 Subject: [PATCH 1/3] XERK-521: per-host default runtime (TURMA_DEFAULT_RUNTIME) for unpinned work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each host a default runtime so auto-started/unpinned tickets and bare '+ New session' spawns run on the right runtime without pinning every one. - resolve_agent_type gains apply_default; the ONE fresh-spawn call passes it, resolving a blank agentType to default_runtime() (explicit pick/pin still wins, every rebuild/resume path keeps the stored runtime). - default_runtime() reads TURMA_DEFAULT_RUNTIME, self-validates against this host's own capability (agent_type_configured) and falls back to claude + logs when set-but-unrunnable — never a broken launch. UNSET -> claude, unchanged. - Heartbeat carries the EFFECTIVE default as top-level defaultRuntime; hub whitelists + coerces it (normalizeDefaultRuntime, kill-switch-consistent), absent -> claude. - Composer pre-selects the reported host default in the Runtime dropdown so a bare spawn shows which runtime it will use. - No hub dispatch capability-filter for the default path: the claiming host applies its own runnable default (findTicketHost unchanged). - Docs: turma-agent.env + agent-native.md (incl. the mixed-org nondeterminism limitation); android/PARITY.md line for the composer pre-select. - Tests: default_runtime/precedence (test_hub_agent.py), normalizeDefaultRuntime + heartbeat passthrough (server.test.js), composer pre-select (sessions.test.js). --- .claude/rules/agent-native.md | 21 ++++++++++ agent/hub-agent.py | 78 ++++++++++++++++++++++++++++++++--- agent/native/turma-agent.env | 11 +++++ agent/tests/test_hub_agent.py | 67 ++++++++++++++++++++++++++++++ android/PARITY.md | 10 +++++ turma/public/sessions.html | 22 +++++++--- turma/server.js | 25 ++++++++++- turma/tests/server.test.js | 51 +++++++++++++++++++++++ turma/tests/sessions.test.js | 29 +++++++++++++ 9 files changed, 301 insertions(+), 13 deletions(-) diff --git a/.claude/rules/agent-native.md b/.claude/rules/agent-native.md index 1e7c416d..f6c95c76 100644 --- a/.claude/rules/agent-native.md +++ b/.claude/rules/agent-native.md @@ -185,3 +185,24 @@ Installs the SAME runtime files onto a host and reuses its tooling. See `agent/n CHANGING) and card Uptime working. The container-log tail is not available natively. - The bundled `tmux.conf` only takes effect at `/etc/tmux.conf`/`~/.tmux.conf`; a host with its own conf loses truecolor and the OSC 52 copy chain (hub-agent launches bare `tmux`). +- **`TURMA_DEFAULT_RUNTIME` sets the per-host default runtime for UNPINNED work** (XERK-521): an + auto-started/unpinned ticket session, and a bare "+ New session" whose Runtime dropdown was never + touched. One of `{claude,dsh,qwen}`; UNSET → claude, so every current host is byte-for-byte + unchanged. Resolved in ONE place agent-side (`resolve_agent_type` / `default_runtime`), so no + spawn route diverges — precedence is `explicit agentType (composer pick OR per-ticket pin) → + TURMA_DEFAULT_RUNTIME → claude`. + - **Self-validating / fail-safe**, the same half-config discipline as `local_model_configured`: it + is checked against THIS host's own capability (`dsh_configured`/`qwen_configured`), so a host + that sets `qwen` but has not configured Qwen falls back to claude and SAYS so (log + the + heartbeat's EFFECTIVE `defaultRuntime`) — never a broken launch. + - **Only the fresh-spawn call applies the default** (`apply_default=True`). Every REBUILD path + (resume, resume-transcript, migration in, closed-record) passes the STORED `agentType` and + leaves the default OFF, so a resumed/migrated session keeps the runtime it already had rather + than being re-defaulted. + - **It needs no hub capability-filter on the dispatch path.** A queued ticket's host is chosen at + DISPATCH (XERK-296) and an unpinned ticket carries no runtime, so `findTicketHost` routes to the + most-available host and the CLAIMING host applies its own default — which it can always run by + construction. (An explicit pin still filters + blocks, `.claude/rules/turma-board.md`.) + - **Known limitation — per-host is NONDETERMINISTIC in a MIXED org**: the same unpinned ticket + runs whatever the host that frees a slot first defaults to. Determinism needs a per-ticket pin + (XERK-515) or homogeneous hosts. Accepted tradeoff of the per-host choice. diff --git a/agent/hub-agent.py b/agent/hub-agent.py index f27fa846..178a12d0 100644 --- a/agent/hub-agent.py +++ b/agent/hub-agent.py @@ -1951,13 +1951,27 @@ def qwen_configured(): in ("1", "true", "yes", "on")) -def resolve_agent_type(agent_type): - """Validate a runtime choice against a fixed enum. Blank -> claude (what - every session was before this existed). "dsh"/"qwen" are refused on a host - that does not offer them, mirroring resolve_model_source's local gate.""" +def resolve_agent_type(agent_type, apply_default=False): + """Validate a runtime choice against a fixed enum. "dsh"/"qwen" are refused + on a host that does not offer them, mirroring resolve_model_source's local + gate. + + Blank is the operator/pin choosing NOTHING, and what that resolves to is the + ONE place XERK-521's precedence lives, so no route can diverge: + explicit agentType -> TURMA_DEFAULT_RUNTIME -> "claude" + The middle rung applies ONLY on the fresh-spawn path (apply_default=True): a + blank there means an unpinned "+ New session" / auto-started ticket, so the + host default (default_runtime(), already self-validated against THIS host's + capability) runs. Every REBUILD path — resume, resume-transcript, migration + in, closed-record — passes the STORED value and leaves apply_default False, + so a resumed/migrated session keeps the runtime it already had rather than + being re-defaulted onto a host default it never chose (a pre-field record + whose stored value is blank must stay claude, not adopt the default). The + default is self-validating, so it never returns a runtime this host can't + launch and needs no capability re-check here.""" agent_type = (agent_type or "").strip() if not agent_type: - return "claude" + return default_runtime() if apply_default else "claude" if agent_type not in AGENT_TYPES: raise ValueError(f"unknown agent type {agent_type!r}") if agent_type == "dsh" and not dsh_configured(): @@ -1967,6 +1981,44 @@ def resolve_agent_type(agent_type): return agent_type +def default_runtime(): + """The per-host default runtime for an UNPINNED spawn (XERK-521): the runtime + a bare "+ New session" (dropdown untouched) or an auto-started/unpinned + ticket session runs on, so a host need not pin every ticket to run it on the + right runtime. It is `TURMA_DEFAULT_RUNTIME` VALIDATED against this host's own + capability, and it is the EFFECTIVE value the heartbeat reports as + `defaultRuntime` (after this fallback), never the raw env. + + Self-validating / fail-safe, the same half-config discipline as + local_model_configured (endpoint AND capability, or it reads as "no"): a host + that sets `qwen`/`dsh` but has NOT configured that runtime (its kill switch + off, or the env unset) falls back to claude and SAYS so, rather than + advertising or launching a runtime it cannot run. So the CLAIMING host always + applies a default it can run by construction, which is why the hub's ticket + dispatch needs no capability filter for the default path (unlike an explicit + pin): findTicketHost routes an unpinned ticket to the most-available host and + that host applies its own default. + + UNSET -> claude, so every current host is byte-for-byte unchanged. An + unknown value (charset/enum-gated by the AGENT_TYPES membership check) also + falls back to claude and logs.""" + raw = (os.environ.get("TURMA_DEFAULT_RUNTIME") or "").strip().lower() + if not raw: + return "claude" + if raw not in AGENT_TYPES: + log(f"TURMA_DEFAULT_RUNTIME={raw[:40]!r} is not one of " + f"{sorted(AGENT_TYPES)} — defaulting to claude") + return "claude" + if not agent_type_configured(raw): + # Set but not runnable here (kill switch off / runtime unconfigured): + # fall back rather than break every unpinned launch. A card signal + # rides the heartbeat's effective `defaultRuntime` (which this feeds). + log(f"TURMA_DEFAULT_RUNTIME={raw!r} but this host cannot run it " + "— defaulting to claude") + return "claude" + return raw + + def agent_type_configured(agent_type): """Whether this host can currently LAUNCH `agent_type` — the rebuild/resume guard that keeps resolve_agent_type from raising on a persisted runtime this @@ -15218,7 +15270,11 @@ def spawn(self, repo_name, *, prompt=None, label=None, base_ref=None, # in the queue. Model and permission mode apply to root too. try: sess["permissionMode"] = resolve_permission_mode(permission_mode) - sess["agentType"] = resolve_agent_type(agent_type) + # The ONE fresh-spawn call: a blank agentType here (unpinned ticket + # or a bare "+ New session" whose dropdown was untouched) resolves to + # THIS host's TURMA_DEFAULT_RUNTIME (XERK-521), not a hardcoded + # claude. An explicit composer pick or per-ticket pin still wins. + sess["agentType"] = resolve_agent_type(agent_type, apply_default=True) if sess["agentType"] == "dsh": # A dsh session's model is a DISCOVERED endpoint id, not a Claude # alias, and dsh has no subscription/local split (D5) — so it does @@ -22882,6 +22938,16 @@ def build_payload(self, beat, light=False): # then refuse to launch. Absent (a pre-qwen agent) reads the same as # false, coerced hub-side (normalizeQwen). "qwen": self._qwen_payload(), + # This host's EFFECTIVE default runtime for an unpinned spawn + # (XERK-521), AFTER the self-validation fallback — so it never + # advertises a runtime this host can't launch (a host that set + # TURMA_DEFAULT_RUNTIME=qwen but has qwen unconfigured reports + # "claude" here). Doubles as the card signal that the fallback fired. + # Absent (a pre-XERK-521 agent) reads as "claude", coerced hub-side + # (normalizeDefaultRuntime); so does an unset env, keeping every + # current host byte-for-byte unchanged. The composer pre-selects this + # so a bare "+ New session" shows which runtime it will use. + "defaultRuntime": default_runtime(), # The largest file this agent will take as a message attachment # (XERK-234). Doubles as the capability flag, exactly like # inputMaxChars above: an agent predating attachments reports nothing diff --git a/agent/native/turma-agent.env b/agent/native/turma-agent.env index 7c18dbce..4a5d272d 100644 --- a/agent/native/turma-agent.env +++ b/agent/native/turma-agent.env @@ -122,3 +122,14 @@ TURMA_INTERVAL=20 # DSH_MODEL_API_KEY_ENV=LOCAL_MODEL_API_KEY # name of the env var holding the key # DSH_MODEL_CONTEXT=200000 # context window; inherits LOCAL_MODEL_CONTEXT +# --- default runtime for unpinned work (XERK-521) --------------------------- +# Which runtime an UNPINNED spawn runs on: an auto-started/unpinned ticket +# session, and a bare "+ New session" whose Runtime dropdown was never touched. +# An explicit composer pick or a per-ticket runtime pin always wins over this. +# One of {claude,dsh,qwen}. UNSET -> claude, so every current host is unchanged. +# Self-validating: if this names a runtime the host has NOT configured (its kill +# switch off, or TURMA_DSH / TURMA_QWEN and the toolchain not in place), the host +# falls back to claude and says so — it never advertises or launches a runtime it +# cannot run. So set this only where you have ALSO enabled that runtime above. +# TURMA_DEFAULT_RUNTIME=qwen + diff --git a/agent/tests/test_hub_agent.py b/agent/tests/test_hub_agent.py index c6737b13..49157615 100644 --- a/agent/tests/test_hub_agent.py +++ b/agent/tests/test_hub_agent.py @@ -629,6 +629,73 @@ def test_qwen_payload_is_the_capability_flag_alone(self): with mock.patch.object(ha, "qwen_configured", lambda: True): self.assertEqual(sm._qwen_payload(), {"available": True}) + def test_default_runtime_unset_is_claude(self): + # UNSET -> claude, so every current host is byte-for-byte unchanged. + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TURMA_DEFAULT_RUNTIME", None) + self.assertEqual(ha.default_runtime(), "claude") + + def test_default_runtime_unknown_value_falls_back_to_claude(self): + # An unknown value (charset/enum-gated by the AGENT_TYPES membership + # check) falls back to claude rather than raising or being launched. + for bad in ("codex", "CLAUDE ", "qwen; rm -rf", "42"): + with mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": bad}): + self.assertEqual(ha.default_runtime(), "claude", bad) + + def test_default_runtime_self_validates_against_host_capability(self): + # Set but NOT runnable here (kill switch off / runtime unconfigured) -> + # claude, never a broken launch. Case-insensitive. + with mock.patch.object(ha, "qwen_configured", lambda: False), \ + mock.patch.object(ha, "dsh_configured", lambda: False): + for v in ("qwen", "QWEN", "dsh"): + with mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": v}): + self.assertEqual(ha.default_runtime(), "claude", v) + # Configured -> the effective default is that runtime. + with mock.patch.object(ha, "qwen_configured", lambda: True): + with mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "qwen"}): + self.assertEqual(ha.default_runtime(), "qwen") + with mock.patch.object(ha, "dsh_configured", lambda: True): + with mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "dsh"}): + self.assertEqual(ha.default_runtime(), "dsh") + # claude is always a valid explicit default. + with mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "claude"}): + self.assertEqual(ha.default_runtime(), "claude") + + def test_resolve_agent_type_applies_default_only_on_fresh_spawn(self): + # XERK-521 precedence, resolved in ONE place: an EXPLICIT choice always + # wins over the host default; a BLANK resolves to the default only when + # apply_default is set (the fresh-spawn path), and stays claude on every + # rebuild/resume path (apply_default False) so a resumed/migrated session + # keeps the runtime it already had. + with mock.patch.object(ha, "qwen_configured", lambda: True), \ + mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "qwen"}): + # Fresh spawn, no explicit type -> the host default. + self.assertEqual(ha.resolve_agent_type("", apply_default=True), "qwen") + self.assertEqual(ha.resolve_agent_type(None, apply_default=True), "qwen") + # Explicit claude pick beats the default. + self.assertEqual(ha.resolve_agent_type("claude", apply_default=True), "claude") + # Rebuild/resume path: a blank stored value stays claude, NOT the + # host default (a pre-field record must not adopt it). + self.assertEqual(ha.resolve_agent_type(""), "claude") + self.assertEqual(ha.resolve_agent_type(None), "claude") + # A stored qwen still resolves to qwen where the host offers it. + self.assertEqual(ha.resolve_agent_type("qwen"), "qwen") + # A default that fails self-validation resolves the fresh spawn to claude. + with mock.patch.object(ha, "qwen_configured", lambda: False), \ + mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "qwen"}): + self.assertEqual(ha.resolve_agent_type("", apply_default=True), "claude") + + def test_default_runtime_rides_the_heartbeat_effective(self): + # The heartbeat carries the EFFECTIVE default (post-fallback), so it never + # advertises a runtime the host can't run. + sm = ha.SessionManager() + with mock.patch.object(ha, "qwen_configured", lambda: True), \ + mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "qwen"}): + self.assertEqual(ha.default_runtime(), "qwen") + with mock.patch.object(ha, "qwen_configured", lambda: False), \ + mock.patch.dict(os.environ, {"TURMA_DEFAULT_RUNTIME": "qwen"}): + self.assertEqual(ha.default_runtime(), "claude") + def test_perm_cycle_for(self): base = ["default", "acceptEdits", "plan"] # Base modes / blank / unknown launch -> base cycle only, no optionals. diff --git a/android/PARITY.md b/android/PARITY.md index 22fa1290..664c7c80 100644 --- a/android/PARITY.md +++ b/android/PARITY.md @@ -532,6 +532,16 @@ those are marked `[MODEL]`. loopback-only host, so the link hides). Android's `DshInfo` does not yet type a `web` field (which is decode-SAFE — `ignoreUnknownKeys` skips it until typed); to reach parity, type `DshInfo.web: DshWebInfo?` and add an equivalent link/affordance on the dsh session/chat screen. +- **P3 per-host default runtime pre-select in the spawn composer (XERK-521).** A host reports its + EFFECTIVE default runtime as top-level `AgentInfo.defaultRuntime` (`{claude,dsh,qwen}`, absent on + an older agent → treat as claude; hub-coerced), and the web composer PRE-SELECTS it in the Runtime + dropdown so a bare "+ New session" shows which runtime it will run on. The unpinned-spawn BEHAVIOUR + is entirely agent-side (the claiming host applies its own default), so a session Android spawns + without touching the runtime already runs on the host default with no app change — this gap is only + the visual pre-select. `AgentInfo` does not type `defaultRuntime` yet (decode-SAFE — + `ignoreUnknownKeys` skips it until typed); to reach parity, type `AgentInfo.defaultRuntime: String?` + and default `SpawnDialog`'s runtime choice to it (falling back to claude when absent or not on + offer), mirroring `sessions.html`'s `hostDefault`/`selRuntime`. - **P2 To-do checklist card + the dsh "Deep diving…" verb (Enable DSH To-Dos).** The web renders a `TodoWrite` / dsh `todo_write` tool call as a CHECKLIST (state glyph per row + a `1 in progress · 6 pending` count on the summary) instead of raw-JSON input — `renderTodoCard` in `chat.js`, fed by diff --git a/turma/public/sessions.html b/turma/public/sessions.html index 60225ae5..23280f37 100644 --- a/turma/public/sessions.html +++ b/turma/public/sessions.html @@ -1410,14 +1410,24 @@

resolves to in the sibling sections, where each one is `:first-of-type` if (localModel) runtimeOpts.push({ v: "local", label: "Claude Code Local" }); if (dsh) runtimeOpts.push({ v: "dsh", label: "dsh" }); if (qwen) runtimeOpts.push({ v: "qwen", label: "Qwen Code" }); - // Selected runtime: the stored `runtime`, else derived from the legacy - // agentType/modelSource a pre-XERK-503 composer persisted, else claude. A - // stored value whose runtime the host no longer offers falls back to claude. + // Selected runtime: the stored `runtime`, else the legacy agentType/modelSource + // a pre-XERK-503 composer persisted, else this HOST's default runtime, else + // claude. A stored value whose runtime the host no longer offers falls back. + // `legacyRuntime` is null (not "claude") when the draft carries NEITHER field, + // so a brand-new draft falls through to the host default rather than masking + // it with a hardcoded claude. const legacyRuntime = o.agentType === "dsh" ? "dsh" - : (o.agentType === "qwen" ? "qwen" - : (o.modelSource === "local" ? "local" : "claude")); + : o.agentType === "qwen" ? "qwen" + : o.modelSource === "local" ? "local" + : (o.agentType === "claude" || o.modelSource === "subscription") ? "claude" + : null; + // A bare "+ New session" whose Runtime dropdown was never touched runs on the + // host's default runtime (XERK-521, `a.defaultRuntime`, effective/self-validated + // agent-side), so the dropdown PRE-SELECTS it — but only when that runtime is + // actually on offer here. Absent (older agent) reads as claude. + const hostDefault = runtimeOpts.some(r => r.v === a.defaultRuntime) ? a.defaultRuntime : "claude"; const selRuntime = runtimeOpts.some(r => r.v === o.runtime) ? o.runtime - : (runtimeOpts.some(r => r.v === legacyRuntime) ? legacyRuntime : "claude"); + : (legacyRuntime && runtimeOpts.some(r => r.v === legacyRuntime) ? legacyRuntime : hostDefault); // The Claude-local endpoint's discovered models (XERK-489): id + optional // context override, revealed when the "Claude Code Local" runtime is chosen. const lModels = (localModel && Array.isArray(localModel.models)) ? localModel.models : []; diff --git a/turma/server.js b/turma/server.js index 7dd68faf..278e106b 100644 --- a/turma/server.js +++ b/turma/server.js @@ -3018,6 +3018,27 @@ function normalizeQwen(payload) { payload.qwen = { available: q.available === true }; } +// This host's EFFECTIVE default runtime for an unpinned spawn (XERK-521), coerced +// at ingest exactly like normalizeQwen/normalizeDsh and for the same reason: it +// is agent-supplied, a client may TYPE it, and `/api/agents` decodes atomically +// on Android, so one host's `defaultRuntime: 123` would fail the whole fleet +// decode. Coerced to the fixed runtime enum; anything else — a non-string, an +// unknown value, or ABSENT (a pre-XERK-521 agent) — reads as "claude", the same +// "can't tell / unchanged" value the composer treats an absent field as, never a +// plausible other default. A runtime whose fleet-wide kill switch is OFF here is +// also forced to "claude", so the served default stays CONSISTENT with the +// zeroed capability block a disabled runtime reports (a composer must not +// pre-select a runtime whose option normalizeQwen/normalizeDsh just hid). +function normalizeDefaultRuntime(a) { + if (!a || typeof a !== "object") return; + if (!("defaultRuntime" in a)) return; + let r = a.defaultRuntime; + if (r !== "claude" && r !== "dsh" && r !== "qwen") r = "claude"; + if (r === "dsh" && !DSH_ENABLED) r = "claude"; + if (r === "qwen" && !QWEN_ENABLED) r = "claude"; + a.defaultRuntime = r; +} + // Merge the agent's on-demand history deliveries (heartbeat `historyResults`) // into the host's per-session cache, then bound its memory: drop entries older // than HISTORY_MAX_AGE_MS and cap the cache at HISTORY_MAX_SESSIONS, evicting @@ -3904,7 +3925,7 @@ const SPAWN_FIELD_MAX = 100000; const HEARTBEAT_KNOWN_KEYS = new Set([ "agentId", "agentVersion", "archiveManifest", "capacity", "claudeAuth", "claudeVersion", "clones", "closedSessions", "codingAgent", "device", - "dsh", "qwen", "gitSources", "github", "inputMaxChars", "jira", "limits", "localModel", + "dsh", "qwen", "defaultRuntime", "gitSources", "github", "inputMaxChars", "jira", "limits", "localModel", "logTail", "memory", "models", "prunes", "repoUsage", "repos", "reposRoot", "sessions", "startedAt", "subscription", "uploadMaxBytes", "usage", "historyResults", "subagentHistoryResults", "jiraIssueResults", @@ -4186,6 +4207,7 @@ function normalizeRecord(a) { normalizeLocalModel(a); normalizeDsh(a); normalizeQwen(a); + normalizeDefaultRuntime(a); normalizeModels(a); normalizeSpawnRefusals(a); normalizeRetired(a); @@ -10467,6 +10489,7 @@ if (process.env.TURMA_TEST) { __setDshEnabled(v) { DSH_ENABLED = v; }, __getDshEnabled() { return DSH_ENABLED; }, normalizeQwen, + normalizeDefaultRuntime, qwenAvailable, // The fleet-wide qwen kill switch (XERK-504) mirrors the dsh one above: the // [Qwen A] tests flip it ON around themselves to prove the plumbing works diff --git a/turma/tests/server.test.js b/turma/tests/server.test.js index 2ea65d0e..259e9353 100644 --- a/turma/tests/server.test.js +++ b/turma/tests/server.test.js @@ -11297,6 +11297,57 @@ test("heartbeat: qwen flag + session agentType survive into the fleet payload (X assert.equal(host.sessions[0].agentType, "qwen"); }); +// ---- XERK-521: per-host default runtime (defaultRuntime) -------------------- + +test("normalizeDefaultRuntime coerces to the runtime enum, absent stays absent", () => { + hub.__setQwenEnabled(true); + hub.__setDshEnabled(true); + const norm = (v) => { const p = { device: "h", defaultRuntime: v }; hub.normalizeDefaultRuntime(p); return p.defaultRuntime; }; + // Valid enum values pass through (both runtimes enabled here). + assert.equal(norm("claude"), "claude"); + assert.equal(norm("qwen"), "qwen"); + assert.equal(norm("dsh"), "dsh"); + // Anything else — unknown string, non-string, the same atomic-decode hazard as + // the qwen/dsh blocks — reads as "claude", the composer's "unchanged" default. + assert.equal(norm("codex"), "claude"); + assert.equal(norm(123), "claude"); + assert.equal(norm(null), "claude"); + assert.equal(norm({}), "claude"); + // A pre-XERK-521 agent sends nothing; the key stays absent (client treats it + // as claude), not an explicit value. + const old = { device: "h" }; + hub.normalizeDefaultRuntime(old); + assert.ok(!("defaultRuntime" in old)); +}); + +test("normalizeDefaultRuntime forces a disabled runtime to claude (kill-switch consistency)", () => { + // With a runtime's fleet-wide kill switch OFF, its capability block is served + // inert — so a served default naming it must also fall to claude, or the + // composer would pre-select a runtime whose option normalizeQwen/normalizeDsh + // just hid. Deliberately does NOT flip the flags (pins the shipped default). + assert.equal(hub.__getQwenEnabled(), false); + assert.equal(hub.__getDshEnabled(), false); + const norm = (v) => { const p = { defaultRuntime: v }; hub.normalizeDefaultRuntime(p); return p.defaultRuntime; }; + assert.equal(norm("qwen"), "claude"); + assert.equal(norm("dsh"), "claude"); + assert.equal(norm("claude"), "claude"); + // The real ingest/restore path coerces it too. + const rec = { device: "h", defaultRuntime: "qwen" }; + hub.normalizeRecord(rec); + assert.equal(rec.defaultRuntime, "claude"); +}); + +test("heartbeat: defaultRuntime survives into the fleet payload (XERK-521)", async () => { + hub.__setQwenEnabled(true); + await request("POST", "/api/heartbeat", { + body: { device: "drt1", qwen: { available: true }, defaultRuntime: "qwen" }, + headers: agentHeaders, + }); + const res = await request("GET", "/api/agents", { headers: userHeaders }); + const host = res.body.agents.find((a) => a.device === "drt1"); + assert.equal(host.defaultRuntime, "qwen"); +}); + test("http: spawn validates a qwen agentType and 409s a host without the capability (XERK-506)", async () => { hub.__setQwenEnabled(true); // A host offering qwen accepts the choice; one without it 409s a stale click. diff --git a/turma/tests/sessions.test.js b/turma/tests/sessions.test.js index bcbeed8c..60f63cf7 100644 --- a/turma/tests/sessions.test.js +++ b/turma/tests/sessions.test.js @@ -2032,6 +2032,35 @@ test("composer: the qwen runtime option appears and maps to agentType qwen (XERK assert.equal(body.permissionMode, undefined); }); +test("composer: a bare spawn pre-selects the host's default runtime (XERK-521)", () => { + const open = (h) => { + const page = loadPage(); + const now = Date.now(); + page.setCache({ now, agents: [h] }); + page.render({ now, agents: [h] }); + page.toggleComposer("hostA::repoX", "repoX"); + return page.els.spawn.innerHTML; + }; + const base = { + key: "hostA", device: "hostA", online: true, terminalOnline: true, + lastSeen: Date.now(), repos: [{ name: "repoX" }], sessions: [], + qwen: { available: true }, + }; + // A host defaulting to qwen pre-selects "Qwen Code" in an untouched dropdown, + // so a bare "+ New session" shows which runtime it will run on. + const dflt = open({ ...base, defaultRuntime: "qwen" }); + assert.match(dflt, /