Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/rules/agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions .claude/rules/turma-board.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 72 additions & 6 deletions agent/hub-agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions agent/native/turma-agent.env
Original file line number Diff line number Diff line change
Expand Up @@ -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

67 changes: 67 additions & 0 deletions agent/tests/test_hub_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions android/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading