diff --git a/.claude/rules/agent-native.md b/.claude/rules/agent-native.md index bbde0a78..4cbf344d 100644 --- a/.claude/rules/agent-native.md +++ b/.claude/rules/agent-native.md @@ -121,3 +121,31 @@ Installs the SAME runtime files onto a host and reuses its tooling. See `agent/n log tail natively. - The bundled `tmux.conf` only applies at `/etc/tmux.conf`/`~/.tmux.conf`; a host with its own conf loses truecolor + the OSC 52 copy chain. +- **`TURMA_DEFAULT_RUNTIME` = the per-host default runtime for UNPINNED work** (XERK-521): an + auto-started/unpinned ticket, and a bare "+ New session" with the Runtime dropdown untouched. One + of `{claude,dsh,qwen}`; UNSET → claude, so every current host is unchanged. Resolved in ONE place + (`resolve_agent_type`/`default_runtime`): precedence `explicit agentType → TURMA_DEFAULT_RUNTIME → + claude`, where an EXPLICIT choice is a NON-BLANK `agentType`, applied via `apply_default=True` on + the fresh-spawn call ONLY — every rebuild/resume/migration passes the STORED value with it OFF, so + a resumed session keeps its runtime (a blank pre-field record stays claude, never adopts the + default). + - **Self-validating / fail-safe** (the `local_model_configured` half-config discipline): checked + against THIS host's `dsh_configured`/`qwen_configured`, so a host that sets `qwen` but hasn't + configured it falls back to claude and SAYS so (log + the heartbeat's EFFECTIVE `defaultRuntime`) + — never a broken launch. + - **No hub capability-filter on the dispatch path**: an unpinned ticket carries no runtime, so + `findTicketHost` routes to the most-available host and the CLAIMING host applies its own default + (always runnable by construction). An explicit pin still filters + blocks (`turma-board.md`). + - **"Explicit claude" must SEND `agentType:"claude"` or it reads as unpinned** — a composer + "Claude Code"/"Claude Code Local" pick omitted it (the bare fast path), which on a + non-claude-default host resolves to the default, so `sessions.html` now sends explicit + `agentType:"claude"` for a claude/local pick WHEN the host default is non-claude (byte-for-byte + unchanged on every claude-default host). + - **KNOWN GAP**: a per-ticket CLAUDE *pin* does NOT yet override a non-claude host default — the + board Runtime row treats `{runtime:"claude"}` as RELEASE, so a claude-pinned ticket adopts the + host default (dsh/qwen pins DO override). Closing it needs the Runtime picker to split "Auto — + host default" from a pinned "Claude Code" across `board.js`/`board.cjs`/`Board.kt`/glasses (a UX + change), deferred to a follow-up. Latent today (dsh/qwen behind their kill switches). + - **Known limitation — NONDETERMINISTIC in a MIXED org**: an unpinned ticket runs whatever host + 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/.claude/rules/turma-board.md b/.claude/rules/turma-board.md index 5648b78e..64f6ccd8 100644 --- a/.claude/rules/turma-board.md +++ b/.claude/rules/turma-board.md @@ -239,6 +239,13 @@ button and the auto-start sweep. host to edit. `{runtime:"dsh"|"qwen"}` pins; `{runtime:"claude"}`/`{auto:true}` release (only non-default stored). dsh side: `.claude/rules/dsh.md` [I]; qwen twin ([Qwen I], XERK-515) lives HERE (the bullets below), not duplicated in `qwen.md`. + - **KNOWN GAP with the per-host default (XERK-521): a `{runtime:"claude"}` release does NOT force + claude when a host DEFAULTS to a non-claude runtime.** The pin is dropped, so `spawnTicket` + carries no `agentType` and the claiming host applies its own `TURMA_DEFAULT_RUNTIME` (possibly + dsh/qwen). A dsh/qwen pin overrides (stores + forwards `agentType`); only claude is + release-not-pin. Closing it needs the picker to split "Auto — host default" from a pinned "Claude + Code" across this file's four mirrors (`board.js` + vendored `board.cjs` + `Board.kt` + glasses) + — a UX change deferred to a follow-up. Latent today (dsh/qwen behind their kill switches). - **Offered only when the org offers it** (`site.dshAvailable`/`site.qwenAvailable` via `mergeSites`; `orgOffersDsh`/`orgOffersQwen` gate hub-side) — but an existing pin is always carried back so it can be released even after the last capable host leaves. diff --git a/agent/hub-agent.py b/agent/hub-agent.py index e152b358..66db4a8b 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 @@ -15245,7 +15297,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 @@ -22909,6 +22965,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..7f945c15 100644 --- a/android/PARITY.md +++ b/android/PARITY.md @@ -532,6 +532,21 @@ 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`. **AND — the load-bearing half — an + explicit "Claude Code"/"Claude Code Local" pick must SEND `agentType:"claude"` when the host default + is non-claude** (the web does this in `startSession`): a claude/local spawn normally OMITS + `agentType`, but an omitted `agentType` resolves to the host default agent-side, so on a + non-claude-default host omitting it silently runs the default runtime. Build the pre-select WITHOUT + this and Android reintroduces the exact XERK-521 defect an explicit claude pick is supposed to beat. - **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..5b8d0575 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 : []; @@ -2344,6 +2354,14 @@

resolves to in the sibling sections, where each one is `:first-of-type` // qwen's model/permission handling are [Qwen B]. Send just the runtime choice. body.agentType = "qwen"; } else { + // else = claude or local, both agentType "claude". Normally we OMIT agentType + // so a bare composed spawn stays byte-for-byte the fast path. But when this + // host DEFAULTS to a non-claude runtime (XERK-521), an omitted agentType + // resolves to that default agent-side — so an EXPLICIT "Claude Code" / + // "Claude Code Local" pick must SEND agentType:"claude" to beat the host + // default, exactly as the dsh/qwen picks send theirs. Scoped to a non-claude + // default so every current host's body is unchanged. + if (host && host.defaultRuntime && host.defaultRuntime !== "claude") body.agentType = "claude"; if (runtime === "local") { body.modelSource = "local"; if (localModel) body.localModel = localModel; 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..addf7dbf 100644 --- a/turma/tests/sessions.test.js +++ b/turma/tests/sessions.test.js @@ -2032,6 +2032,66 @@ 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, /