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 @@