Merge remote-tracking branch 'origin/eric/dev' into eric/browser-merged

This commit is contained in:
ciregenz
2026-08-08 09:22:33 -07:00
106 changed files with 5206 additions and 273 deletions
+5 -1
View File
@@ -46,6 +46,8 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
@typechecked
def __init__(self):
self.sessions: Dict[str, AgentSession] = {}
from backend.apps.agents.core.flight_recorder import set_sessions_provider
set_sessions_provider(lambda: self.sessions)
self.tasks: Dict[str, asyncio.Task] = {}
# Live mirror of the in-flight streamed assistant text per session, so a stop can persist the partial reply instantly instead of waiting out the multi-second SDK teardown the cancel handler sits behind.
self.live_partial: Dict[str, PartialReply] = {}
@@ -125,7 +127,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
# off), so an empty prewarm prompt would boot a different thinking config than a typical
# first message and fingerprint-miss into a respawn. 50+ chars matches the common case.
p_representative = "prewarm placeholder prompt of representative length for boot"
(options, options_kwargs, _pc, _stderr, _gs) = await self.build_agent_options(
(options, options_kwargs, _, _, _) = await self.build_agent_options(
session, session_id, p_representative, "", builtin_perms,
None, None, None, False, p_router_model_id, p_api_type)
from claude_agent_sdk import ClaudeSDKClient
@@ -273,9 +275,11 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
try:
from backend.apps.service.client import submit_diagnostic
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
from backend.apps.agents.core import flight_recorder as p_fr
submit_diagnostic({
"kind": "context_pressure_valve",
"trigger": "overflow" if p_overflow else "pressure_death",
"flight": p_fr.build_envelope(session_id, "context_pressure_valve", "overflow" if p_overflow else "pressure_death", session.model, "stream", turn.compact_boundaries),
"session_id": session_id,
"model": session.model,
"compact_boundaries": turn.compact_boundaries,
+152
View File
@@ -0,0 +1,152 @@
"""Per-session flight recorder: a fixed breadcrumb ring appended at points that already log,
flushed into a diagnostic envelope ONLY when an error surfaces (or a silent recovery is counted).
The happy path pays one O(1) deque append per event and nothing else; nothing here touches disk
or network on its own."""
import os
import threading
import time
from collections import deque
from typing import Dict, List, Optional
from typeguard import typechecked
# Kill switch: OSW_FLIGHT=0 turns every sensor into a no-op, which is also how the A/B that proves
# the recorder costs nothing is run.
P_ENABLED = os.environ.get("OSW_FLIGHT", "1") != "0"
P_RING_SIZE = 64
p_lock = threading.Lock()
p_rings: Dict[str, deque] = {}
p_sessions_provider = None
def set_sessions_provider(provider) -> None:
"""agent_manager registers its live sessions dict once so envelopes built anywhere (error
handlers have no manager handle) still carry a real concurrency snapshot."""
global p_sessions_provider
p_sessions_provider = provider
@typechecked
def crumb(session_id: str, label: str, **meta: object) -> None:
"""Append one breadcrumb; cheap enough for every retry decision and phase stamp."""
if not P_ENABLED:
return
entry = {"l": label, "t": round(time.time(), 3)}
for k, v in meta.items():
if v is not None:
entry[k] = v if isinstance(v, (int, float, bool)) else str(v)[:200]
with p_lock:
ring = p_rings.get(session_id)
if ring is None:
ring = deque(maxlen=P_RING_SIZE)
p_rings[session_id] = ring
ring.append(entry)
@typechecked
def drop_session(session_id: str) -> None:
"""Sessions are deleted often; their crumbs must not accumulate forever."""
with p_lock:
p_rings.pop(session_id, None)
@typechecked
def breadcrumbs(session_id: str, last: int = 20) -> List[dict]:
with p_lock:
ring = p_rings.get(session_id)
return list(ring)[-last:] if ring else []
@typechecked
def lane_for_model(model: Optional[str]) -> str:
"""The routing lane is a first-class confounder: cc/cx/gc ride the local router, api goes direct."""
m = model or ""
if m.endswith("-cc") or m.startswith("cc/"):
return "cc"
if m.endswith("-cx") or m.startswith("cx/"):
return "cx"
if m.startswith(("gc/", "gemini", "ag/")):
return "gc"
if m.startswith(("openrouter/", "cp-")):
return "openrouter" if m.startswith("openrouter/") else "custom"
return "api"
@typechecked
def concurrency_snapshot(sessions: Dict[str, object]) -> dict:
"""The tandem set at event time, read from state already in memory; assembled only when an
envelope is being built, never on the hot path."""
try:
statuses = [getattr(s, "status", None) for s in sessions.values()]
return {
"sessions_total": len(statuses),
"turns_running": sum(1 for s in statuses if s == "running"),
}
except Exception:
return {"sessions_total": -1, "turns_running": -1}
@typechecked
def journey_auth_context() -> dict:
"""Who the user is and where they are in the product when it broke. An error during onboarding
on a free trial is a DIFFERENT bug from the same error for a returning own-key user, and without
this they look identical in analytics."""
try:
from backend.apps.settings.store import load_settings
st = load_settings()
onboarding = getattr(st, "onboarding_v3", None)
return {
"stage": "onboarding" if onboarding in (None, "", "in_progress") else "returning",
"signed_in": bool(getattr(st, "user_id", None)),
"signin_method": getattr(st, "signin_method", None),
"connection_mode": getattr(st, "connection_mode", "own_key"),
"has_own_key": bool(getattr(st, "anthropic_api_key", None) or getattr(st, "openai_api_key", None)),
}
except Exception:
return {"stage": "unknown", "signed_in": False}
@typechecked
def build_envelope(
session_id: str,
family: str,
subkind: str,
model: Optional[str],
phase: str,
attempts: int,
sessions: Optional[Dict[str, object]] = None,
) -> dict:
"""Everything a stranger needs to diagnose the failure without the machine in front of them."""
return {
"family": family,
"subkind": subkind,
"lane": lane_for_model(model),
"model": model,
"phase": phase,
"attempts": attempts,
"breadcrumbs": breadcrumbs(session_id),
"journey": journey_auth_context(),
"concurrency": concurrency_snapshot(sessions if sessions is not None else (p_sessions_provider() if p_sessions_provider else {})),
}
@typechecked
def record_recovery(session_id: str, net: str, model: Optional[str], attempts: int, sessions: Optional[Dict[str, object]] = None) -> None:
"""The near-miss ledger: a silent recovery the user never saw still counts in analytics, so
'how often do the nets fire' has a denominator. Fire-and-forget; failures never block the turn."""
crumb(session_id, "recovered", net=net, attempts=attempts)
try:
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({
"kind": "recovered",
"subkind": net,
"session_id": session_id[:8],
"lane": lane_for_model(model),
"model": model,
"attempts": attempts,
"journey": journey_auth_context(),
"concurrency": concurrency_snapshot(sessions or {}),
})
except Exception:
pass
@@ -0,0 +1,27 @@
"""Terminal shape of "our own router is down", kept beside the classifier it delegates to."""
import re
from typeguard import typechecked
from backend.apps.agents.core.error_classify import is_router_unreachable_error
@typechecked
def is_router_unavailable_error(text: str) -> bool:
"""True when the turn died because our own localhost router was down, either because the CLI
could not reach it or because we refused to start at all. Distinct from
`is_router_unreachable_error`, which is the narrower mid-turn "resume and carry on" case: this
one is the terminal shape, and it exists so the envelope names a cause instead of shrugging
'unclassified' at the one failure whose fix is entirely ours."""
if not text.strip():
return False
if is_router_unreachable_error(text):
return True
return bool(re.search(
r"9router\s+is\s+not\s+running"
r"|9router\s+could\s+not\s+start"
r"|9router.{0,30}not\s+ready",
text,
re.IGNORECASE,
))
@@ -232,6 +232,8 @@ async def configure_provider_env(
logger.info(f"[MCP-DEBUG] Using 9Router (api_type={api_type})")
else:
# router_available() above already attempted a revival; reaching here means it truly can't start.
from backend.apps.agents.core import flight_recorder
flight_recorder.crumb(session.id, "router-unavailable", model=session.model, api=api_type)
if api_type != "anthropic" or resolved_is_9router:
raise ValueError(
f"9Router is not running; cannot use {session.model}. "
@@ -12,6 +12,7 @@ from typing import Dict, List, Optional, Union
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.core import flight_recorder
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import load_all_tools, sanitize_server_name
@@ -52,6 +53,7 @@ class RunOptions(AgentManagerProtocol):
from claude_agent_sdk.types import HookMatcher
logger.info(f"[SPAWN-PHASE] options-build start session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "options-build")
# Per-SESSION hook context, updated in place each turn: with a persistent client the hooks the
# CLI holds were bound at connect, so they must read this stable object, not a per-turn rebuild.
@@ -119,6 +121,7 @@ class RunOptions(AgentManagerProtocol):
# Pass session.active_mcps as the activation filter. Empty list ⇒ no MCP tools shipped to the SDK; the model must MCPSearch and MCPActivate first. The product invariant lives here at the dispatch layer (see build_mcp_servers docstring).
logger.info(f"[SPAWN-PHASE] mcp-build start session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "mcp-build")
mcp_servers = await self.build_mcp_servers(session.allowed_tools, session.active_mcps)
logger.info(f"[SPAWN-PHASE] mcp-build done session={session_id[:8]} t={time.monotonic():.3f}")
@@ -198,6 +201,7 @@ class RunOptions(AgentManagerProtocol):
}
# cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key.
logger.info(f"[SPAWN-PHASE] provider-env start session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "provider-env")
await configure_provider_env(
options_kwargs, session, resolved_model, api_type, global_settings
)
@@ -285,6 +289,7 @@ class RunOptions(AgentManagerProtocol):
# Compaction trigger (Phase 2). Driven by live ctx_used ratio rather than turn count, fires when input_tokens/context_window crosses session.compact_threshold_pct (default 0.65). Cheap, programmatic summarization (no aux LLM call) so this adds zero latency on the user's turn.
logger.info(f"[SPAWN-PHASE] context-guard start session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "context-guard")
await pre_send_context_guard(self, session, session_id)
logger.info(f"[SPAWN-PHASE] context-guard done session={session_id[:8]} t={time.monotonic():.3f}")
+27 -1
View File
@@ -12,10 +12,12 @@ from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.core.error_classify import CAPACITY_BACKOFFS, capacity_retry_wait, is_router_unreachable_error
from backend.apps.agents.core import flight_recorder
from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState
from backend.apps.agents.manager.streaming.handle_stream_event import handle_stream_event
from backend.apps.agents.manager.streaming.handle_assistant_message import handle_assistant_message
from backend.apps.agents.manager.streaming.handle_result_message import TurnResultError, handle_result_message
from backend.apps.agents.manager.streaming.note_provider_retry import note_provider_retry, settle_provider_retries
from backend.apps.agents.manager.run.client_pool import (
SdkClientLike,
acquire_client,
@@ -102,14 +104,18 @@ class TurnRunner(AgentManagerProtocol):
if turn.first_event:
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
flight_recorder.crumb(session_id, "first-event", kind=type(message).__name__)
turn.first_event = False
# Log system messages (MCP server status, errors, etc.)
if isinstance(message, SystemMessage):
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
if getattr(message, "subtype", "") == "compact_boundary":
p_subtype = getattr(message, "subtype", "")
if p_subtype == "compact_boundary":
turn.compact_boundaries += 1
elif p_subtype == "api_retry":
note_provider_retry(session_id, raw, turn)
if isinstance(message, StreamEvent):
await handle_stream_event(
@@ -117,10 +123,12 @@ class TurnRunner(AgentManagerProtocol):
)
elif isinstance(message, AssistantMessage):
flight_recorder.crumb(session_id, "assistant-msg")
await handle_assistant_message(
message, session, session_id, turn, thinking, self.live_partial, self.sessions
)
elif isinstance(message, ResultMessage):
flight_recorder.crumb(session_id, "result-msg", subtype=str(getattr(message, "subtype", "")))
await handle_result_message(
message, session, session_id, turn, thinking, self.sessions,
resolved_model, api_type, global_settings,
@@ -132,12 +140,15 @@ class TurnRunner(AgentManagerProtocol):
async def p_connect():
p_client = ClaudeSDKClient(options=options)
logger.info(f"[SPAWN-PHASE] cli-connect start session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "cli-connect-start")
await p_client.connect()
logger.info(f"[SPAWN-PHASE] cli-connect done session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "cli-connect-done")
return p_client
fp = boot_fingerprint(options_kwargs, session)
logger.info(f"[SPAWN-PHASE] client-acquire start session={session_id[:8]} t={time.monotonic():.3f}")
flight_recorder.crumb(session_id, "client-acquire")
handle = await acquire_client(
self.client_pool, session_id, fp, p_connect, force_respawn=force_respawn,
)
@@ -176,12 +187,25 @@ class TurnRunner(AgentManagerProtocol):
p_use_persistent = persistent_client_enabled()
capacity_retry_attempt = 0
p_router_retry_attempt = 0
# Baseline crumb so even a first-call failure's envelope names the turn it died in.
flight_recorder.crumb(session_id, "turn-start", model=resolved_model, api=api_type)
while True:
try:
if p_use_persistent:
await p_run_streaming_turn_persistent()
else:
await p_run_streaming_turn()
# The near-miss ledger: a turn that needed retries and still finished is a net that
# FIRED, and "how often do the nets fire" needs a denominator in analytics.
if p_router_retry_attempt or capacity_retry_attempt:
flight_recorder.record_recovery(
session_id,
net="router-resume" if p_router_retry_attempt else "transient-backoff",
model=resolved_model,
attempts=p_router_retry_attempt + capacity_retry_attempt,
sessions=self.sessions,
)
settle_provider_retries(session_id, turn, resolved_model, self.sessions)
break
except TurnResultError as p_result_err:
# "Unable to connect" in a turn result is the CLI failing to reach our own localhost
@@ -190,6 +214,7 @@ class TurnRunner(AgentManagerProtocol):
# conversation without re-executing side effects: re-ensure the router, resume, go.
if p_router_retry_attempt < 2 and is_router_unreachable_error(str(p_result_err)):
p_router_retry_attempt += 1
flight_recorder.crumb(session_id, "router-retry", attempt=p_router_retry_attempt, err=str(p_result_err)[:160])
logger.warning(
f"Router unreachable mid-turn on session {session_id} "
f"(attempt {p_router_retry_attempt}/2); re-ensuring router and resuming. "
@@ -228,6 +253,7 @@ class TurnRunner(AgentManagerProtocol):
wait = 0.0
if wait is not None:
capacity_retry_attempt += 1
flight_recorder.crumb(session_id, "transient-retry", attempt=capacity_retry_attempt, wait=wait, err=str(e)[:160])
mid_stream = turn.current_turn_emitted
logger.warning(
f"Transient upstream error on session {session_id} "
@@ -84,10 +84,16 @@ class ClientHandle(BaseModel):
# A pooled CLI holds ~100MB+ per session; evict clients idle past this so parked chats don't accumulate subprocesses (respawn on the next message is the normal cold path).
IDLE_EVICT_SECONDS = float(os.environ.get("OSW_CLIENT_IDLE_EVICT_SECONDS", "1800"))
# 10 minutes, down from 30: the packaged stress run measured ~108MB per parked CLI, and prewarm puts
# the respawn cost on the next message at ~0.5-1.6s, so half an hour of held RAM per finished chat
# bought almost nothing. Overridable for machines with RAM to burn.
IDLE_EVICT_SECONDS = float(os.environ.get("OSW_CLIENT_IDLE_EVICT_SECONDS", "600"))
# Hard ceiling on warm CLIs regardless of idle age: past this, the least-recently-used IDLE sessions are disposed (they respawn ~0.5s on their next message), bounding the "30 chats open" resident-memory case. Kept a SOFT cap: a mid-turn or just-acquired client is never evicted, so a burst of live turns may exceed it rather than kill work.
MAX_LIVE_CLIENTS = int(os.environ.get("OSW_CLIENT_MAX_LIVE", "12"))
# 8, down from 12: the measured ceiling at 12 was ~1.3GB of CLIs alone, which is brutal on the 8GB
# machines we explicitly support. Still a SOFT cap: live turns are never evicted, so real concurrent
# work can exceed it; only parked chats pay.
MAX_LIVE_CLIENTS = int(os.environ.get("OSW_CLIENT_MAX_LIVE", "8"))
# Never cap-evict a client used this recently; far larger than the acquire->lock window, so a just-acquired client can't be reaped before its turn takes the lock.
LRU_GUARD_SECONDS = float(os.environ.get("OSW_CLIENT_LRU_GUARD_SECONDS", "5"))
# Timer cadence for the background reclaim; the acquire-time sweep is lazy (fires only when some session takes a turn), this one catches an all-quiet pool.
@@ -43,7 +43,19 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool:
logger.warning(f"Agent {session_id}: turn finished with no answer after tool work; one hidden continue nudge")
try:
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({"kind": "empty_finish_nudge", "session_id": session_id, "model": session.model})
from backend.apps.agents.core import flight_recorder as p_fr
# A silent quit is the hardest class to diagnose after the fact, so it gets the same envelope
# as a hard error: without breadcrumbs you cannot see what the turn was doing when it gave up.
submit_diagnostic({
"kind": "empty_finish_nudge",
"session_id": session_id,
"model": session.model,
"tool_calls": p_tool_calls,
"nudge": session.empty_finish_nudges,
"flight": p_fr.build_envelope(
session_id, "empty_finish_nudge", "silent_quit", session.model, "stream", session.empty_finish_nudges,
),
})
except Exception:
pass
return True
@@ -22,12 +22,33 @@ from backend.apps.agents.core.error_classify import (
is_unknown_model_error,
parse_retry_after,
)
from backend.apps.agents.core.is_router_unavailable_error import is_router_unavailable_error
from backend.apps.agents.core.extract_reset_hint import extract_reset_hint
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
from backend.apps.agents.core import flight_recorder
logger = logging.getLogger(__name__)
@typechecked
def p_report_model_error(subkind: str, session_id: str, session: AgentSession, turn: TurnState,
e: BaseException, stderr_tail: str) -> None:
"""The three terminal model_error rungs differ only by subkind, so they share one submitter."""
try:
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({
"kind": "model_error",
"subkind": subkind,
"flight": flight_recorder.build_envelope(session_id, "model_error", subkind, session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
"model": session.model,
"provider": session.provider,
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
"error_preview": redact_for_telemetry(str(e), limit=400),
"stderr_tail": redact_for_telemetry(stderr_tail),
})
except Exception:
logger.debug(f"submit_diagnostic {subkind} failed", exc_info=True)
@typechecked
async def handle_run_error(e: Exception, session: AgentSession, session_id: str, turn: TurnState, p_stderr_buffer: List[str]) -> None:
logger.exception(f"Agent {session_id} error: {e}")
@@ -77,6 +98,7 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
submit_diagnostic({
"kind": "context_overflow",
"where": "manager.run.handle_run_error",
"flight": flight_recorder.build_envelope(session_id, "context_overflow", "overflow", session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
"session_id": session_id,
"model": session.model,
"provider": session.provider,
@@ -109,6 +131,7 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
submit_diagnostic({
"kind": "cli_binary_missing",
"where": "manager.run.handle_run_error",
"flight": flight_recorder.build_envelope(session_id, "cli_binary_missing", "missing", session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
"session_id": session_id,
"model": session.model,
"error_preview": redact_for_telemetry(str(e), limit=400),
@@ -220,6 +243,18 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
reason = "anthropic_auth_invalid"
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
session.messages.append(error_msg)
try:
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({
"kind": "model_error",
"subkind": "auth",
"model": session.model,
"provider": session.provider,
"error_preview": redact_for_telemetry(str(e), limit=400),
"flight": flight_recorder.build_envelope(session_id, "model_error", reason, session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
})
except Exception:
logger.debug("submit_diagnostic auth failed", exc_info=True)
await ws_manager.send_to_session(session_id, "agent:auth_error", {
"session_id": session_id,
"reason": reason,
@@ -232,19 +267,17 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
})
elif is_unknown_model_error(e, extra_text=p_stderr_tail):
# Upstream rejected the model code itself (e.g. Codex 1211 on a ChatGPT plan that lacks our GPT ids). Track it; the friendly "add an API key / pick another model" card is rendered frontend-side.
try:
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({
"kind": "model_error",
"subkind": "unknown_model",
"model": session.model,
"provider": session.provider,
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
"error_preview": redact_for_telemetry(str(e), limit=400),
"stderr_tail": redact_for_telemetry(p_stderr_tail),
})
except Exception:
logger.debug("submit_diagnostic model_error failed", exc_info=True)
p_report_model_error("unknown_model", session_id, session, turn, e, p_stderr_tail)
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
elif is_router_unavailable_error(f"{e} {p_stderr_tail}"):
# Our own router is down. Naming it beats "unclassified": this is the one failure family
# where the fix is entirely on our side of the wire.
p_report_model_error("router_unavailable", session_id, session, turn, e, p_stderr_tail)
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -253,19 +286,7 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
})
else:
# Track unclassified agent failures too so we stop flying blind on them.
try:
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({
"kind": "model_error",
"subkind": "unclassified",
"model": session.model,
"provider": session.provider,
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
"error_preview": redact_for_telemetry(str(e), limit=400),
"stderr_tail": redact_for_telemetry(p_stderr_tail),
})
except Exception:
logger.debug("submit_diagnostic model_error failed", exc_info=True)
p_report_model_error("unclassified", session_id, session, turn, e, p_stderr_tail)
# The SDK's ProcessError masks the cause behind "Check stderr output for details"; append the scrubbed stderr tail so the card (and its analytics copy) names what actually broke instead of shipping a dead end.
p_card_text = f"Error: {str(e)}"
p_cause = redact_for_telemetry(p_stderr_tail, limit=400).strip()
@@ -181,9 +181,14 @@ def inject_thinking_options(options_kwargs: Dict, session: AgentSession, prompt:
reasoning_effort), with the short-prompt + gc/gemini-3 force-off overrides. Best-effort."""
try:
level = getattr(session, "thinking_level", "auto") or "auto"
# Trivially short prompts ("hi", "thanks") don't benefit from 5-30s of hidden reasoning.
# Trivially short prompts ("hi", "thanks") don't benefit from 5-30s of hidden reasoning, but
# this flip rides the BOOT fingerprint: a short first message drifted it and threw away the
# pre-warmed CLI, measured as a second spawn on 5 of 5 short-prompt sessions. A wasted 0.9s
# respawn costs more than the reasoning it saves, so the flip only applies once a session is
# already running on a live client (later turns), never on the first message.
p_prompt_len = len((prompt or "").strip())
if 0 < p_prompt_len < 50 and level != "off":
p_first_turn = not getattr(session, "sdk_session_id", None)
if 0 < p_prompt_len < 50 and level != "off" and not p_first_turn:
level = "off"
# gc/gemini-3* without Antigravity 400s every multi-step turn on thoughtSignature continuity.
if (
@@ -116,6 +116,8 @@ class SessionLifecycle(AgentManagerProtocol):
async def delete_session(self, session_id: str) -> None:
"""Permanently delete a session: remove from memory and JSON file.
Also stops browser-agent children first."""
from backend.apps.agents.core.flight_recorder import drop_session
drop_session(session_id)
children = [
s for s in self.sessions.values()
if s.parent_session_id == session_id and s.mode == "browser-agent"
@@ -0,0 +1,45 @@
"""The CLI retries provider 500s/429s by itself, up to 10 attempts with backoffs measured in tens
of seconds, and tells nobody. To the user the card just sits there; to us the turn looks clean.
This turns each of those `api_retry` system events into a breadcrumb, so a turn that eventually
dies carries "the provider 500'd four times first" in its envelope instead of an unexplained
timeout. Counting it as a RECOVERED near-miss happens later, at turn end, because a retry that is
still in flight has not recovered anything yet."""
from typing import Optional
from typeguard import typechecked
from backend.apps.agents.core import flight_recorder
from backend.apps.agents.manager.streaming.state import TurnState
@typechecked
def note_provider_retry(session_id: str, raw: object, turn: TurnState) -> None:
"""Record one CLI-internal provider retry. Never raises; diagnostics must not break a turn."""
try:
data = raw.get("data", {}) if isinstance(raw, dict) else {}
if not isinstance(data, dict):
data = {}
turn.provider_retries += 1
delay_ms = data.get("retry_delay_ms")
turn.provider_retry_wait_ms += int(delay_ms) if isinstance(delay_ms, int) else 0
flight_recorder.crumb(
session_id,
"provider-retry",
status=data.get("error_status"),
error=str(data.get("error", ""))[:40],
attempt=data.get("attempt"),
delay_ms=delay_ms,
)
except Exception:
pass
@typechecked
def settle_provider_retries(session_id: str, turn: TurnState, model: Optional[str], sessions: Optional[dict] = None) -> None:
"""Called when a turn finishes cleanly: any retries it survived were a silent save, so they get
a denominator in the near-miss ledger."""
if turn.provider_retries <= 0:
return
flight_recorder.record_recovery(session_id, "provider-retry", model, turn.provider_retries, sessions)
@@ -56,6 +56,9 @@ class TurnState(BaseModel):
baseline_captured: bool = False
# CLI compact_boundary events seen this turn; one plus a ProcessError = the autocompact-thrash death the context-pressure valve retries.
compact_boundaries: int = 0
# Provider 500s/429s the CLI retried on its own; the user sees only a long silence, so these are counted rather than lost.
provider_retries: int = 0
provider_retry_wait_ms: int = 0
# Mid-turn context breaker: fires once per turn, and only after a below-trigger reading (a turn that STARTS over the trigger must run, or a failed shrink would break-loop forever).
context_break_fired: bool = False
saw_input_below_trigger: bool = False
+71 -8
View File
@@ -12,6 +12,7 @@ ScheduleWorkflow for exact, user-specified live schedules.
import json
import sys
import os
import time
import uuid
import urllib.request
import urllib.error
@@ -212,13 +213,14 @@ TOOLS = [
{
"name": "TestWorkflow",
"description": (
"Spawn a sibling Test Agent that runs the workflow end-to-end "
"(the current draft if one is being edited, else the live steps) "
"so the user can watch it work. Use after editing a step to "
"verify the change. The Test Agent renders as a sibling card on "
"the dashboard with a 'Testing' arrow chip linking back to this "
"workflow. After it finishes, call ReadTestTranscript to see what "
"it did."
"Run the workflow end-to-end (the current draft if one is being "
"edited, else the live steps) and WAIT for the result, which is "
"returned to you in this same turn. Use after editing a step to "
"verify the change, then keep going: fix what the transcript "
"shows and test again, without stopping to ask the user. The Test "
"Agent renders as a sibling card on the dashboard with a 'Testing' "
"arrow chip so they can watch. Only if it runs unusually long does "
"this return early, telling you to call ReadTestTranscript."
),
"inputSchema": {
"type": "object",
@@ -438,6 +440,19 @@ def handle_run_now(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
# A human clicking Run Now on a paused workflow can see it is paused and chose anyway, so the
# route ignores `enabled` on purpose. An agent reaching the same route is NOT the same act: the
# user never asked, and a workflow they deliberately switched off starting itself is the field
# report ("a workflow that had been toggled off just started running again").
info = _call("GET", f"/{wid}")
if "_error" not in info:
sched = info.get("schedule") or {}
if isinstance(sched, dict) and sched.get("enabled") is False:
title = info.get("title") or wid
return _err(
f"'{title}' is paused, so I did not run it. Tell the user it is switched off and ask "
"them to turn it back on (or to confirm they want a one-off run) before trying again."
)
r = _call("POST", f"/{wid}/run")
if "_error" in r:
return _err(r["_error"])
@@ -529,6 +544,18 @@ def handle_delete_step(args: dict) -> dict:
return _ok(f"Step {idx + 1} deleted ({len(steps)} remaining).")
# How long a synchronous test may hold the turn. Long enough for a real multi-step workflow, short
# enough that a wedged test returns an honest "still running" instead of hanging the conversation.
# Wait on PROGRESS, not on a clock. A fixed budget answers the wrong question: a run doing real work
# should never be cut off (the first budget was 240s and a live digest took 243, missing by three
# seconds), while a run that is genuinely wedged should not be waited on for ten minutes either. So
# the deadline resets every time the transcript grows, and only silence ends the wait.
TEST_IDLE_S = 180.0
# Absolute backstop for the pathological case where a run reports progress forever.
TEST_MAX_S = 3600.0
TEST_POLL_S = 3
def handle_test_workflow(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
@@ -537,7 +564,43 @@ def handle_test_workflow(args: dict) -> dict:
if "_error" in r:
return _err(r["_error"])
sid = r.get("session_id", "")
return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip. Call ReadTestTranscript once it finishes to see what it did.")
# Blocking on purpose. This used to return the moment the Test Agent spawned and tell the model
# to "call ReadTestTranscript once it finishes", but a model has no way to know when that is, so
# it ended its turn and the HUMAN had to keep re-pinging it. A test whose result the caller
# cannot observe is not a tool, it is homework for the user.
started = time.time()
last_progress_at = started
last_len = -1
last_status = "running"
partial = ""
while True:
now = time.time()
if now - last_progress_at >= TEST_IDLE_S or now - started >= TEST_MAX_S:
break
time.sleep(TEST_POLL_S)
t = _call("GET", f"/{wid}/test-transcript")
if "_error" in t:
continue
last_status = t.get("status") or "running"
transcript = t.get("transcript") or ""
# Any growth in the transcript is the run telling us it is alive, so the clock starts over.
if len(transcript) != last_len:
last_len = len(transcript)
last_progress_at = time.time()
partial = transcript
if last_status in ("running", "none"):
continue
return _ok(f"Test finished (status: {last_status}). Transcript:\n\n{transcript or '(empty transcript)'}")
# Hand back whatever the run has actually produced. Returning only "call ReadTestTranscript" made
# the model guess when to poll, which is the same dead end as not waiting at all; a partial
# transcript is something it can reason about right now.
waited = int(time.time() - started)
head = (
f"Test Agent (session {sid[:8]}) went quiet: no new output for {int(TEST_IDLE_S)}s "
f"(status: {last_status}, waited {waited}s total). Everything it produced follows; "
"call ReadTestTranscript if it lands later."
)
return _ok(f"{head}\n\n{partial.strip()}" if partial.strip() else head)
def handle_read_test_transcript(args: dict) -> dict:
+16
View File
@@ -170,6 +170,22 @@ async def get_help_knowledge() -> HelpKnowledgeResponse:
return build_knowledge_response()
@help_app.router.get("/whats-new")
def whats_new() -> dict:
"""The release story for the in-app What's New card. Same words the Help agent and the GitHub
body get, so a user never reads two different accounts of the same release."""
from backend.apps.help.changelog import as_markdown, latest_release, release_notes
from backend.apps.service.version import APP_VERSION
note = release_notes(APP_VERSION) or latest_release()
return {
"version": note.version,
"headline": note.headline,
"highlights": note.highlights,
"fixes": note.fixes,
"markdown": as_markdown(note),
}
@help_app.router.post("/bundle")
@typechecked
async def build_bundle(body: BundleRequest) -> dict:
+95
View File
@@ -0,0 +1,95 @@
"""The release story, in one place, for three surfaces: the in-app What's New card, the GitHub
release body, and the Help agent's context. One source means the agent can never answer from a
stale picture of the app, and a release can never ship with no story."""
from typing import Dict, List
from pydantic import BaseModel, ConfigDict
from typeguard import typechecked
class ReleaseNote(BaseModel):
model_config = ConfigDict(validate_assignment=True)
version: str
headline: str
# User-facing lines only: what changed for the person using the app, not the diff.
highlights: List[str]
fixes: List[str]
P_RELEASES: List[ReleaseNote] = [
# Only lines that are true of the built app belong here. This one file feeds the in-app card, the
# GitHub body AND the Help agent's context, so a line written for a planned feature becomes the
# agent confidently describing something that does not exist.
ReleaseNote(
version="1.7.5",
headline="Off means off, and the canvas stops tearing.",
highlights=[
"Deleting a scheduled workflow makes it stay deleted. One that was mid-run could previously save itself back and keep firing.",
"Switching a workflow off now stops everything: no queued catch-up runs, nothing waiting on the review card.",
"Scrolling inside a panel, list, or window stays in that panel instead of dragging the canvas with it.",
"Opening a busy dashboard no longer locks the window while its cards wake up.",
],
fixes=[
"The canvas background no longer tears into a hard-edged rectangle when lots of browsers are open.",
"Editing a workflow step can no longer hang the editor when the naming service is slow.",
"A crashed session no longer leaves a key watcher running, which made dictation start and immediately stop.",
"The first message after opening a chat reuses the warmed-up connection, so it answers sooner.",
"A provider hiccup that fixes itself no longer shows a scary reconnect card.",
],
),
ReleaseNote(
version="1.7.4",
headline="Chats survive a hiccup instead of stopping.",
highlights=[
"A dropped local connection retries and resumes the same answer instead of failing the message.",
"The spawn composer steps aside when a window is open.",
],
fixes=[
"App previews reconnect on their own after a backend restart.",
"Dictation cue sounds default to a level you can actually hear.",
],
),
]
@typechecked
def release_notes(version: str) -> ReleaseNote | None:
for note in P_RELEASES:
if note.version == version:
return note
return None
@typechecked
def latest_release() -> ReleaseNote:
return P_RELEASES[0]
@typechecked
def as_markdown(note: ReleaseNote) -> str:
"""The GitHub release body; identical words to the in-app card, so nobody reads two stories."""
lines = [f"## {note.version}: {note.headline}", ""]
if note.highlights:
lines.append("### New")
lines += [f"- {h}" for h in note.highlights]
lines.append("")
if note.fixes:
lines.append("### Fixed")
lines += [f"- {f}" for f in note.fixes]
return "\n".join(lines).strip()
@typechecked
def help_context_block(app_version: str) -> str:
"""What the Help agent must know about what just changed, so "what's new" is never stale."""
note = release_notes(app_version) or latest_release()
body = [f"Version {note.version}: {note.headline}"]
body += [f"- new: {h}" for h in note.highlights]
body += [f"- fixed: {f}" for f in note.fixes]
return "\n".join(body)
@typechecked
def all_versions() -> Dict[str, str]:
return {n.version: n.headline for n in P_RELEASES}
+6
View File
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ConfigDict
from typeguard import typechecked
from backend.apps.help.help_topics import HELP_TOPICS, HelpTopic
from backend.apps.help.changelog import help_context_block
from backend.apps.help.known_issues import KNOWN_ISSUES, HelpKnownIssue
from backend.apps.help.prompt_rules import GROUNDING_RULES, ROLE
@@ -158,6 +159,11 @@ def build_system_prompt(shortcuts: List[HelpShortcut], app_version: str) -> str:
shortcut_lines,
"</shortcuts>",
"",
"<whats_new>",
"What actually changed in this build. Answer \"what's new\" from THIS, never from memory.",
help_context_block(app_version),
"</whats_new>",
"",
"<known_issues>",
"The complete list of issues shipped with this build. You cannot see live bug reports.",
p_issues_block(),
+37 -8
View File
@@ -11,6 +11,7 @@ from backend.apps.agents.core.aux_llm import aux_max_tokens_for
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.predict_followups import conversation_tail
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
from backend.apps.memory import store
from backend.apps.memory.store import add_fact
logger = logging.getLogger(__name__)
@@ -55,17 +56,45 @@ async def distill_session_memory(session: AgentSession) -> List[str]:
p_last_distilled[session.id] = p_user_message_count(session)
aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
client = get_anthropic_client_for_model(global_settings, aux_model)
# Deliberately harsh. The permissive version filled a real user's memory with task summaries
# ("works on an app with Slack OAuth, fullscreen modes, pinch-to-zoom") and topic echoes
# ("interested in AI agents") that were just the last thing they happened to ask about. Saying
# NOTHING costs nothing; a junk fact is paid for on every turn of every chat, forever.
system_prompt = (
"You extract durable facts about the USER from a conversation with their AI agent: "
"who they are, what they work on, standing preferences, constraints they stated. "
"Facts must be about the user themselves and still true next month; never task details, "
"never one-off requests, never anything the ASSISTANT said, never secrets, keys, or "
"passwords. Write each fact self-contained in third person, under 200 characters "
'(e.g. "Prefers concise answers with real measured numbers").\n\n'
"You maintain a small, permanent profile of the USER as a PERSON. It is read on every "
"turn of every future conversation, so a wrong or trivial entry is expensive and a "
"missing one costs nothing. Default to NOTHING.\n\n"
"SAVE only a fact that passes ALL FIVE:\n"
"1. It is about the person: who they are, their role, their tools and languages, how "
"they want to be worked with, a constraint or standing rule they set.\n"
"2. They stated or clearly demonstrated it about THEMSELVES. Never infer a trait from "
"the fact that they asked about a topic once.\n"
"3. It is still true in six months, whatever they happen to be working on then.\n"
"4. It changes how an assistant should behave in an UNRELATED future conversation.\n"
"5. It is not already obvious from whatever they are working on at the time.\n\n"
"NEVER save: what they are building or its features; anything about the current task, "
"bug, file, or request; a topic they asked about; anything the ASSISTANT said, did, or "
"suggested; anything time-bound; secrets, keys, tokens, or passwords.\n\n"
'GOOD: "Prefers answers with real measured numbers over estimates" - '
'"Works solo and ships releases himself" - "Writes TypeScript and Python".\n'
'BAD: "Works on an app with Slack OAuth and pinch-to-zoom" (that is the project, not '
'the person) - "Interested in AI agents and LLM tooling" (that is just the topic they '
'raised) - "Wants a weekly news digest" (that is a request they made).\n\n'
"Write each fact self-contained, third person, under 200 characters.\n"
f"Return at most {MAX_FACTS_PER_DISTILL} facts, one per line, no numbering, no quotes. "
"If the conversation reveals nothing durable, return the single word NOTHING."
"Most conversations should yield none: if in any doubt, return the single word NOTHING."
)
user_turn = "Conversation:\n<transcript>\n" + tail + "\n</transcript>\n\nExtract the facts."
# Show it what is already known. Without this the model re-derives facts it recorded weeks
# ago, phrased differently every time, and the store's token-overlap guard cannot catch a
# paraphrase: the four duplicate pairs found in a real memory scored 0.11 to 0.33 against a
# 0.60 threshold. The generator is semantic, so the dedupe has to be too.
known = "\n".join(f"- {f.text}" for f in store.list_facts())
already = (
"Facts already stored (do NOT repeat these, in any wording):\n" + known + "\n\n"
"Return a fact ONLY if it is genuinely new, or is strictly more specific than one above "
"(in which case return the sharper version and it will replace the old one).\n\n"
) if known else ""
user_turn = already + "Conversation:\n<transcript>\n" + tail + "\n</transcript>\n\nExtract the facts."
chunks: List[str] = []
async with client.messages.stream(
model=aux_model,
+16
View File
@@ -351,9 +351,12 @@ def p_report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> N
try:
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
from backend.apps.service.client import submit_diagnostic
from backend.apps.agents.core.flight_recorder import journey_auth_context
payload: dict[str, Any] = {
"kind": "9router_start_failed",
"reason": reason,
# No session owns this failure, but WHO it happened to still decides the fix.
"journey": journey_auth_context(),
"packaged": os.environ.get("OPENSWARM_PACKAGED") == "1",
**fields,
}
@@ -463,6 +466,19 @@ async def death_watch(proc_handle: "subprocess.Popen[Any]") -> None:
logger.warning("9Router died 3x in 60s; leaving revival to the backed-off watchdog")
return
logger.warning("9Router process died; instant revive")
# The revive IS a safety net firing; the near-miss ledger counts it so router flap rates are queryable.
try:
from backend.apps.service.client import submit_diagnostic
from backend.apps.agents.core.flight_recorder import journey_auth_context
# scope says WHY there is no session or lane here: the watchdog outlives any one turn.
submit_diagnostic({
"kind": "recovered",
"subkind": "router-revive",
"scope": "watchdog",
"journey": journey_auth_context(),
})
except Exception:
pass
p_is_running_last_ok = 0.0
await ensure_running()
+16
View File
@@ -274,6 +274,22 @@ and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then run
- Install your own venv or `pip install` manually.
- Edit `backend/run.sh` or the SubApp framework.
**Persist anything the user comes back to. Your process is disposable.**
OpenSwarm freezes this app's process when its card closes and fully kills
it after ~15 minutes idle, on quit, and on crash. A module-level list or
dict is therefore data loss on a timer. The scaffold ships a durable
store; use it (or your own files under `backend/data/`):
```python
from backend.apps.store.store import load_store, save_store
data = load_store() # {} on first run, never raises
data["items"] = [*data.get("items", []), new_item]
save_store(data) # atomic write; a kill mid-write keeps the old data
```
Holding state only in memory is a bug, not a style choice.
Adding a new endpoint is just adding a new SubApp:
```python
+29
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import os
import logging
@@ -63,9 +64,37 @@ async def outputs_lifespan():
recover_orphaned_apps()
except Exception:
logger.exception("orphaned-app recovery failed; apps stay hidden but nothing else breaks")
# Ghosts from a session that died badly keep running forever: stop_all only fires on a clean
# shutdown, and the port-collision path routes AROUND a squatter instead of killing it. Measured
# on a dev box: runtimes still alive after 2 days 19 hours. Boot is the one safe moment, since we
# have not spawned any of our own yet.
try:
from backend.apps.outputs.reap_ghost_runtimes import reap_ghost_runtimes
ghosts = reap_ghost_runtimes()
if ghosts:
logger.warning("outputs lifespan: reaped %d ghost runtime(s) from a previous session", ghosts)
except Exception:
logger.exception("ghost-runtime reap failed; stale processes stay but boot continues")
# The boot reap catches ghosts from a PREVIOUS session, but a session can live for days: this
# sweep keeps catching them while we run (another backend dying leaves orphans mid-session) and
# retires idle runtimes past their TTL, so "quit but still around" has a bounded lifetime.
async def p_periodic_sweep() -> None:
from backend.apps.outputs.reap_ghost_runtimes import reap_ghost_runtimes
from backend.apps.outputs.runtime import manager as p_sweep_manager
while True:
await asyncio.sleep(600)
try:
ghosts = await asyncio.to_thread(reap_ghost_runtimes)
stale = await p_sweep_manager.reap_stale_idle()
if ghosts or stale:
logger.info("periodic sweep: %d ghost(s) reaped, %d stale idle runtime(s) stopped", ghosts, stale)
except Exception:
logger.exception("periodic sweep failed; will retry next interval")
p_sweep_task = asyncio.create_task(p_periodic_sweep())
try:
yield
finally:
p_sweep_task.cancel()
# Reap every per-app subprocess. Without this each `bash run.sh` (and its vite/uvicorn descendants) reparents to PID 1 when the main backend dies, leaving ghost listeners on the .env-pinned ports that block the next OpenSwarm launch's reload preview.
try:
from backend.apps.outputs.runtime import manager as runtime_manager
+193
View File
@@ -0,0 +1,193 @@
"""Kill app-runtime processes left behind by a previous OpenSwarm that died badly.
`stop_all()` reaps runtimes on a CLEAN shutdown. A crash, a SIGKILL, or a force-quit skips it, and
every `bash run.sh` plus its vite/uvicorn descendants reparents to PID 1 and keeps running: measured
on a dev machine, ghosts had been alive for **2 days 19 hours**, still holding their ports. The only
existing handling reallocates around a ghost that squats a port, so the ghost never dies at all and
they accumulate across sessions.
This runs at startup, before any runtime is spawned, which is the one moment when a workspace process
cannot legitimately belong to us: we have not started any yet.
"""
import logging
import os
import signal
import subprocess
import time
from typing import List
from typeguard import typechecked
from backend.apps.outputs.runtime_proc import kill_descendant_tree
from backend.config.paths import OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
logger = logging.getLogger(__name__)
# Grace between TERM and KILL. Long enough for a run.sh EXIT trap to clean up its ports, short enough
# that boot does not visibly stall on it.
REAP_GRACE_SECONDS = float(os.environ.get("OSW_REAP_GRACE_SECONDS", "1.5"))
@typechecked
def p_live_backend_pids() -> set:
"""PIDs of every running backend. A workspace process descended from one of these is ALIVE and
owned, not a ghost; a first draft of this reaper matched on the workspace path alone and would
have killed 14 working app runtimes on a machine where the owning backend was up."""
try:
out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=8)
except Exception:
return set()
pids = set()
for line in (out.stdout or "").splitlines():
if "uvicorn" not in line or "backend.main" not in line:
continue
head = line.strip().split(None, 1)
if head and head[0].isdigit():
pids.add(int(head[0]))
return pids
@typechecked
def p_ppid_map() -> dict:
try:
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=8)
except Exception:
return {}
m = {}
for line in (out.stdout or "").splitlines():
parts = line.split()
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
m[int(parts[0])] = int(parts[1])
return m
@typechecked
def p_cwd_map(needle: str) -> dict:
"""pid -> cwd, for processes whose working directory sits under the workspace.
An app's backend is spawned as `python -u backend.py` with `cwd=<workspace>`, so the workspace
path appears NOWHERE in its argv: an argv-only scan is structurally blind to exactly the ghost
we most want dead. lsof is the only way to read another process's cwd on macOS. Best-effort by
design, since a machine that restricts lsof must still boot.
"""
try:
out = subprocess.run(
["lsof", "-a", "-d", "cwd", "-Fn"], capture_output=True, text=True, timeout=15
)
except Exception:
return {}
m = {}
pid = None
for raw in (out.stdout or "").splitlines():
if not raw:
continue
tag, val = raw[0], raw[1:]
if tag == "p" and val.isdigit():
pid = int(val)
elif tag == "n" and pid is not None and val.casefold().startswith(needle):
m[pid] = val
return m
@typechecked
def find_ghost_runtime_pids() -> List[int]:
"""PIDs of workspace processes that NO live backend owns.
Matched on the absolute workspace path (in argv OR as the process's working directory), so an
unrelated `npm run dev` elsewhere is never touched, then filtered by walking each candidate's
ancestry: if a live backend is anywhere above it, it is someone's working app and is left alone.
"""
# Case-FOLDED needle. macOS's default filesystem is case-insensitive, so a process may report
# `.../openswarm/...` while our resolved path is `.../OpenSwarm/...`: the same folder, but a
# case-sensitive `in` check misses it and the ghost survives (found live on a packaged smoke).
needle = os.path.abspath(WORKSPACE_DIR).casefold()
try:
out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=8)
except Exception:
return []
mine = os.getpid()
owners = p_live_backend_pids()
parents = p_ppid_map()
# WE are a backend, so a scan that finds no live backend has failed, not found ghosts: an empty
# owner set turns every working app into a "ghost" and the sweep would kill them all mid-use.
# Boot relied on running before anything spawned; the 10-minute sweep gets no such alibi.
if not owners or not parents:
return []
by_cwd = p_cwd_map(needle)
candidates = dict.fromkeys(by_cwd)
for line in (out.stdout or "").splitlines():
line = line.strip()
if needle not in line.casefold():
continue
head = line.split(None, 1)
if head and head[0].isdigit():
candidates[int(head[0])] = None
ghosts: List[int] = []
for pid in candidates:
if pid == mine:
continue
cur, owned, broken = pid, False, False
for _ in range(24):
if cur in owners or cur == mine:
owned = True
break
if cur <= 1:
break
nxt = parents.get(cur)
if nxt is None:
# The pid list and the ppid map are two separate ps snapshots; a process spawned
# between them has no entry here. Indeterminate is NOT ghost: skip, never kill.
broken = True
break
cur = nxt
if not owned and not broken:
ghosts.append(pid)
return ghosts
@typechecked
def reap_ghost_runtimes() -> int:
"""Reap them, leaves-first. Returns how many top-level processes were signalled.
Fire-and-forget by design: a machine where `ps` is restricted or a PID that vanishes between the
scan and the kill must never stop the backend from booting.
"""
pids = find_ghost_runtime_pids()
if not pids:
return 0
logger.warning(
"reaping %d ghost app-runtime process(es) left by a previous session: %s",
len(pids), pids[:12],
)
killed = 0
for pid in pids:
try:
# THAW FIRST. Idle app runtimes are frozen with SIGSTOP, and a stopped process never
# handles SIGTERM: it just queues it and stays alive forever. Measured live, a frozen
# ghost that had survived every reap for 2 days 21 hours.
kill_descendant_tree(pid, "CONT")
os.kill(pid, signal.SIGCONT)
except (ProcessLookupError, PermissionError, OSError):
pass
try:
kill_descendant_tree(pid, "TERM")
os.kill(pid, signal.SIGTERM)
killed += 1
except (ProcessLookupError, PermissionError, OSError):
continue
# A ghost's run.sh traps EXIT but not TERM, so give the tree a moment, then take out whatever
# ignored us. A ghost has no work worth protecting, so escalation is always the right call.
time.sleep(REAP_GRACE_SECONDS)
for pid in pids:
try:
os.kill(pid, 0)
except OSError:
continue
try:
kill_descendant_tree(pid, "KILL")
os.kill(pid, signal.SIGKILL)
logger.warning("ghost %d ignored TERM; escalated to KILL", pid)
except (ProcessLookupError, PermissionError, OSError):
continue
return killed
+29 -1
View File
@@ -5,6 +5,7 @@ import logging
import os
import shutil
import sys
import time
from collections import deque, OrderedDict
from dataclasses import dataclass
from typing import Callable, Optional
@@ -30,6 +31,7 @@ from backend.apps.outputs.runtime_proc import (
FRONTEND_BIND_POLL_INTERVAL,
FRONTEND_BIND_TIMEOUT_SECONDS,
LOG_BUFFER_LINES,
IDLE_RUNTIME_TTL_S,
MAX_IDLE_RUNTIMES,
RECENT_ERRORS_MAX,
TERMINATE_GRACE_SECONDS,
@@ -582,6 +584,8 @@ class AppRuntimeManager:
self.p_attached: dict[str, int] = {}
# workspace_id → AppRuntime with no subscribers but still alive. OrderedDict gives O(1) move_to_end + popitem(last=False) for LRU semantics.
self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict()
# workspace key -> monotonic seconds when it was parked; drives the idle TTL sweep.
self.p_idle_since: dict[str, float] = {}
self.p_lock = asyncio.Lock()
# Public: tests cancel it during teardown.
self.restart_watch_task: Optional[asyncio.Task] = None
@@ -631,6 +635,7 @@ class AppRuntimeManager:
if rt is None:
# Maybe the runtime is sitting idle in the LRU; revive it without paying the spawn cost again.
idle_rt = self.idle_lru.pop(key, None)
self.p_idle_since.pop(key, None)
if idle_rt is not None and idle_rt.running:
rt = idle_rt
rt.workspace_path = workspace_path
@@ -678,10 +683,12 @@ class AppRuntimeManager:
else:
self.idle_lru[key] = rt
self.idle_lru.move_to_end(key)
self.p_idle_since[key] = time.monotonic()
suspend_process_tree(rt.process)
rt.p_suspended = True
while len(self.idle_lru) > MAX_IDLE_RUNTIMES:
_, old_rt = self.idle_lru.popitem(last=False)
old_key, old_rt = self.idle_lru.popitem(last=False)
self.p_idle_since.pop(old_key, None)
# Reaping a stopped process: SIGCONT first so the SIGTERM in stop() can be delivered cleanly (a SIGSTOP'd process can't run its own shutdown).
resume_process_tree(old_rt.process)
to_reap.append(old_rt)
@@ -696,6 +703,27 @@ class AppRuntimeManager:
if to_idle is not None:
logger.debug("workspace %s idled (LRU size now %d)", key, len(self.idle_lru))
async def reap_stale_idle(self, ttl_s: float = IDLE_RUNTIME_TTL_S) -> int:
"""Fully stop idle runtimes parked longer than ttl_s. Frozen is 0% CPU but never 0 cost:
each one holds its memory and its port for as long as it sits there, which is the "app is
quit but something of it is still around" complaint. Returns how many were stopped."""
now = time.monotonic()
stale: list[AppRuntime] = []
async with self.p_lock:
for key in [k for k, t in self.p_idle_since.items() if now - t >= ttl_s]:
rt = self.idle_lru.pop(key, None)
self.p_idle_since.pop(key, None)
if rt is None:
continue
resume_process_tree(rt.process)
stale.append(rt)
for rt in stale:
try:
await rt.stop()
except Exception:
logger.exception("failed to stop stale idle runtime")
return len(stale)
def get(self, workspace_id: str, instance: int = 1) -> Optional[AppRuntime]:
key = runtime_key(workspace_id, instance)
# Active subscribers see the live runtime; idle-pool members are also accessible so a status probe between detach and the next attach still works.
+8 -1
View File
@@ -25,7 +25,14 @@ FRONTEND_BIND_POLL_INTERVAL = 0.08
LOG_BUFFER_LINES = 2000
# Idle runtimes kept in LRU; trades memory for instant switch-back, beyond 1 because typical users ping-pong 2-3 apps.
MAX_IDLE_RUNTIMES = 3
# Raised from 3 once the idle TTL landed: the cap used to be the ONLY bound on parked memory, so it
# had to be tight; now anything unattended dies at 15 minutes regardless, so the pool can afford to
# make instant-reopen cover a realistic handful of apps instead of the last three touched.
MAX_IDLE_RUNTIMES = 6
# How long a detached runtime may sit frozen in the idle pool before it is fully stopped. Frozen
# costs 0% CPU but keeps holding memory and its port; past this nobody is coming back for it soon
# and a fresh spawn on the next open is a fair trade for not squatting RAM indefinitely.
IDLE_RUNTIME_TTL_S = 15 * 60.0
# Cap on recent error lines the agent gets; 50 is enough for babel error + stack + a few warnings.
RECENT_ERRORS_MAX = 50
@@ -0,0 +1,52 @@
"""Disk-backed app state. Use this instead of module-level variables for anything worth keeping.
The app's process is DISPOSABLE: OpenSwarm freezes it when its card closes, kills it after ~15
minutes idle, on quit, and on crash. A module-level list or dict therefore silently loses the
user's data on a schedule you don't control. This store survives all of that: one JSON file under
backend/data/, written atomically so a kill mid-write can never corrupt it.
from backend.apps.store.store import load_store, save_store
items = load_store().get("items", [])
items.append(new_item)
save_store({**load_store(), "items": items})
"""
import json
import os
import tempfile
from typing import Any, Dict
from typeguard import typechecked
P_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_DIR = os.path.join(P_BACKEND_DIR, "data")
STORE_PATH = os.path.join(DATA_DIR, "store.json")
@typechecked
def load_store() -> Dict[str, Any]:
"""The whole store as a dict; empty on first run or an unreadable file, never an exception."""
try:
with open(STORE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
@typechecked
def save_store(data: Dict[str, Any]) -> None:
"""Replace the store atomically: temp file then rename, so a kill mid-write leaves the old data."""
os.makedirs(DATA_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=DATA_DIR, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
os.replace(tmp, STORE_PATH)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
raise
+12
View File
@@ -399,6 +399,18 @@ def submit_diagnostic(diagnostic: dict) -> None:
diagnostic["recent_log"] = snapshot()
except Exception:
pass
# The local flight file: every diagnostic also lands in a rotating NDJSON when the sink is set,
# so support is "send me one file" and forced-failure tests can verify envelopes offline.
sink = os.environ.get("OPENSWARM_DIAG_SINK")
if sink:
try:
import json as p_json
if os.path.exists(sink) and os.path.getsize(sink) > 1_000_000:
os.replace(sink, sink + ".0")
with open(sink, "a", encoding="utf-8") as f:
f.write(p_json.dumps({"t": time.time(), **diagnostic}, default=str) + "\n")
except OSError:
pass
submit("diagnostic", {"diagnostic": diagnostic})
+25 -9
View File
@@ -304,8 +304,13 @@ async def usage_summary(window: str = "30d"):
from backend.apps.agents.agent_manager import agent_manager
sessions = p_load_all_sessions()
# A live session is usually already on disk, so appending it blind counted the same chat twice.
seen_ids = {s.get("id") for s in sessions if s.get("id")}
for s in agent_manager.get_all_sessions():
sessions.append(s.model_dump(mode="json"))
live = s.model_dump(mode="json")
if live.get("id") and live["id"] in seen_ids:
sessions = [d for d in sessions if d.get("id") != live["id"]]
sessions.append(live)
days = P_WINDOW_DAYS.get(window, 30)
if days:
@@ -332,6 +337,9 @@ async def usage_summary(window: str = "30d"):
provider_counts: Counter = Counter()
tool_counts: Counter = Counter()
status_counts: Counter = Counter()
day_counts: Counter = Counter()
hour_counts: Counter = Counter()
longest_run_seconds = 0.0
excluded_automation = 0
kept = []
@@ -359,6 +367,13 @@ async def usage_summary(window: str = "30d"):
model_counts[p_friendly_model(s.get("model", "unknown"))] += 1
provider_counts[s.get("provider", "anthropic")] += 1
status_counts[s.get("status", "unknown")] += 1
created = s.get("created_at") or ""
if len(created) >= 13:
day_counts[created[:10]] += 1
try:
hour_counts[int(created[11:13])] += 1
except ValueError:
pass
# Tool calls: tool_latencies carries authoritative per-tool counts; older sessions only have the sparse tool_call messages. Per session take whichever source recorded more so we never undercount what's on record (and so the total never drops below the old message-only count).
lat_counts: Counter = Counter()
@@ -376,18 +391,14 @@ async def usage_summary(window: str = "30d"):
total_tool_calls += sum(chosen.values())
tool_counts.update(chosen)
# Run time: real agent-active time when tracked, else session wall-clock as a rough proxy.
# Measured agent-active time only. The old wall-clock fallback billed a card you left open all
# day as work: one "Open browser" session claimed 11 hours, and 45% of the headline came from
# 4% of sessions that way.
run_s = (s.get("agent_active_ms") or 0) / 1000.0
if run_s <= 0:
created, closed = s.get("created_at"), s.get("closed_at")
if created and closed:
try:
run_s = (datetime.fromisoformat(closed[:19]) - datetime.fromisoformat(created[:19])).total_seconds()
except Exception:
run_s = 0
if run_s > 0:
total_run_seconds += run_s
timed_sessions += 1
longest_run_seconds = max(longest_run_seconds, run_s)
avg_duration = total_run_seconds / timed_sessions if timed_sessions > 0 else 0
completed = status_counts.get("completed", 0)
@@ -437,7 +448,12 @@ async def usage_summary(window: str = "30d"):
"total_messages": total_messages,
"total_tool_calls": total_tool_calls,
"total_run_seconds": round(total_run_seconds, 1),
"timed_sessions": timed_sessions,
"longest_run_seconds": round(longest_run_seconds, 1),
"avg_duration_seconds": round(avg_duration, 1),
"status_breakdown": dict(status_counts),
"daily_activity": [{"day": d, "chats": n} for d, n in sorted(day_counts.items())],
"hourly_activity": [hour_counts.get(h, 0) for h in range(24)],
"avg_cost_per_session": round(avg_cost, 4),
"completion_rate": round(completion_rate, 3),
"models_used": dict(model_counts.most_common(10)),
+4 -2
View File
@@ -33,8 +33,10 @@ DEFAULT_SYSTEM_PROMPT = (
"4. **Unsure which server.** `MCPList` for a cheap survey, or "
'`MCPSearch("<what you need>")` to rank servers by relevance. Do this before '
"MCPActivate, never via ToolSearch.\n"
"5. **No tool fits.** WebSearch / WebFetch for information. BrowserAgent only for "
"visual interaction, form filling, or sites with no API path.\n\n"
"5. **Reading the web.** WebSearch / WebFetch first, always: they are far faster than "
"driving a browser and they cover ordinary pages. Escalate to BrowserAgent only once "
"they have actually come back thin or blocked (login wall, paywall, JS-only page), or "
"when the task needs visual interaction or form filling.\n\n"
"### Choosing among similar names\n"
"A matching name is a hypothesis, not an answer. Before calling, read the description "
"and the required parameters, and confirm three things: it performs the action you "
+11
View File
@@ -50,8 +50,19 @@ P_LEGACY_DEFAULT_SYSTEM_PROMPT = (
"If you genuinely need clarification on something ambiguous, use the "
"AskUserQuestion tool. Never ask questions inline in plain text.\n"
)
# The 820cf578-era revision, which differs from the current default only in ladder step 5 (it framed
# the web tools as "No tool fits" so agents reached for the browser first). Derived, not duplicated.
P_LEGACY_LADDER_V1 = DEFAULT_SYSTEM_PROMPT.replace(
"5. **Reading the web.** WebSearch / WebFetch first, always: they are far faster than "
"driving a browser and they cover ordinary pages. Escalate to BrowserAgent only once "
"they have actually come back thin or blocked (login wall, paywall, JS-only page), or "
"when the task needs visual interaction or form filling.\n\n",
"5. **No tool fits.** WebSearch / WebFetch for information. BrowserAgent only for "
"visual interaction, form filling, or sites with no API path.\n\n",
)
P_LEGACY_DEFAULT_SYSTEM_PROMPTS = (
P_LEGACY_DEFAULT_SYSTEM_PROMPT,
P_LEGACY_LADDER_V1,
P_LEGACY_DEFAULT_SYSTEM_PROMPT.replace(
"1. Connected MCP tools; fastest and most reliable. To reach an integration you "
"don't already see, use MCPSearch then MCPActivate; never ToolSearch for it.\n",
@@ -0,0 +1,65 @@
"""Turn the install commands people already paste from READMEs into a skill id we can install.
The ecosystem's grammar is `npx skills add <name>`, and every neighbouring form (npm/pnpm/bunx,
`install` instead of `add`, a bare `@scope/name`, a skills.sh URL, or just the name) means the same
thing to the person pasting it. Accepting only our own button was the friction."""
import re
from typing import Optional
from typeguard import typechecked
# The runners people actually have in their muscle memory.
P_RUNNERS = ("npx", "npm", "pnpm", "pnpx", "yarn", "bunx", "bun", "deno")
P_VERBS = ("add", "install", "i")
# `pnpm dlx` and `yarn dlx` are those managers' npx, and dlx is the form READMEs actually print.
P_RUNNER_SUBCOMMANDS = ("dlx", "exec", "run")
P_SKILL_ID = re.compile(r"^[A-Za-z0-9@._/-]+$")
@typechecked
def parse_install_command(raw: str) -> Optional[str]:
"""Return the skill id a pasted command refers to, or None when it is not an install command.
None means "I could not read this", never a guess: installing the wrong skill because a paste
was ambiguous is worse than asking the user to pick from the list."""
text = (raw or "").strip()
if not text:
return None
# A skills.sh (or GitHub) URL carries the id in its last meaningful path segment.
if text.startswith(("http://", "https://")):
parts = [p for p in text.split("?")[0].split("#")[0].rstrip("/").split("/") if p]
tail = parts[-1] if parts else ""
return tail if tail and P_SKILL_ID.match(tail) else None
# Strip a leading shell prompt or copy artifact ("$ npx ...").
text = re.sub(r"^[$>#]\s*", "", text)
tokens = text.split()
if not tokens:
return None
if tokens[0].lower() in P_RUNNERS:
# npx skills add <id> | npm i skills <id> | bunx skills add <id>
rest = [t for t in tokens[1:] if not t.startswith("-")]
if rest and rest[0].lower() in P_RUNNER_SUBCOMMANDS:
rest = rest[1:]
# The verb and the package name arrive in either order ("npx skills add x", "npm i skills x"),
# so strip both, in whichever order they appear.
named_registry = False
for _ in range(2):
if rest and rest[0].lower() in ("skills", "skill", "@skills/cli", "openswarm"):
rest = rest[1:]
named_registry = True
elif rest and rest[0].lower() in P_VERBS:
rest = rest[1:]
# Without the registry name this is just some other npx command, and `npx create-react-app foo`
# must never read as "install the create-react-app skill".
if not named_registry:
return None
candidate = rest[0] if rest else ""
return candidate if candidate and P_SKILL_ID.match(candidate) else None
# A bare id or scoped package pasted on its own.
if len(tokens) == 1 and P_SKILL_ID.match(tokens[0]) and "." not in tokens[0].split("/")[-1][:1]:
return tokens[0]
return None
@@ -115,6 +115,20 @@ class p_InstallRequest(BaseModel):
confirm: bool = False
class p_ParseCommandRequest(BaseModel):
# What the user pasted: "npx skills add pdf-filler", a skills.sh URL, or a bare name.
command: str
@skill_registry.router.post("/parse-command")
def registry_parse_command(req: p_ParseCommandRequest) -> dict:
"""Resolve a pasted install command to a skill id so the Marketplace's Add box accepts the
grammar people already copy out of READMEs. Resolution only; nothing is installed here, and an
unreadable paste returns null rather than a guess."""
from backend.apps.skill_registry.parse_install_command import parse_install_command
return {"skill_id": parse_install_command(req.command)}
@skill_registry.router.post("/install")
async def registry_install(req: p_InstallRequest):
"""Install a community (skills.sh) skill, in two honest steps.
+42
View File
@@ -0,0 +1,42 @@
"""Which model a workflow runs on when nobody picked one.
`DEFAULT_MODEL` is `opus-5`, and that is Anthropic's API-KEY lane: the Claude subscription lane is a
separate id (`opus-5-cc`). Defaulting every workflow to the literal therefore billed an API key a
subscriber may not even have, and named a model a Codex or Gemini subscriber cannot run at all.
Lives in its own module because both the routes and the executor need it, and importing one from the
other is a cycle.
"""
import logging
from typing import Optional
from typeguard import typechecked
from backend.apps.settings.models import DEFAULT_MODEL
logger = logging.getLogger(__name__)
@typechecked
def user_default_model() -> str:
"""The model this user actually configured, never a vendor literal."""
try:
from backend.apps.settings.settings import load_settings
chosen = (getattr(load_settings(), "default_model", "") or "").strip()
return chosen or DEFAULT_MODEL
except Exception:
logger.debug("could not read the user's default model", exc_info=True)
return DEFAULT_MODEL
@typechecked
def provider_for_model(model: Optional[str]) -> str:
"""Derive the provider from the model instead of assuming Anthropic."""
if not model:
return "anthropic"
try:
from backend.apps.agents.providers.registry import get_api_type
return get_api_type(model) or "anthropic"
except Exception:
logger.debug("could not derive a provider for %s", model, exc_info=True)
return "anthropic"
+64 -4
View File
@@ -7,6 +7,7 @@ routing, retries, and history all aligned with the rest of the app.
"""
import asyncio
import time
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -14,13 +15,20 @@ from typing import Optional
from backend.apps.agents.core.models import AgentConfig
from backend.apps.workflows.models import Workflow, WorkflowRun
from backend.apps.workflows import storage
from backend.apps.settings.models import DEFAULT_MODEL
from backend.apps.workflows.default_model import provider_for_model, user_default_model
logger = logging.getLogger(__name__)
# In-process map: workflow_id -> currently running run id. Prevents two overlapping fires for the same workflow (e.g. cron tick races a manual Run button) without serializing across the whole executor.
_running: dict[str, str] = {}
# Global admission on top of the per-workflow guard: every run is a full agent, and an agent's
# browsers and apps are exempt from the renderer budget by design (sleeping a working agent's browser
# blinds it), so the ONLY thing bounding total pressure is how many runs exist at once. A library of
# 30 workflows whose schedules drift into alignment must queue, not stampede.
MAX_CONCURRENT_RUNS = 3
ADMISSION_WAIT_S = 600.0
ADMISSION_POLL_S = 2.0
_running_lock = asyncio.Lock()
@@ -207,6 +215,58 @@ async def execute(
set_workflow_approval_step,
)
# Off means off. A workflow the user deleted or switched off must not be startable from ANY path:
# the scheduler, an agent tool, an invoke, a retry, the Run Now route, or a stale in-flight handle.
# Guarding this per call site left every unguarded caller able to fire it, which is the field report
# of a toggled-off workflow running itself. Turn it back on to run it.
# Wait for a global slot BEFORE the off-means-off guard, so the guard runs on fresh state after
# a possibly long wait (a workflow paused while queueing still gets refused, not run).
p_admit_start = time.monotonic()
while len(_running) >= MAX_CONCURRENT_RUNS:
if time.monotonic() - p_admit_start >= ADMISSION_WAIT_S:
p_busy = WorkflowRun(
workflow_id=wf.id, status="skipped",
error=f"{MAX_CONCURRENT_RUNS} workflows already running; gave up after {int(ADMISSION_WAIT_S)}s",
scheduled_for=scheduled_for, started_at=datetime.now(),
finished_at=datetime.now(), triggered_by=triggered_by,
)
p_wf_now = storage.get_workflow(wf.id)
if p_wf_now is not None and p_wf_now.deleted_at is None:
try:
storage.record_run(p_busy)
except Exception:
logger.debug("could not record the admission-skip row", exc_info=True)
return p_busy
await asyncio.sleep(ADMISSION_POLL_S)
p_live = storage.get_workflow(wf.id)
p_refusal = None
if p_live is None:
# A hard delete leaves nothing to look up, and reading that as "no objection" is how a
# deleted workflow still ran to the end and then wrote itself back to life.
p_refusal = "Workflow deleted"
elif p_live.deleted_at is not None:
p_refusal = "Workflow deleted"
elif not p_live.schedule.enabled:
p_refusal = "Workflow is paused"
if p_refusal is not None:
p_skipped = WorkflowRun(
workflow_id=wf.id, status="skipped", error=p_refusal,
scheduled_for=scheduled_for, started_at=datetime.now(),
finished_at=datetime.now(), triggered_by=triggered_by,
)
# Recorded, not just returned: a refusal the user cannot see in History reads as the run
# vanishing, and the Run Now route reports whatever row lands.
if p_live is not None and p_live.deleted_at is None:
try:
storage.record_run(p_skipped)
except Exception:
logger.debug("could not record the refusal row", exc_info=True)
return p_skipped
# Toggling a workflow off mid-run must stop it too, whatever started it. Comparing against the
# state at START is what separates "the user just switched it off" from "it was already paused
# and a human deliberately ran it anyway".
p_started_enabled = bool(p_live.schedule.enabled) if p_live is not None else bool(wf.schedule.enabled)
run = WorkflowRun(
workflow_id=wf.id,
status="running",
@@ -269,9 +329,9 @@ async def execute(
resolved_allowed_tools = _resolve_allowed_tools(wf)
config = AgentConfig(
name=wf.title or "Workflow",
model=wf.model or DEFAULT_MODEL,
model=wf.model or user_default_model(),
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
provider=wf.provider or provider_for_model(wf.model or user_default_model()),
system_prompt=_resolve_system_prompt(wf),
# None when the user has not frozen the Actions set, which means the workflow runs with the mode's full surface exactly like a chat does.
allowed_tools=resolved_allowed_tools,
@@ -362,7 +422,7 @@ async def execute(
if fresh_wf is None or fresh_wf.deleted_at is not None:
step_error = "Workflow deleted"
break
if triggered_by == "schedule" and not fresh_wf.schedule.enabled:
if p_started_enabled and not fresh_wf.schedule.enabled:
step_error = "Workflow paused"
break
# Broadcast the step bump before sending so RunningView flips the disc immediately, not after the agent finishes the step. Advancing means we're not paused; keep the broadcast authoritative so it never races a stale paused=True from the watcher.
+24 -1
View File
@@ -36,6 +36,13 @@ _missed_cache: list[MissedRun] = []
_cache_loaded = False
_paused = False
# Ids deleted during this process's life. The cache hands out SHARED Workflow instances, so a run
# already in flight when the user deletes still holds one and writes it back when it finishes, and
# save_workflow used to recreate the file AND the cache entry, fully scheduled: the workflow rose
# from the dead every time, which is exactly the "it never dies" field report. Only needs to live in
# memory, because after a restart nothing holds a stale instance to write back.
p_deleted_ids: set[str] = set()
def _resolve_host_tz_name() -> str:
"""Best-effort IANA name for the host. Mirrors apps/service/client.py."""
@@ -158,8 +165,18 @@ def get_workflow(wid: str) -> Optional[Workflow]:
return _workflow_cache.get(wid)
def save_workflow(wf: Workflow) -> Workflow:
def save_workflow(wf: Workflow, untrash: bool = False) -> Workflow:
with _io_lock:
if wf.id in p_deleted_ids:
logger.info("ignoring a write-back for deleted workflow %s", wf.id)
return wf
# Trash is one-way too: only /restore passes untrash. Any other save carrying an older copy
# (a run that started before the user hit delete) would otherwise clear deleted_at and put
# the workflow back on the page with its schedule re-armed.
prior = _workflow_cache.get(wf.id)
if not untrash and prior is not None and prior.deleted_at is not None and wf.deleted_at is None:
logger.info("ignoring a write-back that would untrash workflow %s", wf.id)
return wf
_ensure_dirs()
_workflow_cache[wf.id] = wf
p_atomic_write_json(_wf_path(wf.id), wf.model_dump(mode="json"))
@@ -171,6 +188,9 @@ def reload_workflow(wid: str) -> Optional[Workflow]:
instances, so a handler that mutated one and then failed must roll back through here or the
unsaved change lingers until any later save persists it by accident."""
with _io_lock:
if wid in p_deleted_ids:
_workflow_cache.pop(wid, None)
return None
path = _wf_path(wid)
if not os.path.exists(path):
_workflow_cache.pop(wid, None)
@@ -184,6 +204,7 @@ def reload_workflow(wid: str) -> Optional[Workflow]:
def delete_workflow(wid: str) -> bool:
with _io_lock:
existed = wid in _workflow_cache
p_deleted_ids.add(wid)
_workflow_cache.pop(wid, None)
_runs_cache.pop(wid, None)
wf_file = _wf_path(wid)
@@ -217,6 +238,8 @@ def list_all_runs(limit: int = 200) -> list[WorkflowRun]:
def record_run(run: WorkflowRun) -> WorkflowRun:
with _io_lock:
if run.workflow_id in p_deleted_ids:
return run
_ensure_dirs()
arr = _runs_cache.setdefault(run.workflow_id, [])
# Replace prior entry with same id if we're updating an in-flight run.
+36 -10
View File
@@ -22,6 +22,7 @@ from backend.apps.workflows.models import (
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
from backend.apps.workflows.cloud.handover import release_before_removing
from backend.apps.settings.models import DEFAULT_MODEL
from backend.apps.workflows.default_model import provider_for_model, user_default_model
logger = logging.getLogger(__name__)
@@ -226,6 +227,14 @@ async def p_sync_cloud_copy(wf: Workflow, data: dict) -> None:
raise HTTPException(status_code=502, detail=outcome.message or "The cloud copy could not be updated; try again.")
def p_drop_pending_missed(workflow_id: str) -> None:
"""Forget queued missed fires for a workflow. Switching one off has to clear the queue the same
way trashing does, or the launch review card still offers to run it and the run is then refused."""
stale = [m.id for m in storage.list_missed() if m.workflow_id == workflow_id]
if stale:
storage.remove_missed(stale)
def _normalize_schedule_state(wf: Workflow, source_allowed_tools: Optional[list[str]] = None) -> None:
if wf.schedule.timezone == "local" and wf.schedule.enabled:
wf.schedule.timezone = scheduler.host_timezone_name()
@@ -284,9 +293,9 @@ async def create_workflow(body: WorkflowCreate):
permissions=body.permissions or [],
source_session_id=body.source_session_id,
dashboard_id=body.dashboard_id,
model=body.model or DEFAULT_MODEL,
model=body.model or user_default_model(),
mode=body.mode or "agent",
provider=body.provider or "anthropic",
provider=body.provider or provider_for_model(body.model or user_default_model()),
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
auto_named=body.auto_named,
unsaved=body.unsaved,
@@ -448,6 +457,10 @@ async def p_generate_metadata_for_steps(
_PLACEHOLDER_TITLES = {"", "New workflow", "Untitled workflow", "Scheduled workflow"}
# Ceiling on the cosmetic label/title aux call, which runs INSIDE the step-edit request. The SDK's own
# stream timeout is minutes, long enough that a stalled lane reads as the editor being dead.
AUX_LABEL_TIMEOUT_S = 20.0
def p_fallback_title_for_steps(steps: list[WorkflowStep]) -> str:
"""Deterministic title derived from the steps, used when the aux model is
@@ -510,9 +523,16 @@ async def p_relabel_steps(
if not regen_idxs and not need_autoname:
return
try:
title, description, labels = await p_generate_metadata_for_steps(steps, model)
title, description, labels = await asyncio.wait_for(
p_generate_metadata_for_steps(steps, model), timeout=AUX_LABEL_TIMEOUT_S,
)
except Exception:
return
# Every caller awaits this INSIDE the PATCH request, so an aux lane that stalls used to hold
# the whole edit open and the editor just span: an agent editing a step bricked the app. This
# is decoration with deterministic fallbacks right below, so failing here must cost a nicer
# label, never the edit itself.
logger.info("workflow meta gen unavailable; using deterministic labels", exc_info=True)
title, description, labels = "", "", []
# One aux call covers labels AND auto-naming. A manual rename sets auto_named=False, so the title/description below are left untouched then.
if need_autoname:
if title:
@@ -674,7 +694,9 @@ async def list_missed_runs(limit: int = 50):
out: list[dict] = []
for m in missed[:limit]:
wf = storage.get_workflow(m.workflow_id)
if not wf:
# Paused counts as gone here, same as trashed: offering to run a switched-off workflow is an
# action we would then refuse, and the card is the one place a stale entry is visible.
if not wf or wf.deleted_at is not None or not wf.schedule.enabled:
continue
out.append({
"id": m.id,
@@ -702,7 +724,7 @@ async def run_missed_runs(body: MissedRunAction):
started = 0
for wid, fors in by_wf.items():
wf = storage.get_workflow(wid)
if not wf:
if not wf or wf.deleted_at is not None or not wf.schedule.enabled:
continue
started += len(fors)
asyncio.create_task(scheduler.run_missed_sequence(wf, fors))
@@ -837,6 +859,8 @@ async def update_workflow(
if not wf.icon:
wf.icon = _derive_icon(wf)
_normalize_schedule_state(wf)
if not wf.schedule.enabled:
p_drop_pending_missed(wf.id)
await p_sync_cloud_copy(wf, data)
storage.save_workflow(wf)
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
@@ -886,9 +910,7 @@ async def delete_workflow(workflow_id: str):
# A trashed workflow's in-flight run was previously un-stoppable even by hand (the manual Stop path filters deleted); halt it now, with the executor's per-step deleted-recheck as backstop.
await _stop_in_flight_run(workflow_id)
# Drop any pending missed fires so a trashed workflow can't haunt the card.
stale = [m.id for m in storage.list_missed() if m.workflow_id == workflow_id]
if stale:
storage.remove_missed(stale)
p_drop_pending_missed(workflow_id)
scheduler.kick()
try:
from backend.apps.agents.core.ws_manager import ws_manager
@@ -906,7 +928,7 @@ async def restore_workflow(workflow_id: str):
if not wf or wf.deleted_at is None:
raise HTTPException(status_code=404, detail="Workflow not in trash")
wf.deleted_at = None
storage.save_workflow(wf)
storage.save_workflow(wf, untrash=True)
enriched = _enriched(wf)
try:
from backend.apps.agents.core.ws_manager import ws_manager
@@ -1453,6 +1475,10 @@ async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# The executor refuses a trashed workflow but writes no history for it, so without this the caller
# got run_id "" with a null status and no idea why nothing happened.
if wf.deleted_at is not None:
raise HTTPException(status_code=409, detail="This workflow is in Trash. Restore it to run it.")
# executor.execute() owns the run record. Don't pre-create a stub here or we end up with two rows per manual fire (one orphan "running" row from this handler plus the real one from the executor).
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
tested_signature = body.get("signature") if isinstance(body, dict) else None
@@ -0,0 +1,47 @@
"""An agent must not start a workflow the user switched off.
Field report (Haik, 1.7.4): "a workflow that had been toggled off just started running again".
The route ignores `schedule.enabled` for manual runs on purpose, because a human clicking Run Now
can see the paused state. An agent reaching that same route is a different act.
"""
from unittest.mock import patch
import backend.apps.agents.schedule_mcp_server as mod
def p_calls(get_result):
seen = {"ran": False}
def fake(method, path, body=None, timeout=None):
if method == "GET":
return get_result
seen["ran"] = True
return {"run_id": "r1"}
return fake, seen
def test_agent_refuses_to_run_a_paused_workflow():
fake, seen = p_calls({"title": "Nightly report", "schedule": {"enabled": False}})
with patch.object(mod, "_call", side_effect=fake):
out = mod.handle_run_now({"workflow_id": "w1"})
assert seen["ran"] is False, "the run must never be dispatched"
assert out.get("isError") is True
text = out["content"][0]["text"]
assert "paused" in text and "Nightly report" in text
def test_agent_runs_an_enabled_workflow_normally():
fake, seen = p_calls({"title": "Nightly report", "schedule": {"enabled": True}})
with patch.object(mod, "_call", side_effect=fake):
out = mod.handle_run_now({"workflow_id": "w1"})
assert seen["ran"] is True
assert not out.get("isError")
def test_an_unreadable_workflow_does_not_block_the_run():
"""Fail open: if we cannot read the workflow, behave as before rather than refusing everything."""
fake, seen = p_calls({"_error": "boom"})
with patch.object(mod, "_call", side_effect=fake):
out = mod.handle_run_now({"workflow_id": "w1"})
assert seen["ran"] is True
assert not out.get("isError")
+18
View File
@@ -124,3 +124,21 @@ def test_reset_window_401_is_transient_not_auth():
def test_genuine_auth_death_still_cards():
assert is_auth_error(Exception("401 unauthorized: invalid authentication credentials"))
assert capacity_retry_wait(Exception("401 unauthorized: invalid api key"), 0) is None
# --- the first-turn thinking flip must not drift the boot fingerprint (measured: it threw away the
# pre-warmed CLI on 5 of 5 short-prompt sessions, costing ~0.9s of respawn per first message) ------
def test_short_first_message_keeps_the_prewarmed_thinking_setting():
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.run.run_options_helpers import inject_thinking_options
first = AgentSession(id="s1", name="n", model="sonnet-cc", thinking_level="auto")
k_first: dict = {}
inject_thinking_options(k_first, first, "hi", "cc/claude-sonnet-4-6", "anthropic")
later = AgentSession(id="s2", name="n", model="sonnet-cc", thinking_level="auto")
later.sdk_session_id = "already-running"
k_later: dict = {}
inject_thinking_options(k_later, later, "hi", "cc/claude-sonnet-4-6", "anthropic")
assert k_first != k_later, "a short FIRST message must keep the prewarmed boot options"
+49
View File
@@ -0,0 +1,49 @@
"""One release story, three surfaces. A release with no story, or a Help agent answering from a
stale picture of the app, are both bugs this pins shut."""
from backend.apps.help.changelog import (
all_versions, as_markdown, help_context_block, latest_release, release_notes,
)
from backend.apps.service.version import APP_VERSION
def test_the_shipping_version_has_a_story():
note = release_notes(APP_VERSION)
assert note is not None, f"{APP_VERSION} ships with no release notes; write them before tagging"
assert note.headline and (note.highlights or note.fixes)
def test_notes_are_written_for_users_not_committers():
for note in (latest_release(),):
for line in note.highlights + note.fixes:
assert not line.startswith("["), "no commit-style prefixes"
assert "commit" not in line.lower() and "refactor" not in line.lower()
assert "" not in line and "" not in line, "house style: no em/en dashes"
def test_no_em_dashes_anywhere_in_a_release_body():
# House rule, and the header was the one place the earlier test did not look.
md = as_markdown(latest_release())
assert "\u2014" not in md and "\u2013" not in md, "release bodies use plain punctuation"
def test_markdown_body_carries_the_same_words_as_the_app():
note = latest_release()
md = as_markdown(note)
assert note.headline in md
for line in note.highlights + note.fixes:
assert line in md, "the GitHub body must not drift from the in-app card"
def test_help_context_names_the_version_and_its_changes():
block = help_context_block(APP_VERSION)
assert APP_VERSION in block
note = release_notes(APP_VERSION)
assert note is not None
assert note.highlights[0] in block
def test_unknown_version_falls_back_to_the_latest_story_not_silence():
assert release_notes("0.0.0") is None
assert latest_release().version in help_context_block("0.0.0")
assert len(all_versions()) >= 2
@@ -0,0 +1,52 @@
"""Shipping a new default system prompt has to carry the old one with it.
The default persists into settings.json, so bumping the constant alone leaves every existing install
on the old text forever: they never see the change, and "Reset to default" compares against something
that no longer exists. Each legacy revision is derived from the current default with a `replace`, and
a `replace` whose anchor has drifted silently returns the string unchanged, which turns the whole
migration into a no-op that nothing would notice. These pin both halves.
"""
from backend.apps.settings.models import DEFAULT_SYSTEM_PROMPT
from backend.apps.settings.store import (
P_LEGACY_DEFAULT_SYSTEM_PROMPT,
P_LEGACY_DEFAULT_SYSTEM_PROMPTS,
P_LEGACY_LADDER_V1,
)
def test_every_legacy_revision_actually_differs_from_the_current_default():
"""A derived revision equal to the default means its anchor text drifted and the replace did
nothing, so users on that revision would never be migrated off it."""
for i, legacy in enumerate(P_LEGACY_DEFAULT_SYSTEM_PROMPTS):
assert legacy != DEFAULT_SYSTEM_PROMPT, (
f"legacy revision {i} is byte-identical to the current default, so its derivation "
"silently no-opped, most likely because the anchor string it replaces was edited"
)
def test_the_shipped_revisions_are_all_tracked():
assert P_LEGACY_DEFAULT_SYSTEM_PROMPT in P_LEGACY_DEFAULT_SYSTEM_PROMPTS
assert P_LEGACY_LADDER_V1 in P_LEGACY_DEFAULT_SYSTEM_PROMPTS
assert len(set(P_LEGACY_DEFAULT_SYSTEM_PROMPTS)) == len(P_LEGACY_DEFAULT_SYSTEM_PROMPTS), (
"duplicate legacy revisions mean one of the derivations collapsed onto another"
)
def test_the_web_ladder_tells_the_agent_to_try_the_cheap_tools_first():
"""Eric's ask, and the reason the ladder step was rewritten: an agent given a plain reading task
was driving a browser instead of searching, which is far slower. The old wording filed the web
tools under 'No tool fits', which reads as a last resort."""
assert "WebSearch / WebFetch first" in DEFAULT_SYSTEM_PROMPT
assert "No tool fits." not in DEFAULT_SYSTEM_PROMPT, "the last-resort framing is back"
ladder = DEFAULT_SYSTEM_PROMPT[DEFAULT_SYSTEM_PROMPT.index("5. **Reading the web.**"):]
web_at = ladder.index("WebSearch")
browser_at = ladder.index("BrowserAgent")
assert web_at < browser_at, "the browser must not be named before the cheap tools"
assert "Escalate to BrowserAgent only" in DEFAULT_SYSTEM_PROMPT, "the fallback must stay conditional"
def test_a_user_customized_prompt_is_never_mistaken_for_a_default():
"""The migration is a verbatim match, so an edited prompt must never collide with a shipped one."""
customized = DEFAULT_SYSTEM_PROMPT + "\nAlways answer in French.\n"
assert customized not in P_LEGACY_DEFAULT_SYSTEM_PROMPTS
@@ -0,0 +1,156 @@
"""A deleted workflow must never come back, by any route.
Field report (Haik, 1.7.4): a scheduled workflow survived deleting it, wiping OpenSwarm data,
deleting the app, and reinstalling. It kept firing every 45 minutes. `delete_workflow` removes the
file and the cache entry, but `save_workflow` was an unconditional upsert that recreates BOTH, so any
write-back from a run that was already in flight resurrected it, fully scheduled. Runs that stall for
1200s make that window enormous, and each resurrection re-armed the timer, so it never died.
"""
import asyncio
from datetime import datetime, timezone
import pytest
from backend.apps.workflows import storage
from backend.apps.workflows.models import Workflow, WorkflowRun, WorkflowStep
def p_make(title: str = "ghost") -> Workflow:
return Workflow(
title=title,
steps=[WorkflowStep(prompt="do a thing")],
schedule={"enabled": True, "kind": "interval", "every_minutes": 45},
)
@pytest.fixture(autouse=True)
def p_isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(storage, "DATA_DIR", str(tmp_path / "workflows"))
monkeypatch.setattr(storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
monkeypatch.setattr(storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
monkeypatch.setattr(storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
monkeypatch.setattr(storage, "_workflow_cache", {})
monkeypatch.setattr(storage, "_runs_cache", {})
monkeypatch.setattr(storage, "_missed_cache", [])
monkeypatch.setattr(storage, "p_deleted_ids", set(), raising=False)
monkeypatch.setattr(storage, "_cache_loaded", True)
yield
def test_stale_save_after_delete_does_not_resurrect():
"""The exact Haik bug: an in-flight run holds the object, the user deletes, the run writes back."""
wf = storage.save_workflow(p_make())
in_flight = storage.get_workflow(wf.id)
assert in_flight is not None
assert storage.delete_workflow(wf.id) is True
in_flight.last_run_at = datetime.now(timezone.utc)
storage.save_workflow(in_flight)
assert storage.get_workflow(wf.id) is None
assert [w.id for w in storage.list_workflows()] == []
import os
assert not os.path.exists(storage._wf_path(wf.id))
def test_repeated_stale_saves_never_resurrect():
"""It never dies: every later write-back must also bounce, not just the first."""
wf = storage.save_workflow(p_make())
stale = storage.get_workflow(wf.id)
storage.delete_workflow(wf.id)
for i in range(5):
stale.next_run_at = datetime.now(timezone.utc)
storage.save_workflow(stale)
assert storage.get_workflow(wf.id) is None, f"resurrected on write-back {i + 1}"
def test_record_run_after_delete_does_not_recreate_history():
"""Runs are deleted with the workflow; a late run row must not rebuild an orphan history file."""
wf = storage.save_workflow(p_make())
storage.delete_workflow(wf.id)
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success"))
assert storage.list_runs(wf.id) == []
import os
assert not os.path.exists(storage._runs_path(wf.id))
def test_delete_does_not_block_a_different_workflow():
"""The tombstone is per id: deleting one must not stop anything else being saved."""
dead = storage.save_workflow(p_make("dead"))
storage.delete_workflow(dead.id)
alive = storage.save_workflow(p_make("alive"))
assert storage.get_workflow(alive.id) is not None
assert [w.id for w in storage.list_workflows()] == [alive.id]
def test_executor_refuses_a_workflow_that_no_longer_exists():
"""A hard delete makes get_workflow return None, which the pause guard did not treat as refusal,
so a deleted workflow still ran to completion and then resurrected itself on write-back."""
from backend.apps.workflows import executor
wf = storage.save_workflow(p_make())
storage.delete_workflow(wf.id)
run = asyncio.run(executor.execute(wf, triggered_by="schedule"))
assert run.status == "skipped"
assert "delete" in (run.error or "").lower()
assert storage.get_workflow(wf.id) is None
def test_disable_schedule_on_a_deleted_workflow_stays_dead():
"""The scheduler disables end-of-life workflows by saving them; on a deleted one that is a
resurrection with enabled=False, which still shows up on the Workflows page."""
from backend.apps.workflows import scheduler
wf = storage.save_workflow(p_make())
stale = storage.get_workflow(wf.id)
storage.delete_workflow(wf.id)
scheduler._disable_schedule(stale)
assert storage.get_workflow(wf.id) is None
assert [w.id for w in storage.list_workflows()] == []
def test_a_stale_copy_cannot_untrash_a_workflow():
"""Trash is one-way. reload_workflow hands out a NEW instance, so a run that started before the
user hit delete can end up holding a copy whose deleted_at is still None; saving that copy put
the workflow back on the page with its schedule re-armed."""
wf = storage.save_workflow(p_make())
stale = storage.get_workflow(wf.id)
storage.reload_workflow(wf.id) # the cache now holds a DIFFERENT instance; the run kept `stale`
trashed = storage.get_workflow(wf.id)
assert trashed is not stale, "test models nothing unless the two copies really diverged"
assert stale.deleted_at is None
trashed.deleted_at = datetime.now()
trashed.schedule.enabled = False
storage.save_workflow(trashed)
stale.last_run_at = datetime.now(timezone.utc)
storage.save_workflow(stale)
live = storage.get_workflow(wf.id)
assert live is not None and live.deleted_at is not None, "a stale copy untrashed it"
assert live.schedule.enabled is False, "the schedule was re-armed by a stale write-back"
def test_restore_can_still_untrash():
"""The one-way rule must not brick the Trash > Restore button."""
wf = storage.save_workflow(p_make())
trashed = storage.get_workflow(wf.id)
trashed.deleted_at = datetime.now()
storage.save_workflow(trashed)
back = storage.reload_workflow(wf.id)
back.deleted_at = None
storage.save_workflow(back, untrash=True)
assert storage.get_workflow(wf.id).deleted_at is None
def test_delete_survives_a_reload_from_disk():
"""reload_workflow re-reads from disk; on a deleted id it must not repopulate the cache."""
wf = storage.save_workflow(p_make())
storage.delete_workflow(wf.id)
assert storage.reload_workflow(wf.id) is None
assert storage.get_workflow(wf.id) is None
@@ -0,0 +1,54 @@
"""A workflow the user deleted or switched off must not run from ANY path.
Eric: "if the workflow is toggled off or deleted, it shouldn't be able to run ever, even as a
detached head". Guarding individual call sites left every unguarded caller able to fire it, so the
invariant lives in the executor where all of them converge.
"""
import asyncio
from datetime import datetime
from unittest.mock import patch
import pytest
from backend.apps.workflows import executor
from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep
def p_wf(enabled: bool, deleted: bool = False) -> Workflow:
wf = Workflow(
title="t",
steps=[WorkflowStep(text="say hi", enabled=True)],
schedule=ScheduleConfig(enabled=enabled),
)
if deleted:
wf.deleted_at = datetime.now()
return wf
@pytest.mark.parametrize("trigger", ["schedule", "retry", "manual"])
def test_a_deleted_workflow_never_runs_from_any_trigger(trigger):
wf = p_wf(enabled=True, deleted=True)
with patch.object(executor.storage, "get_workflow", return_value=wf):
run = asyncio.run(executor.execute(wf, triggered_by=trigger))
assert run.status == "skipped"
assert run.error == "Workflow deleted"
@pytest.mark.parametrize("trigger", ["schedule", "retry", "manual"])
def test_a_paused_workflow_never_runs_from_any_trigger(trigger):
"""Off means off: even the Run Now route cannot start a workflow the user switched off."""
wf = p_wf(enabled=False)
with patch.object(executor.storage, "get_workflow", return_value=wf):
run = asyncio.run(executor.execute(wf, triggered_by=trigger))
assert run.status == "skipped"
assert run.error == "Workflow is paused"
def test_turning_it_back_on_lets_it_run_again():
"""The guard must be about state, not a permanent block."""
wf = p_wf(enabled=True)
with patch.object(executor.storage, "get_workflow", return_value=wf):
with patch.object(executor.storage, "record_run"):
with patch.object(executor, "_monthly_spend_so_far", return_value=0.0):
assert executor.storage.get_workflow(wf.id).schedule.enabled is True
@@ -0,0 +1,52 @@
import inspect
"""Clause: every surfaced error carries the full flight envelope.
`cli_binary_missing` shipped without one and nobody noticed, because the only way to see it is to
force a failure class that cannot be forced on a dev box (the SDK falls back to the system `claude`,
then to ~/.claude/local/claude). An audit of the source is the sensor that does not need the repro."""
import re
from backend.apps.agents.manager.run import handle_run_error as p_handler_mod
from backend.apps.agents import agent_manager as p_manager_mod
P_SOURCES = {
"handle_run_error": p_handler_mod,
"agent_manager": p_manager_mod,
}
def p_diagnostic_blocks(src: str):
return re.findall(r'submit_diagnostic\(\{(.*?)\}\)', src, re.S)
def test_every_error_diagnostic_carries_a_flight_envelope():
import inspect
missing = []
for name, mod in P_SOURCES.items():
for block in p_diagnostic_blocks(inspect.getsource(mod)):
kind = re.search(r'"kind":\s*"([a-z_]+)"', block)
kind = kind.group(1) if kind else "?"
# `recovered` rows are near-miss ledger entries, built by record_recovery, not error cards.
if kind == "recovered":
continue
if '"flight"' not in block:
missing.append(f"{name}:{kind}")
assert not missing, f"error diagnostics with no envelope: {missing}"
def test_the_cli_missing_class_specifically_is_covered():
import inspect
src = inspect.getsource(p_handler_mod)
block = [b for b in p_diagnostic_blocks(src) if '"cli_binary_missing"' in b]
assert block, "the cli_binary_missing diagnostic disappeared"
assert '"flight"' in block[0]
def test_silent_quit_diagnostic_carries_a_full_envelope():
"""A silent quit is the hardest class to diagnose later, so it must not ship envelope-less.
Found live: empty_finish_nudge was the ONE family writing only kind/model/session_id."""
import backend.apps.agents.manager.run.empty_finish as ef
src = inspect.getsource(ef.maybe_nudge_empty_finish) if hasattr(ef, "maybe_nudge_empty_finish") else inspect.getsource(ef)
assert "build_envelope" in src, "empty_finish_nudge must attach a flight envelope"
assert '"flight"' in src, "the envelope must ride under the standard 'flight' key"
@@ -0,0 +1,88 @@
"""The flight envelope has to answer what/who/when/during-what from analytics alone.
The recorder was already built and wired into six error paths, but nothing asserted that what it
emits is COMPLETE. An envelope missing its lane or its breadcrumbs looks fine in a log and is useless
in an investigation, which is the exact failure it exists to prevent: every bug found by hand tonight
(a workflow that resurrected itself, a keyboard tap alive for 2h35m, an editor hung on a stalled aux
call) was silent in analytics.
These force each wired family and assert the envelope a stranger would receive.
"""
from typing import List
import pytest
from backend.apps.agents.core import flight_recorder as fr
# Straight from the clause: what a stranger needs, with no machine in front of them.
P_REQUIRED_FIELDS = {
"family", "subkind", "lane", "model", "phase", "attempts",
"breadcrumbs", "journey", "concurrency",
}
# Every family that actually attaches an envelope today (grep: build_envelope call sites).
P_WIRED_FAMILIES: List[tuple] = [
("model_error", "provider_500", "stream"),
("model_error", "auth_401", "spawn"),
("context_overflow", "overflow", "stream"),
("cli_binary_missing", "missing", "spawn"),
("context_pressure_valve", "pressure_death", "stream"),
("empty_finish", "no_answer_text", "stream"),
]
@pytest.fixture(autouse=True)
def p_clean_rings(monkeypatch):
monkeypatch.setattr(fr, "p_rings", {})
yield
@pytest.mark.parametrize("family,subkind,phase", P_WIRED_FAMILIES)
def test_every_wired_family_emits_a_complete_envelope(family, subkind, phase):
sid = f"sess-{family}"
for i in range(12):
fr.crumb(sid, "phase", step=i)
env = fr.build_envelope(sid, family, subkind, "claude-sonnet-5", phase, 3, sessions={})
missing = P_REQUIRED_FIELDS - set(env)
assert not missing, f"{family}: envelope is missing {sorted(missing)}"
assert env["family"] == family
assert env["subkind"] == subkind
assert env["phase"] == phase
assert env["attempts"] == 3
assert env["lane"], "lane must name WHICH provider the failure happened on"
def test_breadcrumbs_reach_the_ten_the_clause_asks_for():
sid = "sess-crumbs"
for i in range(15):
fr.crumb(sid, "tool_call", n=i)
env = fr.build_envelope(sid, "model_error", "x", "claude-sonnet-5", "stream", 1, sessions={})
assert len(env["breadcrumbs"]) >= 10, f"only {len(env['breadcrumbs'])} breadcrumbs"
def test_a_turn_with_no_history_still_emits_a_usable_envelope():
"""A failure at spawn has no breadcrumbs yet; the envelope must still name cause and context
rather than blowing up, or the earliest failures are exactly the ones we cannot see."""
env = fr.build_envelope("sess-empty", "cli_binary_missing", "missing", None, "spawn", 0, sessions={})
assert not (P_REQUIRED_FIELDS - set(env))
assert env["breadcrumbs"] == []
assert env["family"] == "cli_binary_missing"
def test_the_envelope_never_raises_on_a_bad_session_id():
"""It is built on the error path. If it can throw, it converts a diagnosable failure into a
second, undiagnosable one."""
for sid in ("", "does-not-exist", "\x00weird"):
env = fr.build_envelope(sid, "model_error", "x", "gpt-5", "stream", 1, sessions={})
assert env["family"] == "model_error"
def test_the_near_miss_ledger_records_a_silent_recovery():
"""The clause's last line: a recovery the user never saw still needs a denominator."""
sid = "sess-recover"
fr.crumb(sid, "api_retry", attempt=1)
fr.record_recovery(sid, net="router_respawn", model="claude-sonnet-5", attempts=2, sessions={})
labels = [c.get("l") for c in fr.breadcrumbs(sid)]
assert "recovered" in labels, f"no recovery breadcrumb, got {labels}"
+84
View File
@@ -0,0 +1,84 @@
"""The flight recorder's contract: cheap crumbs, bounded rings, envelopes that name everything,
and a near-miss ledger that counts silent recoveries."""
from backend.apps.agents.core import flight_recorder as fr
def test_ring_is_bounded_and_ordered():
sid = "t-ring"
fr.drop_session(sid)
for i in range(100):
fr.crumb(sid, "step", n=i)
crumbs = fr.breadcrumbs(sid, last=100)
assert len(crumbs) == 64, "ring must cap at 64"
assert crumbs[-1]["n"] == 99 and crumbs[0]["n"] == 36, "oldest drop first"
fr.drop_session(sid)
assert fr.breadcrumbs(sid) == []
def test_meta_values_are_truncated_and_typed():
sid = "t-meta"
fr.drop_session(sid)
fr.crumb(sid, "err", msg="x" * 999, count=3, flag=True, skipped=None)
c = fr.breadcrumbs(sid)[0]
assert len(c["msg"]) == 200 and c["count"] == 3 and c["flag"] is True and "skipped" not in c
fr.drop_session(sid)
def test_lane_classification_matches_the_routing_reality():
assert fr.lane_for_model("sonnet-cc") == "cc"
assert fr.lane_for_model("cx/gpt-5.2") == "cx"
assert fr.lane_for_model("gemini-2.5-pro") == "gc"
assert fr.lane_for_model("openrouter/meta/llama") == "openrouter"
assert fr.lane_for_model("cp-openai/local") == "custom"
assert fr.lane_for_model("sonnet") == "api"
assert fr.lane_for_model(None) == "api"
def test_envelope_carries_cause_context_and_crumbs():
sid = "t-env"
fr.drop_session(sid)
fr.crumb(sid, "router-retry", attempt=1)
class FakeSession:
status = "running"
env = fr.build_envelope(sid, "model_error", "unclassified", "sonnet-cc", "stream", 2, {"a": FakeSession(), "b": FakeSession()})
assert env["family"] == "model_error" and env["lane"] == "cc" and env["phase"] == "stream"
assert env["attempts"] == 2 and env["breadcrumbs"][0]["l"] == "router-retry"
assert env["concurrency"] == {"sessions_total": 2, "turns_running": 2}
fr.drop_session(sid)
def test_recovery_ledger_emits_a_countable_diagnostic(monkeypatch):
sent = []
import backend.apps.service.client as svc
monkeypatch.setattr(svc, "submit_diagnostic", lambda d: sent.append(d))
fr.record_recovery("t-rec-12345678", "router-resume", "sonnet-cc", 1, None)
assert len(sent) == 1
d = sent[0]
assert d["kind"] == "recovered" and d["subkind"] == "router-resume" and d["lane"] == "cc" and d["attempts"] == 1
fr.drop_session("t-rec-12345678")
def test_envelope_carries_journey_and_auth_context():
sid = "t-journey"
fr.drop_session(sid)
env = fr.build_envelope(sid, "model_error", "auth", "sonnet-cc", "spawn", 1, {})
j = env["journey"]
assert set(j) >= {"stage", "signed_in"}, "stage + signed_in are the minimum honest context"
assert j["stage"] in ("onboarding", "returning", "unknown")
fr.drop_session(sid)
def test_breadcrumb_trail_reaches_ten_across_a_normal_spawn():
# The spawn pipeline now crumbs every phase; ten is the bar for reconstructing a turn.
sid = "t-trail"
fr.drop_session(sid)
for label in ["turn-start", "options-build", "mcp-build", "provider-env", "context-guard",
"client-acquire", "cli-connect-start", "cli-connect-done", "first-event",
"assistant-msg", "result-msg"]:
fr.crumb(sid, label)
assert len(fr.breadcrumbs(sid, last=20)) >= 10
fr.drop_session(sid)
@@ -0,0 +1,43 @@
"""Pasting the command from a README must install the skill. Guessing wrong is worse than asking,
so anything unreadable returns None rather than a best effort."""
import pytest
from backend.apps.skill_registry.parse_install_command import parse_install_command
@pytest.mark.parametrize("cmd", [
"npx skills add pdf-filler",
"npx skills install pdf-filler",
"npm i skills pdf-filler",
"pnpm skills add pdf-filler",
"bunx skills add pdf-filler",
"$ npx skills add pdf-filler",
"npx --yes skills add pdf-filler",
"npx skills add pdf-filler ",
])
def test_every_common_runner_and_verb_resolves_the_same_skill(cmd):
assert parse_install_command(cmd) == "pdf-filler"
def test_scoped_names_survive():
assert parse_install_command("npx skills add @anthropic/docx") == "@anthropic/docx"
assert parse_install_command("@anthropic/docx") == "@anthropic/docx"
def test_a_bare_name_is_accepted():
assert parse_install_command("pdf-filler") == "pdf-filler"
def test_urls_carry_their_id_in_the_last_segment():
assert parse_install_command("https://skills.sh/s/pdf-filler") == "pdf-filler"
assert parse_install_command("https://skills.sh/s/pdf-filler/") == "pdf-filler"
assert parse_install_command("https://skills.sh/s/pdf-filler?ref=x") == "pdf-filler"
@pytest.mark.parametrize("junk", [
"", " ", "npx skills add", "npm install", "how do i install a skill",
"rm -rf /", "npx skills add ; rm -rf /",
])
def test_unreadable_input_returns_none_rather_than_a_guess(junk):
assert parse_install_command(junk) is None
@@ -0,0 +1,52 @@
"""A paste that is not a skills install must return None, not a guess.
`npx create-react-app foo` parsed as the skill id "create-react-app" until 2026-08-07: any unrelated
npx command a user pasted would have installed a skill by that name. The module's own docstring says
None means "I could not read this", never a guess, so the registry name is now required."""
import pytest
from backend.apps.skill_registry.parse_install_command import parse_install_command
REAL_INSTALLS = [
("npx skills add pdf-filler", "pdf-filler"),
("$ npx skills add pdf-filler", "pdf-filler"),
("npm i skills pdf-filler", "pdf-filler"),
("bunx skills install pdf-filler", "pdf-filler"),
("pnpm skills add @acme/pdf-filler", "@acme/pdf-filler"),
("npx skills add pdf-filler --force", "pdf-filler"),
("https://skills.sh/s/pdf-filler", "pdf-filler"),
("pdf-filler", "pdf-filler"),
]
NOT_INSTALLS = [
"npx create-react-app foo",
"npm i lodash",
"npx vite build",
"yarn add react",
"rm -rf /",
"",
" ",
]
@pytest.mark.parametrize("raw,expected", REAL_INSTALLS)
def test_the_forms_people_actually_paste_still_parse(raw, expected):
assert parse_install_command(raw) == expected
@pytest.mark.parametrize("raw", NOT_INSTALLS)
def test_anything_that_is_not_a_skills_install_is_refused(raw):
assert parse_install_command(raw) is None, f"{raw!r} must not be read as a skill id"
def test_dlx_is_the_form_readmes_actually_print():
"""`pnpm dlx` / `yarn dlx` are those managers' npx; missing them meant the most common paste failed."""
assert parse_install_command("pnpm dlx skills add note-taker") == "note-taker"
assert parse_install_command("yarn dlx skills add note-taker") == "note-taker"
assert parse_install_command("pnpm exec skills add note-taker") == "note-taker"
def test_dlx_does_not_widen_the_refusals():
"""Skipping the runner subcommand must not turn every dlx invocation into an install."""
assert parse_install_command("pnpm dlx create-vite my-app") is None
assert parse_install_command("yarn dlx prettier --write .") is None
@@ -0,0 +1,110 @@
"""Switching a workflow off must leave NOTHING queued anywhere.
Eric: "when you pause it it better not be putting ANYTHING anywhere". Trashing already dropped a
workflow's pending missed fires; pausing did not, and neither the review-card list nor its run
endpoint looked at `schedule.enabled` (both only skipped `if not wf`). So a paused workflow kept
appearing on the launch review card, and running it from there reported started=N while the executor
refused every one of them.
"""
from datetime import datetime, timedelta, timezone
import pytest
from backend.apps.workflows import storage
from backend.apps.workflows.models import MissedRun, Workflow, WorkflowStep
def p_make(enabled: bool = True) -> Workflow:
return Workflow(
title="nightly",
steps=[WorkflowStep(text="do it")],
schedule={"enabled": enabled, "kind": "interval", "every_minutes": 45},
)
@pytest.fixture(autouse=True)
def p_isolated(tmp_path, monkeypatch):
monkeypatch.setattr(storage, "DATA_DIR", str(tmp_path / "workflows"))
monkeypatch.setattr(storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
monkeypatch.setattr(storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
monkeypatch.setattr(storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
monkeypatch.setattr(storage, "_workflow_cache", {})
monkeypatch.setattr(storage, "_runs_cache", {})
monkeypatch.setattr(storage, "_missed_cache", [])
monkeypatch.setattr(storage, "p_deleted_ids", set(), raising=False)
monkeypatch.setattr(storage, "_cache_loaded", True)
yield
def p_queue_a_miss(wf: Workflow) -> MissedRun:
m = MissedRun(
workflow_id=wf.id,
scheduled_for=datetime.now(timezone.utc) - timedelta(hours=2),
)
storage.add_missed(m)
return m
def test_pausing_clears_the_pending_missed_queue():
from backend.apps.workflows.workflows import p_drop_pending_missed
wf = storage.save_workflow(p_make())
p_queue_a_miss(wf)
assert [m.workflow_id for m in storage.list_missed()] == [wf.id]
wf.schedule.enabled = False
storage.save_workflow(wf)
p_drop_pending_missed(wf.id)
assert storage.list_missed() == [], "a switched-off workflow left fires queued"
def test_pausing_one_workflow_leaves_another_alone():
from backend.apps.workflows.workflows import p_drop_pending_missed
off = storage.save_workflow(p_make())
on = storage.save_workflow(p_make())
p_queue_a_miss(off)
p_queue_a_miss(on)
p_drop_pending_missed(off.id)
assert [m.workflow_id for m in storage.list_missed()] == [on.id]
@pytest.mark.asyncio
async def test_review_card_hides_a_paused_workflows_misses():
"""Belt over the clearing above: entries queued BEFORE this fix shipped must vanish too."""
from backend.apps.workflows.workflows import list_missed_runs
wf = storage.save_workflow(p_make())
p_queue_a_miss(wf)
assert len((await list_missed_runs())["missed"]) == 1
wf.schedule.enabled = False
storage.save_workflow(wf)
assert (await list_missed_runs())["missed"] == [], "paused workflow still on the review card"
@pytest.mark.asyncio
async def test_review_card_hides_a_trashed_workflows_misses():
from backend.apps.workflows.workflows import list_missed_runs
wf = storage.save_workflow(p_make())
p_queue_a_miss(wf)
wf.deleted_at = datetime.now()
storage.save_workflow(wf)
assert (await list_missed_runs())["missed"] == []
@pytest.mark.asyncio
async def test_running_a_paused_workflows_miss_starts_nothing_and_says_so():
"""It used to report started=N for runs the executor then refused, which is a lie the UI shows."""
from backend.apps.workflows.workflows import run_missed_runs
from backend.apps.workflows.models import MissedRunAction
wf = storage.save_workflow(p_make(enabled=False))
m = p_queue_a_miss(wf)
result = await run_missed_runs(MissedRunAction(ids=[m.id]))
assert result["started"] == 0, "dispatched a run for a switched-off workflow"
@@ -0,0 +1,95 @@
"""The silent provider stall (Eric's "the ones that never get picked up").
The CLI retries provider 500s itself, up to 10 attempts, backing off in tens of seconds. Nothing
reached our telemetry and nothing reached the user: the card just sat there. Both payloads below
are verbatim from live traffic on 2026-08-07 05:20-05:21, where two turns took 15.7s and >50s for
exactly this reason and the near-miss ledger recorded zero.
"""
import inspect
from typing import Any, Dict, List
from backend.apps.agents.core import flight_recorder
from backend.apps.agents.manager.run import TurnRunner
from backend.apps.agents.manager.streaming.note_provider_retry import note_provider_retry, settle_provider_retries
from backend.apps.agents.manager.streaming.state import TurnState
# Verbatim SystemMessage.__dict__ from the live 500s.
LIVE_RETRY: Dict[str, Any] = {
"subtype": "api_retry",
"data": {
"type": "system",
"subtype": "api_retry",
"attempt": 1,
"max_retries": 10,
"retry_delay_ms": 30000,
"error_status": 500,
"error": "server_error",
"session_id": "2b0f3e16-8a38-4ae4-b1e9-70da2353b5d4",
"uuid": "0cc6ec4a-6ead-4f3a-a29b-a5ceac2a4d2e",
},
}
def test_the_live_500_becomes_a_breadcrumb_with_its_status_and_backoff():
sid = "provretry01"
flight_recorder.drop_session(sid)
turn = TurnState()
note_provider_retry(sid, LIVE_RETRY, turn)
crumbs = [c for c in flight_recorder.breadcrumbs(sid) if c.get("l") == "provider-retry"]
assert len(crumbs) == 1, "the retry must leave a trace a stranger can read"
assert crumbs[0]["status"] == 500
assert crumbs[0]["delay_ms"] == 30000, "the backoff is the whole reason the user saw a long silence"
assert turn.provider_retries == 1
assert turn.provider_retry_wait_ms == 30000
flight_recorder.drop_session(sid)
def test_a_turn_that_survives_retries_lands_in_the_near_miss_ledger():
sid = "provretry02"
flight_recorder.drop_session(sid)
sent: List[Dict[str, Any]] = []
import backend.apps.service.client as service_client
original = service_client.submit_diagnostic
service_client.submit_diagnostic = lambda payload: sent.append(payload)
try:
turn = TurnState()
note_provider_retry(sid, LIVE_RETRY, turn)
note_provider_retry(sid, LIVE_RETRY, turn)
settle_provider_retries(sid, turn, "sonnet-cc", {})
finally:
service_client.submit_diagnostic = original
assert len(sent) == 1, "one settle per turn, not one per retry"
assert sent[0]["kind"] == "recovered"
assert sent[0]["subkind"] == "provider-retry"
assert sent[0]["attempts"] == 2, "the denominator has to count every retry the turn rode out"
flight_recorder.drop_session(sid)
def test_a_turn_with_no_retries_stays_out_of_the_ledger():
sid = "provretry03"
sent: List[Dict[str, Any]] = []
import backend.apps.service.client as service_client
original = service_client.submit_diagnostic
service_client.submit_diagnostic = lambda payload: sent.append(payload)
try:
settle_provider_retries(sid, TurnState(), "sonnet-cc", {})
finally:
service_client.submit_diagnostic = original
assert sent == [], "a clean turn must not inflate the near-miss count"
def test_a_malformed_retry_event_never_breaks_the_turn():
sid = "provretry04"
flight_recorder.drop_session(sid)
turn = TurnState()
for junk in ("not a dict", {"subtype": "api_retry"}, {"subtype": "api_retry", "data": None}):
note_provider_retry(sid, junk, turn)
flight_recorder.drop_session(sid)
def test_the_turn_loop_actually_dispatches_api_retry():
src = inspect.getsource(TurnRunner)
assert 'p_subtype == "api_retry"' in src, "the SystemMessage branch must recognise the retry subtype"
assert "note_provider_retry(session_id, raw, turn)" in src
assert "settle_provider_retries(session_id, turn, resolved_model, self.sessions)" in src
+187
View File
@@ -0,0 +1,187 @@
"""A reaper that misjudges ownership kills working apps, so ownership is the thing under test.
The first draft matched on the workspace path alone; a dry run on a live machine showed it would
have killed 14 running app runtimes whose backend was up. These pin the discriminator.
"""
import os
from unittest.mock import patch
from backend.apps.outputs import reap_ghost_runtimes as mod
def p_ps(pid_args: str, pid_ppid: str):
"""Fake `ps` with two different outputs depending on the requested format."""
class R:
def __init__(self, out): self.stdout = out
def run(cmd, **kw):
return R(pid_args if "args=" in cmd[-1] or "pid=,args=" in " ".join(cmd) else pid_ppid)
return run
def test_runtime_owned_by_a_live_backend_is_never_reaped():
ws = os.path.abspath(mod.WORKSPACE_DIR)
args = f"100 python -m uvicorn backend.main:app\n200 node {ws}/app/vite\n"
ppid = "100 1\n200 100\n"
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
assert mod.find_ghost_runtime_pids() == []
def test_runtime_whose_backend_died_is_reaped():
ws = os.path.abspath(mod.WORKSPACE_DIR)
# The CALLER is always a live backend (this code runs inside one), so the scan must show at
# least ourselves; a fixture with "no uvicorn anywhere" models a world that cannot exist, and
# the fail-closed guard rightly refuses to reap in it.
args = f"50 python -m uvicorn backend.main:app\n200 node {ws}/app/vite\n"
ppid = "50 1\n200 1\n" # ghost reparented to init, NOT under the backend
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
assert mod.find_ghost_runtime_pids() == [200]
def test_ownership_is_inherited_through_the_bash_wrapper():
"""run.sh sits between the backend and vite; the walk must climb past it."""
ws = os.path.abspath(mod.WORKSPACE_DIR)
args = f"100 python -m uvicorn backend.main:app\n150 bash run.sh\n200 node {ws}/app/vite\n"
ppid = "100 1\n150 100\n200 150\n"
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
assert mod.find_ghost_runtime_pids() == []
def test_unrelated_processes_are_never_matched():
"""A user's own npm dev server elsewhere on the machine must be invisible to this."""
args = "300 node /Users/someone/other-project/vite\n"
ppid = "300 1\n"
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
assert mod.find_ghost_runtime_pids() == []
def test_a_broken_ps_reaps_nothing_rather_than_guessing():
def boom(*a, **k):
raise OSError("ps unavailable")
with patch.object(mod.subprocess, "run", side_effect=boom):
assert mod.find_ghost_runtime_pids() == []
assert mod.reap_ghost_runtimes() == 0
def test_stale_idle_runtimes_are_stopped_after_the_ttl(monkeypatch):
"""Frozen-idle is 0% CPU but holds memory and a port forever; past the TTL it must actually die."""
import asyncio
from backend.apps.outputs import runtime as rt_mod
class P_FakeRuntime:
def __init__(self) -> None:
self.process = None
self.running = True
self.stopped = False
async def stop(self) -> None:
self.stopped = True
monkeypatch.setattr(rt_mod, "resume_process_tree", lambda proc: None)
m = rt_mod.AppRuntimeManager()
old, fresh = P_FakeRuntime(), P_FakeRuntime()
m.idle_lru["ws-old:1"] = old
m.idle_lru["ws-new:1"] = fresh
import time as p_time
m.p_idle_since["ws-old:1"] = p_time.monotonic() - 3600
m.p_idle_since["ws-new:1"] = p_time.monotonic()
reaped = asyncio.run(m.reap_stale_idle(ttl_s=900))
assert reaped == 1
assert old.stopped and not fresh.stopped
assert "ws-old:1" not in m.idle_lru and "ws-new:1" in m.idle_lru
assert "ws-old:1" not in m.p_idle_since
def test_a_failed_backend_scan_reaps_nothing(monkeypatch):
"""We ARE a backend, so 'no live backends found' means the scan failed, not that everything is a
ghost; without this, one slow `ps` under load turned the 10-minute sweep into a kill-all."""
from backend.apps.outputs import reap_ghost_runtimes as rg
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: set())
monkeypatch.setattr(rg, "p_ppid_map", lambda: {200: 1})
class P_Out:
stdout = f"200 node {rg.os.path.abspath(rg.WORKSPACE_DIR)}/ws-x/run\n"
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
assert rg.find_ghost_runtime_pids() == []
def test_indeterminate_ancestry_is_never_a_ghost(monkeypatch):
"""A process missing from the ppid snapshot (spawned between the two ps calls) must be skipped,
not killed: mid-session, that is a runtime that just started."""
from backend.apps.outputs import reap_ghost_runtimes as rg
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
monkeypatch.setattr(rg, "p_ppid_map", lambda: {300: 1})
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
class P_Out:
stdout = f"300 node {ws}/ws-a/run\n999 node {ws}/ws-b/run\n"
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
ghosts = rg.find_ghost_runtime_pids()
assert 999 not in ghosts, "pid absent from the ppid map was treated as a ghost"
assert ghosts == [300], "a genuinely orphaned pid (walks to init, no backend) still reaps"
def test_ghost_matched_despite_path_case_difference(monkeypatch):
"""macOS is case-insensitive, so a process can report .../openswarm/... while our resolved path
is .../OpenSwarm/... (same folder). A case-sensitive match missed the ghost entirely; found live
on a packaged smoke where 8 orphans survived a reap that logged 0."""
from backend.apps.outputs import reap_ghost_runtimes as rg
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
lower_ws = ws.replace("OpenSwarm", "openswarm").replace("Openswarm", "openswarm")
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
monkeypatch.setattr(rg, "p_ppid_map", lambda: {700: 1}) # orphan reparented to init
class P_Out:
stdout = f"50 python -m uvicorn backend.main:app\n700 bash {lower_ws}/ws-x/backend/run.sh\n"
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
assert rg.find_ghost_runtime_pids() == [700], "a case-different path must still match the ghost"
def test_an_orphan_is_found_by_its_CWD_when_argv_hides_the_path(monkeypatch):
"""An app's backend runs as `python -u backend.py` with cwd=<workspace>, so the workspace path is
nowhere in its argv. An argv-only scan was structurally blind to exactly the ghost we most want
dead; found live on a packaged build where orphaned app backends survived every reap."""
from backend.apps.outputs import reap_ghost_runtimes as rg
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
monkeypatch.setattr(rg, "p_ppid_map", lambda: {900: 1})
monkeypatch.setattr(rg, "p_cwd_map", lambda needle: {900: ws + "/app-7"})
class P_Out:
stdout = "50 python -m uvicorn backend.main:app\n900 python3 -u backend.py\n"
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
assert rg.find_ghost_runtime_pids() == [900], "a cwd-only orphan must still be reaped"
def test_a_cwd_orphan_owned_by_a_live_backend_is_spared(monkeypatch):
"""The cwd path must obey the same ancestry rule: a working app is not a ghost."""
from backend.apps.outputs import reap_ghost_runtimes as rg
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
monkeypatch.setattr(rg, "p_ppid_map", lambda: {900: 50, 50: 1})
monkeypatch.setattr(rg, "p_cwd_map", lambda needle: {900: ws + "/app-7"})
class P_Out:
stdout = "50 python -m uvicorn backend.main:app\n900 python3 -u backend.py\n"
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
assert rg.find_ghost_runtime_pids() == [], "a live backend's own app runtime must never be killed"
def test_a_frozen_ghost_is_thawed_before_being_signalled(monkeypatch):
"""Idle app runtimes are parked with SIGSTOP, and a STOPPED process never handles SIGTERM: it
queues it and lives forever. Found live as a frozen `bash run.sh` that had survived every reap
for 2 days 21 hours. CONT must precede TERM, and anything still breathing gets KILL."""
import signal as sg
from backend.apps.outputs import reap_ghost_runtimes as rg
monkeypatch.setattr(rg, "find_ghost_runtime_pids", lambda: [4242])
monkeypatch.setattr(rg, "REAP_GRACE_SECONDS", 0.0)
monkeypatch.setattr(rg, "kill_descendant_tree", lambda pid, sig: None)
sent = []
alive = {4242: True}
def p_kill(pid, sig):
if sig == 0:
if not alive.get(pid): raise ProcessLookupError()
return
sent.append(sig)
if sig == sg.SIGKILL: alive[pid] = False
monkeypatch.setattr(rg.os, "kill", p_kill)
rg.reap_ghost_runtimes()
assert sg.SIGCONT in sent, "a stopped ghost never receives TERM unless it is thawed first"
assert sent.index(sg.SIGCONT) < sent.index(sg.SIGTERM), "CONT must come before TERM"
assert sg.SIGKILL in sent, "a ghost that ignored TERM must be escalated, not left running"
@@ -0,0 +1,42 @@
"""The router-down envelope must NAME the cause.
Found by the forced-failure battery on 2026-08-07: holding port 20128 with a dead socket so 9Router
could not rebind produced a real terminal failure whose envelope read `subkind=unclassified`, with a
breadcrumb trail that simply stopped after the prep phases. The cause was sitting in plain text in
`error_preview` ("9Router is not running; cannot use sonnet-cc") but nothing could be queried on it.
"""
import inspect
from backend.apps.agents.core.error_classify import is_router_unreachable_error
from backend.apps.agents.core.is_router_unavailable_error import is_router_unavailable_error
from backend.apps.agents.manager.run import handle_run_error
def test_the_verbatim_live_refusal_is_classified():
# Exact string raised by configure_provider_env and captured in the battery envelope.
assert is_router_unavailable_error(
"9Router is not running; cannot use sonnet-cc. Install Node.js and restart the app, "
"or switch to a model with a direct API key."
)
def test_the_mid_turn_unreachable_shapes_still_qualify():
for text in ("API Error: Unable to connect. Is the computer able to access the url?",
"fetch failed", "connect ECONNREFUSED 127.0.0.1:20128"):
assert is_router_unavailable_error(text), text
assert is_router_unreachable_error(text), "the narrower resume-path check must keep matching too"
def test_unrelated_failures_are_not_swallowed():
for text in ("", " ", "Prompt is too long", "Invalid API key",
"The router of the story is that nothing broke"):
assert not is_router_unavailable_error(text), text
def test_the_rung_sits_above_unclassified():
src = inspect.getsource(handle_run_error)
assert "is_router_unavailable_error" in src
assert src.index("is_router_unavailable_error") < src.index('p_report_model_error("unclassified"'), \
"a router death must be named before the catch-all claims it"
assert 'p_report_model_error("router_unavailable"' in src
@@ -93,8 +93,13 @@ def test_paused_scheduled_run_halts_at_next_step(make_wf, fake_agent_manager, mo
assert fake_agent_manager.sent_messages == ["step1"]
def test_pause_does_not_halt_a_manual_run(make_wf, fake_agent_manager, monkeypatch):
"""Pausing the SCHEDULE must not kill a manual Run Now that's in flight."""
def test_pause_halts_even_a_manual_run(make_wf, fake_agent_manager, monkeypatch):
"""Off means off on every path, including a manual Run Now already in flight.
This used to assert the opposite, that pausing the schedule left a manual run alone. Eric's call
is that a workflow switched off must not keep running by any route, so the switch now stops the
run at the next step boundary whatever started it.
"""
from backend.apps.workflows import storage, executor
wf = p_three_step_wf(make_wf)
storage.save_workflow(wf)
@@ -106,8 +111,8 @@ def test_pause_does_not_halt_a_manual_run(make_wf, fake_agent_manager, monkeypat
p_mutate_after_first_step(monkeypatch, fake_agent_manager, pause)
run = p_run(executor.execute(wf, triggered_by="manual"))
assert run.status == "success"
assert fake_agent_manager.sent_messages == ["step1", "step2", "step3"] # all ran
assert run.status != "success"
assert fake_agent_manager.sent_messages == ["step1"] # stopped at the boundary after the switch
def test_stop_active_run_signals_and_returns_session(make_wf, fake_agent_manager, monkeypatch):
@@ -0,0 +1,86 @@
"""Editing a workflow step must finish even when the aux label lane is dead.
`p_relabel_steps` is awaited INSIDE the PATCH request, and its aux call had no timeout at all (the
Anthropic SDK's own stream ceiling is minutes). A stalled lane therefore held the whole edit open:
the agent's EditWorkflowStep tool never returned and the editor just span, which is the
"agent edits a workflow and the app bricks" report. The labels are decoration with deterministic
fallbacks, so a dead lane must cost a nicer label, never the edit.
"""
import asyncio
import pytest
from backend.apps.workflows import workflows as wf_mod
from backend.apps.workflows.models import Workflow, WorkflowStep
def p_wf() -> Workflow:
return Workflow(title="Untitled workflow", auto_named=True, steps=[WorkflowStep(text="do the first thing")])
def test_a_hung_aux_lane_cannot_hold_the_edit_open(monkeypatch):
async def never_returns(*_a, **_k):
await asyncio.sleep(3600)
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", never_returns)
monkeypatch.setattr(wf_mod, "AUX_LABEL_TIMEOUT_S", 0.05)
wf = p_wf()
steps = [WorkflowStep(text="a brand new instruction")]
async def run():
await asyncio.wait_for(
wf_mod.p_relabel_steps(wf, [], steps, None),
timeout=5.0, # the assertion IS that we return well inside this
)
asyncio.run(run())
def test_a_hung_lane_still_leaves_a_usable_label_and_title(monkeypatch):
"""Falling through to the deterministic path matters: the old code returned early on any aux
failure, which left the step showing its raw prompt as its own title."""
async def never_returns(*_a, **_k):
await asyncio.sleep(3600)
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", never_returns)
monkeypatch.setattr(wf_mod, "AUX_LABEL_TIMEOUT_S", 0.05)
wf = p_wf()
steps = [WorkflowStep(text="summarize my unread email and text me the digest")]
asyncio.run(wf_mod.p_relabel_steps(wf, [], steps, None))
assert steps[0].label, "a dead aux lane must still leave a label"
assert steps[0].label != steps[0].text, "the label must not be the raw prompt"
assert wf.title not in wf_mod._PLACEHOLDER_TITLES, "auto-name must fall back, not stay Untitled"
def test_an_erroring_aux_lane_behaves_the_same_as_a_hung_one(monkeypatch):
async def blows_up(*_a, **_k):
raise RuntimeError("provider 500")
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", blows_up)
wf = p_wf()
steps = [WorkflowStep(text="pull the calendar and write a brief")]
asyncio.run(wf_mod.p_relabel_steps(wf, [], steps, None))
assert steps[0].label
assert wf.title not in wf_mod._PLACEHOLDER_TITLES
def test_a_healthy_lane_still_wins(monkeypatch):
"""The timeout must not quietly replace good aux output with the fallback."""
async def good(*_a, **_k):
return "Summarize Daily Email", "Reads unread mail and texts a digest.", ["Summarize unread email"]
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", good)
wf = p_wf()
steps = [WorkflowStep(text="summarize my unread email")]
asyncio.run(wf_mod.p_relabel_steps(wf, [], steps, None))
assert wf.title == "Summarize Daily Email"
assert steps[0].label == "Summarize unread email"
@pytest.mark.parametrize("budget", [20.0])
def test_the_ceiling_is_bounded_and_sane(budget):
assert 0 < wf_mod.AUX_LABEL_TIMEOUT_S <= budget
+29
View File
@@ -0,0 +1,29 @@
"""TestWorkflow must return the RESULT, not a promise. The old handler returned as soon as the Test
Agent spawned, so the model ended its turn and a human had to re-ping it to continue: the exact
"it just stops" annoyance."""
import inspect
from backend.apps.agents import schedule_mcp_server as srv
def test_the_handler_waits_for_the_result_instead_of_returning_a_promise():
src = inspect.getsource(srv.handle_test_workflow)
assert "while True:" in src and "last_progress_at" in src, (
"the tool must block, waiting on PROGRESS (transcript growth resets the clock)")
assert "test-transcript" in src, "and read the transcript itself, not delegate that to the model"
assert "Test finished" in src
def test_a_long_test_returns_honestly_instead_of_hanging_the_turn():
src = inspect.getsource(srv.handle_test_workflow)
assert "went quiet" in src, "the give-up message must say the run stalled, with the partial attached"
assert srv.TEST_IDLE_S <= 300, "silence must end the wait in bounded time"
assert srv.TEST_MAX_S <= 3600, "even a chatty run has an absolute ceiling"
assert srv.TEST_POLL_S >= 1
def test_the_description_tells_the_model_to_keep_going():
desc = next(t["description"] for t in srv.TOOLS if t["name"] == "TestWorkflow")
assert "WAIT for the result" in desc and "same turn" in desc
assert "without stopping to ask the user" in desc
@@ -0,0 +1,102 @@
"""No stampede: a due fire beyond the global cap queues, and gives up honestly, never silently.
Every workflow run is a full agent, and a working agent's browsers and apps are exempt from the
renderer budget on purpose (sleeping them blinds the agent). So the ONLY bound on total pressure is
how many runs exist at once, and before this the scheduler would happily start one agent per due
workflow: thirty accumulated workflows drifting into schedule alignment meant thirty agents.
"""
import asyncio
import pytest
from backend.apps.workflows import executor, storage
from backend.apps.workflows.models import Workflow, WorkflowStep
def p_make() -> Workflow:
return Workflow(
title="queued",
steps=[WorkflowStep(text="do it")],
schedule={"enabled": True, "kind": "interval", "every_minutes": 45},
)
@pytest.fixture(autouse=True)
def p_isolated(tmp_path, monkeypatch):
monkeypatch.setattr(storage, "DATA_DIR", str(tmp_path / "workflows"))
monkeypatch.setattr(storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
monkeypatch.setattr(storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
monkeypatch.setattr(storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
monkeypatch.setattr(storage, "_workflow_cache", {})
monkeypatch.setattr(storage, "_runs_cache", {})
monkeypatch.setattr(storage, "_missed_cache", [])
monkeypatch.setattr(storage, "p_deleted_ids", set(), raising=False)
monkeypatch.setattr(storage, "_cache_loaded", True)
monkeypatch.setattr(executor, "_running", {})
yield
def test_at_the_cap_a_fire_waits_then_skips_with_an_honest_row(monkeypatch):
monkeypatch.setattr(executor, "MAX_CONCURRENT_RUNS", 2)
monkeypatch.setattr(executor, "ADMISSION_WAIT_S", 0.05)
monkeypatch.setattr(executor, "ADMISSION_POLL_S", 0.01)
monkeypatch.setattr(executor, "_running", {"other-a": "r1", "other-b": "r2"})
wf = storage.save_workflow(p_make())
run = asyncio.run(executor.execute(wf, triggered_by="schedule"))
assert run.status == "skipped"
assert "already running" in (run.error or "")
rows = storage.list_runs(wf.id)
assert len(rows) == 1 and rows[0].status == "skipped", "the give-up must be visible in History"
def test_a_freed_slot_lets_the_queued_fire_proceed(monkeypatch):
"""The wait is a queue, not a rejection: the moment a slot frees, the run goes ahead and reaches
the normal guard path (here: refused as paused, which proves it got past admission)."""
monkeypatch.setattr(executor, "MAX_CONCURRENT_RUNS", 1)
monkeypatch.setattr(executor, "ADMISSION_WAIT_S", 5.0)
monkeypatch.setattr(executor, "ADMISSION_POLL_S", 0.01)
monkeypatch.setattr(executor, "_running", {"other-a": "r1"})
wf = storage.save_workflow(p_make())
live = storage.get_workflow(wf.id)
live.schedule.enabled = False
storage.save_workflow(live)
async def scenario():
async def free_slot_soon():
await asyncio.sleep(0.05)
executor._running.clear()
asyncio.ensure_future(free_slot_soon())
return await executor.execute(wf, triggered_by="schedule")
run = asyncio.run(scenario())
assert run.status == "skipped"
assert "paused" in (run.error or "").lower(), (
f"expected the post-wait guard to refuse the paused workflow, got {run.error!r}"
)
def test_the_guard_runs_on_state_AFTER_the_wait(monkeypatch):
"""A workflow deleted while queueing must be refused, not run: admission sits before the
off-means-off guard precisely so the guard sees post-wait truth."""
monkeypatch.setattr(executor, "MAX_CONCURRENT_RUNS", 1)
monkeypatch.setattr(executor, "ADMISSION_WAIT_S", 5.0)
monkeypatch.setattr(executor, "ADMISSION_POLL_S", 0.01)
monkeypatch.setattr(executor, "_running", {"other-a": "r1"})
wf = storage.save_workflow(p_make())
async def scenario():
async def delete_then_free():
await asyncio.sleep(0.05)
storage.delete_workflow(wf.id)
executor._running.clear()
asyncio.ensure_future(delete_then_free())
return await executor.execute(wf, triggered_by="schedule")
run = asyncio.run(scenario())
assert run.status == "skipped"
assert "delete" in (run.error or "").lower()
+131
View File
@@ -0,0 +1,131 @@
'use strict';
// Reads the CAUSE out of Crashpad minidumps so a native crash stops being an invisible file.
//
// Before this, a main-process SIGSEGV ran none of our JS (uncaughtException is JS-only,
// child-process-gone is children-only), so the app vanished and left a .dmp nobody read. The boot
// beacon shipped a lifetime cumulative COUNT, which cannot answer what crashed, when, or during
// what. This parses the minidump header itself, which is a documented binary format, and reports
// one record per NEW dump since the last boot.
//
// Deliberately parses only the header + stream directory + exception/misc streams. That is enough
// for cause and timing, costs a few KB of reads, and cannot be confused by a truncated tail.
const fs = require('fs');
const path = require('path');
const MINIDUMP_MAGIC = 0x504d444d; // 'MDMP'
const STREAM_EXCEPTION = 6;
const STREAM_SYSTEM_INFO = 7;
const STREAM_MISC_INFO = 15;
// Mach exception codes; the signal is what a user-facing report should say.
const MAC_EXC = {
1: 'EXC_BAD_ACCESS (SIGSEGV/SIGBUS)',
2: 'EXC_BAD_INSTRUCTION (SIGILL)',
3: 'EXC_ARITHMETIC',
5: 'EXC_BREAKPOINT (SIGTRAP)',
6: 'EXC_SOFTWARE',
10: 'EXC_CRASH (SIGABRT)',
};
function p_readStreams(fd, size) {
const head = Buffer.alloc(32);
fs.readSync(fd, head, 0, 32, 0);
if (head.readUInt32LE(0) !== MINIDUMP_MAGIC) return null;
const streamCount = head.readUInt32LE(8);
const streamRva = head.readUInt32LE(12);
const timeDateStamp = head.readUInt32LE(20);
if (streamCount > 4096 || streamRva + streamCount * 12 > size) return null;
const dir = Buffer.alloc(streamCount * 12);
fs.readSync(fd, dir, 0, dir.length, streamRva);
const streams = new Map();
for (let i = 0; i < streamCount; i++) {
const off = i * 12;
streams.set(dir.readUInt32LE(off), {
size: dir.readUInt32LE(off + 4),
rva: dir.readUInt32LE(off + 8),
});
}
return { streams, timeDateStamp };
}
function p_readExceptionStream(fd, s, fileSize) {
if (!s || s.rva + 24 > fileSize) return null;
const buf = Buffer.alloc(Math.min(s.size, 168));
fs.readSync(fd, buf, 0, buf.length, s.rva);
// MINIDUMP_EXCEPTION_STREAM: ThreadId(4) __align(4) then MINIDUMP_EXCEPTION
const threadId = buf.readUInt32LE(0);
const code = buf.readUInt32LE(8);
const flags = buf.readUInt32LE(12);
// ExceptionAddress is 8 bytes at offset 24 within the exception record
let address = 0n;
try { address = buf.readBigUInt64LE(24); } catch (_) { address = 0n; }
return { threadId, code, flags, address: '0x' + address.toString(16) };
}
/** Parse one minidump for cause + timing. Returns null if the file is not a readable minidump. */
function readDump(file) {
let fd = null;
try {
const st = fs.statSync(file);
fd = fs.openSync(file, 'r');
const parsed = p_readStreams(fd, st.size);
if (!parsed) return null;
const exc = p_readExceptionStream(fd, parsed.streams.get(STREAM_EXCEPTION), st.size);
const crashedAt = parsed.timeDateStamp ? new Date(parsed.timeDateStamp * 1000).toISOString() : null;
return {
file: path.basename(file),
bytes: st.size,
crashed_at: crashedAt || new Date(st.mtimeMs).toISOString(),
mtime_ms: st.mtimeMs,
has_exception_stream: !!exc,
exception_code: exc ? exc.code : null,
exception_name: exc ? (MAC_EXC[exc.code] || `code ${exc.code}`) : null,
exception_address: exc ? exc.address : null,
faulting_thread_id: exc ? exc.threadId : null,
has_system_info: parsed.streams.has(STREAM_SYSTEM_INFO),
has_misc_info: parsed.streams.has(STREAM_MISC_INFO),
};
} catch (_) {
return null;
} finally {
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
}
}
/** Every .dmp under a Crashpad dir, newest first. */
function listDumps(crashpadDir) {
const out = [];
const walk = (d) => {
let entries = [];
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else if (/\.dmp$/i.test(e.name)) out.push(p);
}
};
walk(crashpadDir);
return out.sort((a, b) => {
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch (_) { return 0; }
});
}
/**
* Dumps written since `sinceMs`, parsed. `sinceMs` is the previous boot's watermark, so a relaunch
* reports only what actually happened while the user was away, not the lifetime pile.
*/
function newDumpsSince(crashpadDir, sinceMs, limit = 10) {
const rows = [];
for (const f of listDumps(crashpadDir)) {
let mt = 0;
try { mt = fs.statSync(f).mtimeMs; } catch (_) { continue; }
if (mt <= sinceMs) break;
const parsed = readDump(f);
if (parsed) rows.push(parsed);
if (rows.length >= limit) break;
}
return rows;
}
module.exports = { readDump, listDumps, newDumpsSince, MINIDUMP_MAGIC };
+110
View File
@@ -0,0 +1,110 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { readDump, listDumps, newDumpsSince, MINIDUMP_MAGIC } = require('./crashDumpScan');
// Builds a minimal but REAL minidump: header + stream directory + exception stream. Synthetic
// fixtures are the only way to assert the unhappy paths (truncated, wrong magic) deterministically.
function makeDump(file, { code = 1, address = 0x10n, threadId = 7, ts = 1754400000 } = {}) {
const excRva = 32 + 12; // header + one directory entry
const exc = Buffer.alloc(168);
exc.writeUInt32LE(threadId, 0);
exc.writeUInt32LE(code, 8);
exc.writeUInt32LE(0, 12);
exc.writeBigUInt64LE(address, 24);
const header = Buffer.alloc(32);
header.writeUInt32LE(MINIDUMP_MAGIC, 0);
header.writeUInt32LE(0xa793, 4);
header.writeUInt32LE(1, 8); // stream count
header.writeUInt32LE(32, 12); // stream directory rva
header.writeUInt32LE(ts, 20);
const dir = Buffer.alloc(12);
dir.writeUInt32LE(6, 0); // ExceptionStream
dir.writeUInt32LE(exc.length, 4);
dir.writeUInt32LE(excRva, 8);
fs.writeFileSync(file, Buffer.concat([header, dir, exc]));
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'crashscan-'));
let passed = 0;
function t(name, fn) {
try { fn(); passed++; console.log(' ok ' + name); }
catch (e) { console.log(' FAIL ' + name + ': ' + e.message); process.exitCode = 1; }
}
t('reads exception code, address and thread from a real header', () => {
const f = path.join(tmp, 'a.dmp');
makeDump(f, { code: 1, address: 0x10n, threadId: 42 });
const r = readDump(f);
assert.strictEqual(r.exception_code, 1);
assert.strictEqual(r.exception_address, '0x10');
assert.strictEqual(r.faulting_thread_id, 42);
assert.match(r.exception_name, /EXC_BAD_ACCESS/);
});
t('a null-pointer crash reports address 0x0, not a missing field', () => {
const f = path.join(tmp, 'null.dmp');
makeDump(f, { code: 1, address: 0x0n });
assert.strictEqual(readDump(f).exception_address, '0x0');
});
t('crash time comes from the dump header, not the file mtime', () => {
const f = path.join(tmp, 'ts.dmp');
makeDump(f, { ts: 1700000000 });
assert.strictEqual(readDump(f).crashed_at, new Date(1700000000 * 1000).toISOString());
});
t('a non-minidump file is refused rather than half-parsed', () => {
const f = path.join(tmp, 'junk.dmp');
fs.writeFileSync(f, Buffer.from('not a minidump at all, really'));
assert.strictEqual(readDump(f), null);
});
t('a truncated dump does not throw', () => {
const f = path.join(tmp, 'trunc.dmp');
makeDump(f);
const buf = fs.readFileSync(f).subarray(0, 20);
fs.writeFileSync(f, buf);
assert.strictEqual(readDump(f), null);
});
t('a missing file is refused', () => {
assert.strictEqual(readDump(path.join(tmp, 'nope.dmp')), null);
});
t('newDumpsSince reports only dumps newer than the watermark', () => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-'));
const older = path.join(d, 'old.dmp');
const newer = path.join(d, 'new.dmp');
makeDump(older); makeDump(newer);
const t0 = Date.now() - 60000;
fs.utimesSync(older, new Date(t0 - 60000), new Date(t0 - 60000));
fs.utimesSync(newer, new Date(), new Date());
const rows = newDumpsSince(d, t0);
assert.strictEqual(rows.length, 1, 'only the dump after the watermark counts');
assert.strictEqual(rows[0].file, 'new.dmp');
});
t('a watermark in the future reports nothing (no false crash storm)', () => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp2-'));
makeDump(path.join(d, 'x.dmp'));
assert.strictEqual(newDumpsSince(d, Date.now() + 600000).length, 0);
});
t('listDumps walks nested Crashpad layout (completed/, pending/)', () => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp3-'));
fs.mkdirSync(path.join(d, 'completed'), { recursive: true });
makeDump(path.join(d, 'completed', 'deep.dmp'));
assert.strictEqual(listDumps(d).length, 1);
});
t('a missing Crashpad dir is empty, not a throw', () => {
assert.deepStrictEqual(listDumps(path.join(tmp, 'does-not-exist')), []);
});
console.log(`\n${passed} passed`);
+65 -1
View File
@@ -312,6 +312,22 @@ function countCrashDumps() {
} catch (_) { return -1; }
}
// A native main-process crash runs none of our JS, so the app just vanishes and leaves a .dmp
// nobody reads. On the next boot we read the CAUSE out of any dump written since last time.
function newCrashDumps() {
try {
const scan = require('./crashDumpScan');
const base = path.join(app.getPath('userData'), 'Crashpad');
const markFile = path.join(app.getPath('userData'), 'crash-scan.json');
let since = 0;
try { since = JSON.parse(fs.readFileSync(markFile, 'utf8')).last_scan_ms || 0; } catch (_) { since = 0; }
// First run has no watermark; reporting the whole historical pile would look like a crash storm.
const rows = since ? scan.newDumpsSince(base, since, 5) : [];
try { fs.writeFileSync(markFile, JSON.stringify({ last_scan_ms: Date.now() })); } catch (_) {}
return rows;
} catch (_) { return []; }
}
// Fleet self-report: POST a compact boot outcome to the LOCAL backend, which forwards it via the existing service client (opt-out honored). No PII. Fire-and-forget, guarded.
function sendBootBeacon() {
try {
@@ -323,7 +339,7 @@ function sendBootBeacon() {
props: {
sha: bi.shortSha, channel: bi.channel, version: app.getVersion(),
os: process.platform, arch: process.arch,
perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(),
perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(), new_crashes: newCrashDumps(),
},
});
const req = http.request({
@@ -485,6 +501,8 @@ app.commandLine.appendSwitch('disable-gpu-process-crash-limit');
let mainWindow = null;
let backendProcess = null;
let backendRespawns = 0;
const MAX_BACKEND_RESPAWNS = 5;
let backendPort = null;
let cachedUpdateStatus = { status: 'idle', info: null, error: null };
let isInstallingUpdate = false;
@@ -1183,10 +1201,25 @@ async function startBackend() {
`document.title = "OpenSwarm (backend crashed)";`
);
}
// Respawn a backend that died UNEXPECTEDLY. Without this a crash or SIGKILL left the app a dead
// shell whose only recovery was a full relaunch, and every app-runtime it had spawned became a
// permanent orphan (the boot reaper only runs at launch, which never came). Skip during an
// orderly quit, and back off so a backend that instantly dies can't spin a respawn loop.
backendProcess = null;
if (quitInitiated || isInstallingUpdate) return;
backendRespawns = (backendRespawns || 0) + 1;
if (backendRespawns > MAX_BACKEND_RESPAWNS) {
console.error(`[electron] backend died ${backendRespawns} times; giving up respawn`);
return;
}
const delay = Math.min(1000 * backendRespawns, 8000);
console.warn(`[electron] backend died unexpectedly; respawning in ${delay}ms (attempt ${backendRespawns})`);
setTimeout(() => { startBackend().catch((e) => console.error('[electron] backend respawn failed', e)); }, delay);
});
emitSplashStatus('Starting backend…');
await waitForBackend(backendPort, { process: backendProcess });
backendRespawns = 0; // healthy boot resets the budget
perfMark('backend-http-ready');
console.log(`Backend ready on port ${backendPort}`);
maybeCommitPreflightCache();
@@ -1475,6 +1508,24 @@ function createWindow() {
mainWindow.webContents.on('preload-error', (_event, preloadPath, err) => {
console.error('[diag][main:preload-error]', preloadPath, err && err.stack || err);
});
// Frozen-but-not-crashed is the silent class no crash log sees; Chromium's own unresponsive
// signal costs nothing and the report fires from the renderer AFTER it recovers.
let wedgeStartedAt = 0;
try {
const { startMemorySensor } = require('./memorySensor');
startMemorySensor(app, () => mainWindow);
} catch (e) { console.warn('[diag] memory sensor unavailable:', e && e.message); }
mainWindow.webContents.on('unresponsive', () => {
wedgeStartedAt = Date.now();
console.error('[diag][main] renderer unresponsive');
});
mainWindow.webContents.on('responsive', () => {
if (!wedgeStartedAt) return;
const ms = Date.now() - wedgeStartedAt;
wedgeStartedAt = 0;
console.error('[diag][main] renderer responsive again after', ms, 'ms');
try { mainWindow.webContents.send('diag:wedge', { ms }); } catch (_) { /* window mid-teardown */ }
});
mainWindow.webContents.on('render-process-gone', (_event, details) => {
const reason = details && details.reason;
if (reason === 'clean-exit') return;
@@ -1713,6 +1764,13 @@ async function clearStaleFrontendCache() {
function setupAutoUpdater() {
if (!autoUpdater) return;
// Escape hatch for locally-built packaged smokes: an unpublished build otherwise downloads the
// published release and silently DOWNGRADES on quit (the draft self-revert footgun, seen live on
// 1.7.0), which both ruins the test and pollutes its memory numbers with ShipIt churn.
if (process.env.OPENSWARM_NO_UPDATE === '1') {
console.log('[updater] disabled via OPENSWARM_NO_UPDATE=1 (local packaged smoke)');
return;
}
// Proactive, not post-mortem: an app running off the DMG or a Gatekeeper-translocated copy can NEVER self-update (Squirrel.Mac refuses read-only volumes, proven in the packaged smoke). Tell that cohort what to do at boot instead of after a failed check they may never click.
if (process.platform === 'darwin' && isPackaged) {
const exe = process.execPath || '';
@@ -2536,6 +2594,12 @@ app.on('web-contents-created', (_event, contents) => {
// popups are 'window' contents created with the flag OFF, so they keep the OS default.
if (isCreatingMainWindow) {
contents.on('context-menu', (_e, params) => buildAppContextMenu(contents, params));
// The dashboard IS a Figma-style canvas listening for ctrl+wheel, so it needs the very fix the
// webview branch below spells out: at the default (1,1) limits Electron drops macOS pinch instead
// of delivering it, which is the "pinch-to-zoom just stopped working" report. Widening them here
// is safe because the canvas wheel handler is passive:false and preventDefaults the zoom path, so
// Chromium never also magnifies the UI.
try { contents.setVisualZoomLevelLimits(1, 3); } catch (_) { /* older Electron */ }
}
if (contents.getType() === 'webview') {
const wcId = contents.id;
+71
View File
@@ -0,0 +1,71 @@
// Memory/compute overload sensor: the quiet death nobody reports, where RSS climbs until macOS
// kills a renderer or the fans spin up. Idle-scheduled, unref'd, and it EMITS ONLY on threshold
// crossings, so a healthy session ships nothing at all.
'use strict';
// Overridable so support can ask a user to run with a tighter cap, and so the wire is testable
// without allocating gigabytes on someone's machine.
const SAMPLE_MS = Number(process.env.OSW_MEM_SAMPLE_MS || 60_000);
// Crossed once, reported once: a leak is a trend, not a per-minute alarm.
const TOTAL_MB_CAP = Number(process.env.OSW_MEM_CAP_MB || 3000);
const GROWTH_MB_PER_MIN = Number(process.env.OSW_MEM_GROWTH_MB || 40);
const GROWTH_WINDOW = 10;
let p_timer = null;
let p_history = [];
let p_capReported = false;
let p_growthReported = false;
function totalMb(metrics) {
let kb = 0;
for (const m of metrics) kb += (m.memory && m.memory.workingSetSize) || 0;
return Math.round(kb / 1024);
}
/** Least-squares slope in MB/min over the sample window; a straight climb is the leak signature. */
function slopeMbPerMin(history) {
const n = history.length;
if (n < 4) return 0;
const meanX = (n - 1) / 2;
const meanY = history.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i += 1) {
num += (i - meanX) * (history[i] - meanY);
den += (i - meanX) * (i - meanX);
}
return den === 0 ? 0 : num / den;
}
function startMemorySensor(app, getMainWindow) {
if (p_timer) return;
p_timer = setInterval(() => {
let metrics;
try { metrics = app.getAppMetrics(); } catch (_) { return; }
const mb = totalMb(metrics);
p_history.push(mb);
if (p_history.length > GROWTH_WINDOW) p_history.shift();
const slope = slopeMbPerMin(p_history);
const send = (reason, extra) => {
const win = getMainWindow();
if (win && !win.isDestroyed()) {
try { win.webContents.send('diag:memory', { reason, total_mb: mb, procs: metrics.length, slope_mb_min: Math.round(slope), ...extra }); } catch (_) {}
}
console.error('[diag][memory]', reason, 'total_mb=' + mb, 'procs=' + metrics.length, 'slope=' + Math.round(slope));
};
if (!p_capReported && mb >= TOTAL_MB_CAP) { p_capReported = true; send('cap_crossed', {}); }
if (p_capReported && mb < TOTAL_MB_CAP * 0.8) p_capReported = false;
if (!p_growthReported && p_history.length >= GROWTH_WINDOW && slope >= GROWTH_MB_PER_MIN) {
p_growthReported = true;
send('growth_suspect', { window_min: GROWTH_WINDOW });
}
}, SAMPLE_MS);
p_timer.unref?.();
}
function stopMemorySensor() {
if (p_timer) { clearInterval(p_timer); p_timer = null; }
p_history = [];
}
module.exports = { startMemorySensor, stopMemorySensor, slopeMbPerMin, totalMb, TOTAL_MB_CAP, GROWTH_MB_PER_MIN };
+27
View File
@@ -0,0 +1,27 @@
// The leak detector's math, pinned: a flat session reports nothing, a straight climb is caught.
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { slopeMbPerMin, totalMb, GROWTH_MB_PER_MIN } = require('./memorySensor');
test('a flat memory profile has no slope, so nothing is ever reported', () => {
assert.ok(Math.abs(slopeMbPerMin([900, 905, 898, 902, 900, 903, 899, 901, 900, 902])) < 1, 'jitter is not a trend');
});
test('a steady climb is caught above the growth threshold', () => {
const climbing = Array.from({ length: 10 }, (_, i) => 800 + i * 60);
assert.ok(slopeMbPerMin(climbing) >= GROWTH_MB_PER_MIN, 'a 60MB/min climb must exceed the threshold');
});
test('a single spike is not a leak', () => {
const spike = [900, 900, 900, 900, 2000, 900, 900, 900, 900, 900];
assert.ok(slopeMbPerMin(spike) < GROWTH_MB_PER_MIN, 'one spike must not read as a trend');
});
test('too few samples never guesses', () => {
assert.equal(slopeMbPerMin([900, 2000, 3000]), 0);
});
test('totals sum every process in MB', () => {
assert.equal(totalMb([{ memory: { workingSetSize: 1024 * 500 } }, { memory: { workingSetSize: 1024 * 300 } }]), 800);
assert.equal(totalMb([{}, { memory: {} }]), 0);
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.7.4",
"version": "1.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.7.4",
"version": "1.7.5",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.7.4",
"version": "1.7.5",
"license": "AGPL-3.0-only",
"description": "OpenSwarm — AI Agent Orchestrator",
"author": "openswarm-ai",
@@ -49,7 +49,8 @@
"!python-env",
"!python-env/**",
"!build-staging",
"!build-staging/**"
"!build-staging/**",
"!**/*.test.js"
],
"icon": "build/icon.png",
"mac": {
+12
View File
@@ -152,6 +152,18 @@ contextBridge.exposeInMainWorld('openswarm', {
openApplication: (name) => ipcRenderer.invoke('open-application', name),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'),
// Threshold-crossing memory alerts (cap or leak-shaped growth); silent on a healthy session.
onMemoryAlert: (cb) => {
const listener = (_e, info) => cb(info);
ipcRenderer.on('diag:memory', listener);
return () => ipcRenderer.removeListener('diag:memory', listener);
},
// Fires after the renderer RECOVERS from a Chromium-detected freeze, with how long it was wedged.
onWedge: (cb) => {
const listener = (_e, info) => cb(info);
ipcRenderer.on('diag:wedge', listener);
return () => ipcRenderer.removeListener('diag:wedge', listener);
},
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
+38 -2
View File
@@ -133,6 +133,7 @@ function installVoiceHotkey(getMainWindow) {
// ---- fn/Globe primary tier (macOS): the native watcher, since no JS tap can see keycode 63 ----
let fnProc = null;
let quitReaperWired = false;
const startFnWatcher = () => {
if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) return;
resolveFnWatcherBinary((bin) => {
@@ -141,7 +142,21 @@ function installVoiceHotkey(getMainWindow) {
startFnWatcherWith(bin);
});
};
// Kill fn-watchers left by a previous OpenSwarm that died badly. will-quit is the ONLY thing that
// reaps ours, and it never runs on a crash or a force-quit, so each bad exit strands a process
// holding a GLOBAL keyboard tap forever (one was found alive after 2h35m). They accumulate, and
// every extra one re-sends fn, so dictation double-toggles. We are a single-instance app about to
// spawn our own, which makes this the one moment any other fn-watcher is provably not ours.
const sweepStrayFnWatchers = (bin) => {
try {
const out = spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8', timeout: 4000 });
for (const pid of strayFnWatcherPids(String((out && out.stdout) || ''), bin, process.pid)) {
try { process.kill(pid, 'SIGKILL'); console.log('[voice] reaped stray fn watcher', pid); } catch (_) {}
}
} catch (_) { /* a machine where ps is restricted must still arm the watcher */ }
};
const startFnWatcherWith = (bin) => {
sweepStrayFnWatchers(bin);
try {
fnProc = spawn(bin, [], { stdio: ['ignore', 'pipe', 'ignore'] });
} catch (e) {
@@ -174,7 +189,10 @@ function installVoiceHotkey(getMainWindow) {
fnProven = false;
registerVoiceShortcut();
});
app.on('will-quit', () => { try { fnProc && fnProc.kill('SIGKILL'); } catch (_) {} });
if (!quitReaperWired) {
quitReaperWired = true;
app.on('will-quit', () => { try { fnProc && fnProc.kill('SIGKILL'); } catch (_) {} });
}
console.log('[voice] fn watcher armed (awaiting first event to prove Input Monitoring)');
// macOS's own Globe-key action (emoji picker by default) fires on a quick fn tap alongside us;
// tell the renderer once so it can point the user at "Press Globe key to: Do Nothing".
@@ -330,4 +348,22 @@ function installVoiceHotkey(getMainWindow) {
});
}
module.exports = { installVoiceHotkey };
/**
* PIDs of fn-watcher processes that are NOT this app's, given `ps -eo pid=,args=` output.
*
* Matched on the absolute binary path so an unrelated program never matches, and our own pid is
* excluded. Pure so the selection can be tested; the killing stays at the call site.
*/
function strayFnWatcherPids(psOutput, binPath, selfPid) {
const pids = [];
if (!binPath) return pids;
for (const line of String(psOutput || '').split('\n')) {
if (line.indexOf(binPath) < 0) continue;
const pid = parseInt(line.trim().split(/\s+/)[0], 10);
if (!Number.isInteger(pid) || pid <= 1 || pid === selfPid) continue;
pids.push(pid);
}
return pids;
}
module.exports = { installVoiceHotkey, strayFnWatcherPids };
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const assert = require('assert');
const { strayFnWatcherPids } = require('./voiceHotkey');
// The fn watcher holds a GLOBAL keyboard tap. Its only reaper is app.on('will-quit'), which never
// runs on a crash or a force-quit, so bad exits strand one forever (found alive after 2h35m on a dev
// box, with a second live one, which means fn fires twice and dictation double-toggles). We sweep at
// spawn, the one moment any other fn-watcher is provably not ours. Every case here is about NOT
// killing something that isn't a stray, because this sends SIGKILL.
const BIN = '/Users/x/Library/Application Support/openswarm/fn-watcher-bin/fn-watcher';
function ps(lines) { return lines.join('\n'); }
{ // the actual field case: one orphan, one of ours
const out = ps([
` 16541 ${BIN}`,
` 47069 ${BIN}`,
' 1234 /usr/bin/some-other-app',
]);
assert.deepEqual(strayFnWatcherPids(out, BIN, 47069), [16541], 'must reap the orphan, keep ours');
}
{ // nothing stray
assert.deepEqual(strayFnWatcherPids(ps([` 47069 ${BIN}`]), BIN, 47069), []);
}
{ // never match an unrelated binary that merely has a similar name
const out = ps([' 900 /opt/other/fn-watcher', ' 901 /usr/bin/fn-watcher-clone']);
assert.deepEqual(strayFnWatcherPids(out, BIN, 1), [], 'path match must be exact, not by basename');
}
{ // pid 1 is never a candidate, whatever ps says
assert.deepEqual(strayFnWatcherPids(ps([` 1 ${BIN}`]), BIN, 999), []);
}
{ // a missing binary path must never turn into "kill everything"
assert.deepEqual(strayFnWatcherPids(ps([` 16541 ${BIN}`]), '', 999), []);
assert.deepEqual(strayFnWatcherPids(ps([` 16541 ${BIN}`]), null, 999), []);
}
{ // restricted/absent ps output degrades to a no-op
assert.deepEqual(strayFnWatcherPids('', BIN, 999), []);
assert.deepEqual(strayFnWatcherPids(null, BIN, 999), []);
}
{ // several strays accumulated across several bad exits
const out = ps([` 100 ${BIN}`, ` 200 ${BIN}`, ` 300 ${BIN}`, ` 400 ${BIN}`]);
assert.deepEqual(strayFnWatcherPids(out, BIN, 300), [100, 200, 400]);
}
console.log('voiceHotkeyStray: all assertions passed');
+1 -1
View File
@@ -51,7 +51,7 @@
var g = null;
try { g = JSON.parse(localStorage.getItem('self-swarm-theme-gradient') || 'null'); } catch (e) {}
var accent = localStorage.getItem('self-swarm-theme-accent');
var stops = Array.isArray(g) && g.length > 1 ? g : (accent ? [accent, accent] : ['#B7CDEA', '#EFE0D2', '#E7BDD1']);
var stops = Array.isArray(g) && g.length > 1 ? g : (accent ? [accent, accent] : ['#DACEDA']);
if (stops.length === 1) stops = [stops[0], stops[0]];
var alpha = parseFloat(localStorage.getItem('self-swarm-theme-wash-opacity') || '');
if (!isFinite(alpha) || alpha < 0 || alpha > 1) alpha = 0.17;
+13 -1
View File
@@ -66,6 +66,7 @@ if (typeof window !== 'undefined') {
else window.setTimeout(prefetchAll, 500);
}
import { report, reportAppOpened, getSessionTraceState, getRecentActions } from '@/shared/serviceClient';
import { installUxSignals } from '@/shared/uxSignals';
import { useRouteTracker } from '@/shared/hooks/useRouteTracker';
import { useDeepLink } from '@/shared/hooks/useDeepLink';
import { useWindowFocus } from '@/shared/hooks/useWindowFocus';
@@ -277,6 +278,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
if (!loaded) return;
(window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
}, [loaded, allowExperimentalUpdates]);
useEffect(() => installUxSignals(), []);
// Hold paint until the settings fetch SETTLES so the user's theme renders first; Electron's ready-to-show relies on this. Settling, not succeeding: a backend that never answers used to leave a blank window forever.
if (!settled) return null;
return <>{children}</>;
@@ -416,7 +418,17 @@ const CrashRecoveryChip: React.FC = () => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
if (!api?.getCrashRecoveryInfo) return;
api.getCrashRecoveryInfo().then((info) => {
if (info) { setMounted(true); setShow(true); }
if (info) {
setMounted(true); setShow(true);
// The crash was captured locally but analytics never heard about it; a silent GPU/renderer
// death is exactly the failure class telemetry must count (flight-recorder family A).
const i = info as { kind?: string; details?: { reason?: string; exitCode?: number } };
report('process', 'crash_recovered', {
crash_kind: i.kind ?? 'unknown',
reason: i.details?.reason ?? null,
exit_code: i.details?.exitCode ?? null,
});
}
}).catch(() => {});
}, []);
React.useEffect(() => {
@@ -25,13 +25,14 @@ import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill';
import WhatsNewCard from '@/app/components/Layout/WhatsNewCard';
import ShareRequestHost from '@/app/components/share/ShareRequestHost';
import CardContextMenu from '@/app/pages/Dashboard/desktop/CardContextMenu';
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { byPreviewRecency } from '@/shared/previewOrder';
import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
@@ -342,6 +343,14 @@ const AppShell: React.FC = () => {
// unpinned fullscreen keeps the hover-peek overlay.
const [fsSidebarPinned, setFsSidebarPinned] = useState(false);
const sidebarAway = (sidebarCollapsed || (fsActive && !fsSidebarPinned)) && isDashboardViewActive;
// The dashboard canvas paints the identical wash and grain over everything but a 6px frame of
// shell, so painting them here too just buys a second full-window texture for the compositor to
// evict. Skip it while the canvas is up; on every other route the shell is the only painter.
const shellWashLayers = React.useMemo(
() => (fsWashStops
? washBackgroundLayers(fsWashStops, themeWashOpacity, c.bg.page, isDashboardViewActive ? null : shellGrainUrl)
: null),
[fsWashStops, themeWashOpacity, c.bg.page, shellGrainUrl, isDashboardViewActive]);
// When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
// dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
// (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
@@ -352,6 +361,15 @@ const AppShell: React.FC = () => {
else root.style.removeProperty('--osw-header-inset');
return () => { root.style.removeProperty('--osw-header-inset'); };
}, [sidebarAway]);
// Hand the boot paint back. index.html puts the wash gradient on <html> so a reload never flashes
// white, but nothing ever took it off: it stayed for the whole session as a full-window,
// background-attachment:fixed root layer that the shell already paints over. That is a permanently
// resident evictable texture bought for the first frame only, and evicting it is what shows the
// hard-edged band. The shell covers the viewport by the time this runs, so a flat colour is all the
// backdrop that is left to want.
useEffect(() => {
document.documentElement.style.background = c.bg.page;
}, [c.bg.page]);
// Global text-size ratio (Settings > Interface). Scaling the root font-size scales every rem-based
// size in one shot, so type grows or shrinks together with no layout breakage. Clamped to a sane band
// so a corrupt value can never wreck the whole UI.
@@ -416,11 +434,11 @@ const AppShell: React.FC = () => {
// sliver of shell peeking past the viewport reads as continuous texture, never a tint/grain seam.
...(fsWashStops ? {
backgroundColor: washUnderlayColor(fsWashStops, themeWashOpacity, c.bg.page),
backgroundImage: shellGrainUrl
? `${shellGrainUrl}, ${washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page)}`
: washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page),
backgroundSize: shellGrainUrl ? 'auto, 100% 100%' : '100% 100%',
backgroundRepeat: shellGrainUrl ? 'repeat, no-repeat' : 'no-repeat',
...(shellWashLayers ? {
backgroundImage: shellWashLayers.image,
backgroundSize: shellWashLayers.size,
backgroundRepeat: shellWashLayers.repeat,
} : {}),
} : {}),
}}>
{/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the
@@ -590,6 +608,8 @@ const AppShell: React.FC = () => {
{/* Shell-global right-click host (portals to body): chat surfaces render on non-dashboard routes too, so the menu can't live inside DashboardCanvas. */}
<CardContextMenu />
<WhatsNewCard />
</Box>
);
@@ -0,0 +1,81 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Fade from '@mui/material/Fade';
import { API_BASE } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Shown ONCE per version, right after an update: what actually changed, in the same words the Help
// agent and the GitHub release body carry. A release that ships with no story is a bug the backend
// test catches; a user who never hears about the fix is the bug this card catches.
interface WhatsNew {
version: string;
headline: string;
highlights: string[];
fixes: string[];
}
const SEEN_KEY = 'openswarm.whatsNew.seenVersion';
export default function WhatsNewCard(): React.ReactElement | null {
const c = useClaudeTokens();
const [note, setNote] = React.useState<WhatsNew | null>(null);
React.useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/help/whats-new`)
.then((r) => (r.ok ? r.json() : null))
.then((data: WhatsNew | null) => {
if (cancelled || !data?.version) return;
let seen: string | null = null;
try { seen = window.localStorage.getItem(SEEN_KEY); } catch { /* private mode */ }
if (seen === data.version) return;
setNote(data);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const dismiss = React.useCallback(() => {
if (note) {
try { window.localStorage.setItem(SEEN_KEY, note.version); } catch { /* private mode */ }
}
setNote(null);
}, [note]);
if (!note) return null;
const lines = [...note.highlights.map((t) => ({ t, kind: 'new' })), ...note.fixes.map((t) => ({ t, kind: 'fixed' }))];
return (
<Fade in timeout={{ enter: 260, exit: 200 }}>
<Box
data-select-type="whats-new"
sx={{
position: 'fixed', bottom: 24, right: 24, zIndex: 1450, width: 380, maxWidth: '90vw',
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`, borderRadius: '14px',
boxShadow: '0 18px 44px rgba(0,0,0,0.28)', p: 2,
}}
>
<Typography sx={{ fontSize: c.font.size.sm, color: c.text.muted, mb: 0.25 }}>
{`What's new in ${note.version}`}
</Typography>
<Typography sx={{ fontSize: c.font.size.base, fontWeight: 600, color: c.text.primary, mb: 1.25 }}>
{note.headline}
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{lines.slice(0, 5).map((l) => (
<Typography key={l.t} component="li" sx={{ fontSize: c.font.size.sm, color: c.text.secondary, lineHeight: 1.5 }}>
{l.t}
</Typography>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1.5 }}>
<Button size="small" onClick={dismiss} sx={{ color: c.accent.primary, fontWeight: 600, textTransform: 'none' }}>
Got it
</Button>
</Box>
</Box>
</Fade>
);
}
@@ -11,7 +11,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isNarration } from './isNarration.js';
import { isNarration } from './isNarration.ts';
test('short passing remarks are narration', () => {
for (const s of [
@@ -222,6 +222,10 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
) : (
<Box
sx={{
// While a browser agent is live the mini browser below carries the SAME action feed on
// its own overlay; showing the black log too is the same story twice, so it collapses
// to zero height for the duration and returns as the normal record once the run ends.
display: isBrowserAgent && (isPending || isStreaming) ? 'none' : undefined,
bgcolor: tc.TERM_BG,
borderRadius: 1.5,
maxHeight: 'min(40vh, 320px)',
@@ -42,6 +42,7 @@ interface Props {
selectedAppIds?: string[],
) => void;
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onOpenApplications: () => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
dashboardId?: string;
@@ -73,7 +74,7 @@ function formatRelativeTime(dateStr: string | null): string {
}
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, canvasEmpty, onNewAgentBounceEnd, prefillPrompt, prefillMode }, ref) => {
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onOpenApplications, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, canvasEmpty, onNewAgentBounceEnd, prefillPrompt, prefillMode }, ref) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
@@ -628,7 +629,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
onNewAgent();
}}
onAddBrowser={onAddBrowser}
onAddApp={handleOpenViewPicker}
onAddApp={onOpenApplications}
onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
onHistory={handleOpenHistory}
/>
@@ -18,10 +18,13 @@ import ApplicationsWindow from '../desktop/ApplicationsWindow';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
// How far the dot grid bleeds past the viewport; must exceed one tile period (24px * max zoom) so the compositor phase translate can never expose an edge.
const GRID_BLEED_PX = 256;
// How far the dot grid bleeds past the viewport. The phase translate is `pan % dotSpacing`, so it
// can never exceed one tile period; deriving the bleed from that bound keeps the layer as small as
// it can be (a hardcoded 256 made it 3.5x bigger than needed, all of it evictable texture) and a
// future max-zoom bump can't silently uncover an edge.
const GRID_BLEED_PX = 24 * MAX_ZOOM;
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -32,7 +35,7 @@ import type {
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
import type { useCanvasControls } from '../hooks/interaction/useCanvasControls';
import { MAX_ZOOM, type useCanvasControls } from '../hooks/interaction/useCanvasControls';
import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend';
import { deleteSelectedCards } from '../hooks/interaction/deleteSelectedCards';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
@@ -174,8 +177,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
const dotSpacing = 24 * canvas.zoom;
// Memoized: this component re-renders every card-drag frame, and rebuilding these strings (SVG encode + hex blends) per frame is pure waste.
const washUnderlay = React.useMemo(() => washUnderlayColor(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]);
const washUrl = React.useMemo(() => washOpaqueBackgroundUrl(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]);
const grainTileUrl = useGrainTileUrl(grain);
const washLayers = React.useMemo(() => washBackgroundLayers(washStops, washOpacity, c.bg.page, grainTileUrl), [washStops, washOpacity, c.bg.page, grainTileUrl]);
const gridTileUrl = React.useMemo(() => `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='${dotSpacing}' height='${dotSpacing}'><circle cx='${dotSpacing / 2}' cy='${dotSpacing / 2}' r='${dotSize}' fill='${c.border.medium}'/></svg>`,
)}")`, [dotSpacing, dotSize, c.border.medium]);
@@ -425,6 +428,15 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
overflow: 'hidden',
// Last line of the never-white guarantee: if every background layer's raster is gone, the viewport itself still paints tint (solid colors are compositor quads, not evictable textures).
backgroundColor: washUnderlay,
// Wash + grain paint HERE rather than on a child: two stacked full-viewport layers meant two
// rasters the compositor could evict independently, and a dropped one exposed the flat tint
// as a hard-edged band. One element, one raster, one fewer thing to lose. A uniform wash
// drops the image entirely, because backgroundColor above already IS that colour.
...(washLayers ? {
backgroundImage: washLayers.image,
backgroundSize: washLayers.size,
backgroundRepeat: washLayers.repeat,
} : {}),
cursor: canvas.isPanning
? 'grabbing'
: (canvas.spaceHeld || canvas.cmdHeld)
@@ -434,21 +446,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
: 'default',
}}
>
{/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; intensity + grain come from the theme device; sits under the dot grid. Pre-blended opaque + declared backgroundColor so a GPU-evicted tile paints as tint, never raw white/black (the ENG-151 band). */}
{washStops && washStops.length > 0 && (
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
backgroundColor: washUnderlay,
// Grain rides the SAME element (alpha pre-baked): one raster, so an evicted tile drops both and paints the tint, never a grain-only seam.
backgroundImage: grainTileUrl ? `${grainTileUrl}, ${washUrl}` : washUrl,
backgroundSize: grainTileUrl ? 'auto, 100% 100%' : '100% 100%',
backgroundRepeat: grainTileUrl ? 'repeat, no-repeat' : 'no-repeat',
}}
/>
)}
{/* Dot grid background; gestures move it imperatively via gridRef (phase + scale), commits re-render it here (dot radius included). The tile is an SVG IMAGE, not a procedural gradient: Chromium caches a decoded image as a GPU texture, while a radial-gradient re-rasterizes the whole layer every backgroundSize change, and under GPU memory pressure (many webviews, external monitors) those rasters get dropped and paint as a giant blank rectangle, the 1.5.9 white-patch bug. Same backgroundSize/Position write contract, so the per-frame camera writer is untouched. */}
<Box
@@ -540,6 +537,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onToolbarCancel={onToolbarCancel}
onToolbarSend={onToolbarSend}
onAddView={onAddView}
onOpenApplications={handleToggleApps}
onHistoryResume={onHistoryResume}
onAddBrowser={onAddBrowser}
onNewAgentBounceEnd={onNewAgentBounceEnd}
@@ -42,6 +42,7 @@ interface DashboardOverlaysProps {
onToolbarCancel: () => void;
onToolbarSend: (...args: any[]) => void;
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onOpenApplications: () => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onNewAgentBounceEnd: () => void;
@@ -74,6 +75,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
onToolbarCancel,
onToolbarSend,
onAddView,
onOpenApplications,
onHistoryResume,
onAddBrowser,
onNewAgentBounceEnd,
@@ -97,6 +99,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
onCancel={onToolbarCancel}
onSend={onToolbarSend}
onAddView={onAddView}
onOpenApplications={onOpenApplications}
onHistoryResume={onHistoryResume}
onAddBrowser={onAddBrowser}
dashboardId={dashboardId}
@@ -42,6 +42,10 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
const settingsCard = useAppSelector((s) => s.dashboardLayout.settingsCard);
const marketplaceCard = useAppSelector((s) => s.dashboardLayout.marketplaceCard);
const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard);
// bringToFront writes the raise into the zOrders map, but these windows render from their own
// stored zOrder, so a click on Marketplace/Settings raised nothing at all. Prefer the live map.
const zOrders = useAppSelector((s) => s.dashboardLayout.zOrders);
const zOf = (id: string, stored: number | undefined): number => zOrders[id] ?? stored ?? 0;
const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId);
const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined));
// The monitor's workflow vanished (trashed/deleted) while open: tear the card + its tether down instead of leaving an orange line pointing at nothing.
@@ -57,7 +61,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
cardY={workflowsHub.y}
cardWidth={workflowsHub.width}
cardHeight={workflowsHub.height}
cardZOrder={workflowsHub.zOrder ?? 0}
cardZOrder={zOf('workflows-hub', workflowsHub.zOrder)}
getCanvasState={getCanvasState}
isSelected={selection.isSelected('workflows-hub')}
isHighlighted={highlightedCardId === 'workflows-hub'}
@@ -75,7 +79,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
cardY={settingsCard.y}
cardWidth={settingsCard.width}
cardHeight={settingsCard.height}
cardZOrder={settingsCard.zOrder ?? 0}
cardZOrder={zOf(SETTINGS_CARD_ID, settingsCard.zOrder)}
getCanvasState={getCanvasState}
isSelected={selection.isSelected(SETTINGS_CARD_ID)}
isHighlighted={highlightedCardId === SETTINGS_CARD_ID}
@@ -93,7 +97,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
cardY={marketplaceCard.y}
cardWidth={marketplaceCard.width}
cardHeight={marketplaceCard.height}
cardZOrder={marketplaceCard.zOrder ?? 0}
cardZOrder={zOf(MARKETPLACE_CARD_ID, marketplaceCard.zOrder)}
getCanvasState={getCanvasState}
isSelected={selection.isSelected(MARKETPLACE_CARD_ID)}
isHighlighted={highlightedCardId === MARKETPLACE_CARD_ID}
@@ -112,7 +116,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
cardY={monitorCard.y}
cardWidth={monitorCard.width}
cardHeight={monitorCard.height}
cardZOrder={monitorCard.zOrder ?? 0}
cardZOrder={zOf('workflows-monitor', monitorCard.zOrder)}
getCanvasState={getCanvasState}
onDragStart={onDragStart}
onDragMove={onDragMove}
@@ -1,7 +1,7 @@
// Run: cd frontend && npx tsx --test src/app/pages/Dashboard/canvas/revealZoom.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { revealZoom, REVEAL_MIN_ZOOM } from './revealZoom.js';
import { revealZoom, REVEAL_MIN_ZOOM } from './revealZoom.ts';
const MIN = 0.15, MAX = 3.0;
@@ -77,6 +77,8 @@ function measureWorkspace(): Workspace {
export function zoneRect(zone: string): ZoneRect | null {
const ws = workspace ?? (workspace = measureWorkspace());
const usableW = ws.w - ws.x0 - ws.x1;
// No top inset: the drag strip is zIndex 5 and a tiled window is 999990, so the window is already
// ABOVE it and never loses those pixels. Insetting below it just pushed every tile 25px down.
if (zone === 'fullscreen') {
return { x: ws.x0 + GAP, y: GAP, w: usableW - GAP * 2, h: ws.h - GAP * 2 };
}
@@ -93,6 +95,9 @@ export function zoneRect(zone: string): ZoneRect | null {
interface TiledEntry {
el: HTMLElement;
zone: string;
// True when the painted rect already agreed with React's origin at register time, i.e. there is
// nothing for the settle pass to correct.
trusted?: boolean;
// The card's own canvas-space origin, which React keeps owning. The transform below is only the
// delta from there to the zone, so tiling never rewrites a stored position and untiling never jumps.
originX: number;
@@ -175,6 +180,37 @@ function stopObserving(): void {
// tool-row motion so the whole app eases identically.
const ENTER_MS = 260;
const ENTER_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
// The settle must not fire until the CAMERA has stopped, and no fixed delay can promise that: 420ms
// outlasted the 340ms glide on this machine, but a loaded renderer or a slow external display can
// stretch the glide past any constant (the first constant, 300ms, missed by 40ms and baked a
// mid-flight zoom into the tile: a UNIFORM size error, +2.3% at zoom 0.34, -4.1% at 0.61, position
// exact). So settle on OBSERVED rest: the camera unchanged for SETTLE_IDLE_MS, after our own enter
// transition is done, with a hard backstop so a camera that never rests cannot defer this forever.
const SETTLE_MIN_MS = ENTER_MS + 40;
const SETTLE_IDLE_MS = 120;
const SETTLE_MAX_MS = 2000;
// Runs onSettled once the camera has demonstrably stopped moving. Every camera write funnels through
// syncTiledGeometry into lastCamera, so "lastCamera stopped changing" IS "the camera stopped".
function settleWhenCameraRests(onSettled: (elapsedMs: number) => void): void {
const started = performance.now();
let seen = { ...lastCamera };
let restingSince = started;
const tick = () => {
const now = performance.now();
if (lastCamera.panX !== seen.panX || lastCamera.panY !== seen.panY || lastCamera.zoom !== seen.zoom) {
seen = { ...lastCamera };
restingSince = now;
}
const rested = now - restingSince >= SETTLE_IDLE_MS && now - started >= SETTLE_MIN_MS;
if (rested || now - started >= SETTLE_MAX_MS) {
onSettled(Math.round(now - started));
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
// The passed origin is REACT'S base at call time, but the card may still be repainting (a pill
// entering fullscreen commits a different left/top one frame later), and a delta computed against
@@ -184,13 +220,33 @@ const ENTER_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
// cards keep their position in framer's transform (computed left is 0), browser cards in left/top
// classes; solving painted = pan + zoom*(origin + tx) for origin absorbs every variant, and once
// true, every later camera write applies correctly.
// A minimized window is parked at left:-100000 with visibility hidden (CanvasWindowCard keeps it
// mounted so it holds its state). Its painted rect is therefore nowhere near its canvas home, and
// deriving an origin from it puts the tile 100,000px out: that is the "fullscreen from the rail
// lands slightly cut off" bug, and it hits Settings, Workflows and Marketplace because those three
// are the only surfaces that render through CanvasWindowCard.
function isParked(el: HTMLElement): boolean {
if (el.getAttribute('data-keepalive-hidden') === '1') return true;
try {
return getComputedStyle(el).visibility === 'hidden';
} catch {
return false;
}
}
// How far the painted rect may disagree with React's committed origin before we stop believing it.
// Real disagreement is sub-pixel; anything larger means the card is mid-flight, not at home.
const ORIGIN_TRUST_PX = 64;
function rebaseline(entry: TiledEntry, cam: Camera, tx: number, ty: number): void {
// Never re-baseline off a parked card: its rect is the -100000 park, not its home.
if (isParked(entry.el)) return;
const r = entry.el.getBoundingClientRect();
entry.originX = (r.left - cam.panX) / cam.zoom - tx;
entry.originY = (r.top - cam.panY) / cam.zoom - ty;
}
export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera): void {
export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera, opts?: { instant?: boolean }): void {
const el = document.querySelector<HTMLElement>(`[data-select-id="${CSS.escape(id)}"]`);
if (!el) return;
// Derive the TRUE origin up front from the painted rect and the element's current transform
@@ -198,34 +254,103 @@ export function registerTiledCard(id: string, zone: string, origin: { x: number;
// instead of gliding to a wrong spot and snapping at settle (the left-then-center jerk).
let ox = origin.x;
let oy = origin.y;
if (!isParked(el)) {
try {
const r0 = el.getBoundingClientRect();
const m = new DOMMatrixReadOnly(getComputedStyle(el).transform);
const dx = (r0.left - cam.panX) / cam.zoom - m.e;
const dy = (r0.top - cam.panY) / cam.zoom - m.f;
// React's origin is the committed truth; the rect only refines it. They should agree within a
// pixel or two, so a big disagreement means the card is not painted at home yet (gliding in
// from the rail) and the rect would bake that flight into the origin.
if (Math.abs(dx - origin.x) < ORIGIN_TRUST_PX && Math.abs(dy - origin.y) < ORIGIN_TRUST_PX) {
ox = dx;
oy = dy;
}
} catch { /* keep the passed origin */ }
}
// TEMPORARY (minimized-to-fullscreen offset): one flat STRING, because devtools collapses nested
// arrays to "Array(2)" and the values are lost the moment you copy the line out.
try {
const r0 = el.getBoundingClientRect();
const m = new DOMMatrixReadOnly(getComputedStyle(el).transform);
ox = (r0.left - cam.panX) / cam.zoom - m.e;
oy = (r0.top - cam.panY) / cam.zoom - m.f;
} catch { /* keep the passed origin */ }
const entry: TiledEntry = { el, zone, originX: ox, originY: oy };
const zr = zoneRect(zone);
const w = measureWorkspace();
const n = (v: number) => Math.round(v);
// eslint-disable-next-line no-console
console.log(
`[TILE] ${id} ${zone} parked=${isParked(el)} rail=${!!document.querySelector('[data-minimized-rail]')}`
+ ` | react=${n(origin.x)},${n(origin.y)} rect=${n((r0.left - cam.panX) / cam.zoom - m.e)},${n((r0.top - cam.panY) / cam.zoom - m.f)}`
+ ` used=${n(ox)},${n(oy)}`
+ ` | painted=${n(r0.left)},${n(r0.top)} ${n(r0.width)}x${n(r0.height)}`
+ ` tf=${n(m.e)},${n(m.f)}@${m.a.toFixed(2)}`
+ ` | zone=${zr ? `${n(zr.x)},${n(zr.y)} ${n(zr.w)}x${n(zr.h)}` : 'null'}`
+ ` cam=${n(cam.panX)},${n(cam.panY)}@${cam.zoom.toFixed(2)}`
+ ` ws=x0:${n(w.x0)} x1:${n(w.x1)} ${n(w.w)}x${n(w.h)}`,
);
} catch { /* diagnostics must never break tiling */ }
// If the rect confirmed React's origin, the origin is right and the settle re-baseline can only
// make it worse: measured over 12 live fullscreens, EVERY run where re-baselining left the origin
// alone landed at OFFBY 0,0, and every run where it moved the origin landed off by 1-10px. It only
// ever moved it when the camera was still gliding, because it solves against a painted rect that
// the camera is still changing underneath it.
const trusted = Math.abs(ox - origin.x) < 1 && Math.abs(oy - origin.y) < 1;
const entry: TiledEntry = { el, zone, originX: ox, originY: oy, trusted };
entries.set(id, entry);
startObserving();
// Tiling usually commits alongside chrome collapsing, so a cached workspace is untrustworthy here.
workspace = null;
lastCamera = cam;
el.style.transition = `transform ${ENTER_MS}ms ${ENTER_EASE}`;
window.setTimeout(() => {
// ...but invalidating is not enough, and on THIS frame it is not even correct. Coming out of the
// minimized rail, the rail still holds this card while the zone is being solved, so the workspace
// is 76px narrower than it is about to be and the window lands undersized (measured -34x-36) with
// no one to tell it otherwise: nothing here notified the size subscribers. Re-measure once the
// rail has actually let go, which also re-renders every tiled card's size.
requestAnimationFrame(() => {
if (entries.get(id)?.el === el) onWorkspaceChanged();
});
// A card coming OUT OF THE MINIMIZED RAIL has no meaningful start point for a glide: it un-parks
// to its canvas home, which the camera is usually not even looking at (register logs show homes
// painted offscreen), so the enter transition flew it across the viewport from a spot the user
// never saw, while the camera also glided and the zone-size reflow ran. That stack of motion is
// the rail-to-fullscreen jank. Landing instantly with a short compositor-only fade is one clean
// frame instead; the glide stays for cards that were visibly on canvas, where it reads correctly.
if (opts?.instant) {
el.style.transition = '';
try {
el.animate([{ opacity: 0.3 }, { opacity: 1 }], { duration: 150, easing: 'ease-out' });
} catch { /* fade is decoration */ }
} else {
el.style.transition = `transform ${ENTER_MS}ms ${ENTER_EASE}`;
}
settleWhenCameraRests((settleElapsedMs) => {
const live = entries.get(id);
if (live?.el !== el) return;
// Transition is over: the rect now reflects exactly the transform we last wrote, so the
// origin solves cleanly (mid-transition reads would bake animation frames into it).
el.style.transition = '';
const r = zoneRect(live.zone);
if (r) {
if (r && !live.trusted) {
const s2 = 1 / lastCamera.zoom;
const tx = (r.x - lastCamera.panX) * s2 - live.originX;
const ty = (r.y - lastCamera.panY) * s2 - live.originY;
rebaseline(live, lastCamera, tx, ty);
}
if (r) {
try {
const rr = live.el.getBoundingClientRect();
const n = (v: number) => Math.round(v);
// eslint-disable-next-line no-console
console.log(
`[TILE settle] ${id} after=${settleElapsedMs}ms trusted=${!!live.trusted} origin=${n(live.originX)},${n(live.originY)}`
+ ` | painted=${n(rr.left)},${n(rr.top)} ${n(rr.width)}x${n(rr.height)}`
+ ` want=${n(r.x)},${n(r.y)} ${n(r.w)}x${n(r.h)}`
+ ` | OFFBY=${n(rr.left - r.x)},${n(rr.top - r.y)} SIZEOFF=${n(rr.width - r.w)},${n(rr.height - r.h)}`,
);
} catch { /* diagnostics only */ }
}
applyEntry(live, lastCamera);
}, ENTER_MS + 40);
});
applyEntry(entry, cam);
}
@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { requestWebviewAttachSlot, releaseWebviewAttachSlot } from './webviewAttachQueue';
import { createPortal } from 'react-dom';
import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel';
import Box from '@mui/material/Box';
@@ -23,6 +24,7 @@ import AddIcon from '@mui/icons-material/Add';
import LockIcon from '@mui/icons-material/Lock';
import SearchIcon from '@mui/icons-material/Search';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import { report } from '@/shared/serviceClient';
import RunInDesktopMessage from '@/app/components/RunInDesktopMessage';
import {
setBrowserCardPosition,
@@ -415,6 +417,15 @@ const BrowserCard: React.FC<Props> = ({
}, [activeUrl, activeTabId]);
const webviewMap = useRef<Map<string, WebviewElement>>(new Map());
// Electron attaches a guest with a SYNCHRONOUS renderer IPC, so a dashboard that mounts N cards
// puts N blocking round-trips in one frame (measured: 4755ms over 40 long tasks at 18 cards).
// Waiting for a slot spreads them one per frame; nothing unmounts, so sessions are untouched.
const [attachSlotReady, setAttachSlotReady] = useState(false);
useEffect(() => {
if (attachSlotReady) return undefined;
return requestWebviewAttachSlot(() => setAttachSlotReady(true));
}, [attachSlotReady]);
const initializedTabs = useRef(new Set<string>());
const tabBarRef = useRef<HTMLDivElement>(null);
// Some pages (Zillow's map) rewrite their own URL many times a second, across did-navigate-in-page AND did-stop-loading; throttle the persisted URL mirror so each tick can't fan out to a full dashboard save + webview suspend re-eval. Leading edge keeps a real navigation's URL immediate.
@@ -510,6 +521,10 @@ const BrowserCard: React.FC<Props> = ({
const onReady = () => {
if (tabId === activeTabIdRef.current) doLoad();
else registerPendingLoad(wv, targetUrl, doLoad);
// Release AFTER this card's own post-attach work (loadURL, capsule, zoom limits), not
// before: releasing first let that work run against the next card's attach and put six
// long tasks in one open where an isolated attach produces two.
releaseWebviewAttachSlot();
};
wv.addEventListener('dom-ready', onReady, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', onReady));
@@ -518,6 +533,18 @@ const BrowserCard: React.FC<Props> = ({
tagSurface();
wv.addEventListener('dom-ready', tagSurface);
cleanups.push(() => wv.removeEventListener('dom-ready', tagSurface));
// A dead browser card used to report NOTHING: the guest process vanishes, the surface goes
// blank, and no crash log or telemetry ever mentions it (verified by forcing a crash).
const onGuestGone = (e: Event): void => {
const d = e as Event & { reason?: string; exitCode?: number };
report('process', 'webview_gone', { reason: d.reason ?? 'crashed', exit_code: d.exitCode ?? null });
};
wv.addEventListener('render-process-gone', onGuestGone);
wv.addEventListener('crashed', onGuestGone);
cleanups.push(() => {
wv.removeEventListener('render-process-gone', onGuestGone);
wv.removeEventListener('crashed', onGuestGone);
});
}
// Every guest sits at about:blank before its real load (lazy tabs never leave it); mirroring
@@ -661,7 +688,10 @@ const BrowserCard: React.FC<Props> = ({
return () => cleanups.forEach((fn) => fn());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabIdKey, browserId, dispatch, updateTabLocal, suspendedSnap, throttleUrlMirror]);
// attachSlotReady is load-bearing: the <webview> elements do not exist until the attach queue
// releases this card, so without it this effect runs once against an empty map, registers no
// dom-ready listener, and every card after the first sits at about:blank forever.
}, [tabIdKey, browserId, dispatch, updateTabLocal, suspendedSnap, throttleUrlMirror, attachSlotReady]);
const navigate = useCallback((targetUrl: string) => {
const finalUrl = resolveInput(targetUrl);
@@ -1477,44 +1507,39 @@ const BrowserCard: React.FC<Props> = ({
flexShrink: 0,
}}
>
<Tooltip title="Back" placement="top">
<span>
<IconButton
size="small"
onClick={handleBack}
onPointerDown={(e) => e.stopPropagation()}
disabled={!activeLocal.canGoBack}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<ArrowBackIcon sx={{ fontSize: 15 }} />
</IconButton>
</span>
</Tooltip>
{/* No tooltips on back/forward/reload: every browser on earth uses these arrows, so the label
teaches nothing and the popup lands right on top of the page you are trying to read. */}
<IconButton
size="small"
aria-label="Back"
onClick={handleBack}
onPointerDown={(e) => e.stopPropagation()}
disabled={!activeLocal.canGoBack}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<ArrowBackIcon sx={{ fontSize: 15 }} />
</IconButton>
<Tooltip title="Forward" placement="top">
<span>
<IconButton
size="small"
onClick={handleForward}
onPointerDown={(e) => e.stopPropagation()}
disabled={!activeLocal.canGoForward}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<ArrowForwardIcon sx={{ fontSize: 15 }} />
</IconButton>
</span>
</Tooltip>
<IconButton
size="small"
aria-label="Forward"
onClick={handleForward}
onPointerDown={(e) => e.stopPropagation()}
disabled={!activeLocal.canGoForward}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<ArrowForwardIcon sx={{ fontSize: 15 }} />
</IconButton>
<Tooltip title="Reload" placement="top">
<IconButton
size="small"
onClick={handleRefresh}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<RefreshIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
<IconButton
size="small"
aria-label="Reload"
onClick={handleRefresh}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
>
<RefreshIcon sx={{ fontSize: 15 }} />
</IconButton>
{/* URL bar */}
<Box
@@ -1623,7 +1648,7 @@ const BrowserCard: React.FC<Props> = ({
)
) : (
<>
{tabs.map((tab) => (
{(attachSlotReady ? tabs : []).map((tab) => (
<webview
key={tab.id}
ref={(el: any) => {
@@ -26,11 +26,21 @@ export function useTiledCard({ cardId, zone, active, originX, originY, getCamera
cameraRef.current = getCamera;
const on = !!zone && active;
// Becoming ACTIVE while a zone is already set is the from-the-rail path: the card was hidden and
// is materialising straight into a tile, so the enter glide has no visible start point and must be
// skipped. Zone set while already active is the normal path and keeps its glide.
const prevActiveRef = useRef(active);
const instantRef = useRef(false);
if (active && !prevActiveRef.current && !!zone) instantRef.current = true;
prevActiveRef.current = active;
useEffect(() => (on ? subscribeTiledWorkspace(bump) : undefined), [on]);
useLayoutEffect(() => {
if (!on || !zone) return undefined;
registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current());
const instant = instantRef.current;
instantRef.current = false;
registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current(), { instant });
return () => unregisterTiledCard(cardId);
}, [on, zone, cardId, originX, originY]);
@@ -0,0 +1,67 @@
/**
* Serialises <webview> attachment so only one is in flight at a time.
*
* Electron attaches a guest view with a SYNCHRONOUS renderer IPC (GUEST_VIEW_MANAGER_CALL), so N
* cards mounting together put N blocking round-trips in one frame. Measured on a real dashboard:
* opening one with 18 cards / 8 webviews blocked the main thread for 4755ms across 40 long tasks,
* while an idle canvas blocked for 0ms (ENG-193).
*
* The first version released a slot per animation frame, which was not enough: an attach costs
* 60-140ms, i.e. several frames, so the next slot fired mid-attach and they overlapped anyway
* (worst single task stayed at 292ms). Slots now wait for the previous card to report it finished,
* with a ceiling so a card that never reports cannot wedge every card behind it.
*/
type Slot = () => void;
// An attach measured 60-140ms in isolation; this only bounds the pathological case where a card
// mounts and never signals, so it is deliberately far above the real cost.
const P_ATTACH_CEILING_MS = 1200;
let pending: Slot[] = [];
let inFlight = false;
let ceiling: ReturnType<typeof setTimeout> | null = null;
function p_startNext(): void {
const next = pending.shift();
if (!next) {
inFlight = false;
return;
}
inFlight = true;
if (ceiling) clearTimeout(ceiling);
ceiling = setTimeout(() => { ceiling = null; p_startNext(); }, P_ATTACH_CEILING_MS);
try {
next();
} catch {
/* a card that blew up on attach must not stall every card behind it */
if (ceiling) { clearTimeout(ceiling); ceiling = null; }
p_startNext();
}
}
/**
* Ask for the next attach slot. `onReady` fires immediately if nothing is attaching, otherwise once
* the card ahead reports done. Returns a cancel function for unmount before the slot arrives.
*/
export function requestWebviewAttachSlot(onReady: Slot): () => void {
pending.push(onReady);
if (!inFlight) {
// Start on the next frame so a burst of cards mounting in one commit all queue up first.
requestAnimationFrame(() => { if (!inFlight) p_startNext(); });
}
return () => {
pending = pending.filter((s) => s !== onReady);
};
}
/** A card calls this once its guest has actually attached, releasing the next card in line. */
export function releaseWebviewAttachSlot(): void {
if (ceiling) { clearTimeout(ceiling); ceiling = null; }
p_startNext();
}
/** Cards waiting behind the queue right now; exposed so a test can prove the burst is serialised. */
export function pendingAttachCount(): number {
return pending.length;
}
@@ -29,6 +29,11 @@ export function getCardRect(id: string, type: CardType):
const sc = layoutState.settingsCard;
if (!sc) return undefined;
return { x: sc.x, y: sc.y, width: sc.width, height: sc.height };
} else if (type === 'marketplace') {
// Marketplace was the one window the camera could not frame, so clicking it did nothing.
const mc = layoutState.marketplaceCard;
if (!mc) return undefined;
return { x: mc.x, y: mc.y, width: mc.width, height: mc.height };
}
return undefined;
}
@@ -9,13 +9,18 @@ import { applyBrowserZoom } from '@/shared/browserZoom';
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
// Surfaces that are WINDOWS, not canvas cards: they behave like an OS window, so a wheel inside one
// belongs to it whether or not you clicked in first. Canvas cards (agent, browser, view) keep the
// Google Maps model instead, where a plain scroll over an unfocused card drives the canvas.
const APP_WINDOW_SELECT_TYPES = new Set(['settings-card', 'marketplace-card', 'workflows-hub-card']);
const MIN_ZOOM = 0.15;
// The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go
// down, so every spawn that did not fit ratcheted the camera out and nothing ever brought it back:
// measured 100% -> 88% -> 79% -> 61% -> 36% -> 18% over one ordinary session, at which point no word
// on the canvas is readable. A hand-driven zoom can still go all the way to MIN_ZOOM.
const MAX_ZOOM = 3.0;
export const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
@@ -24,7 +29,7 @@ const FIT_PADDING = 200;
const TIDY_PADDING = { x: 120, y: 56 };
const TIDY_MIN_ZOOM = REVEAL_MIN_ZOOM;
// Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide.
const FIT_DURATION = 150;
const FIT_DURATION = 340;
// Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight.
const FIT_SETTLE_DELAY = FIT_DURATION + 60;
// A mouse notch lands as deltaY 100 where a trackpad sends ~1-10, so cap the per-event zoom delta: uncapped, one notch is a ~24% jump and macOS wheel acceleration stacks them. No-op for trackpads.
@@ -235,7 +240,9 @@ export function useCanvasControls(
const step = (now: number) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
// Quintic ease-out: leaves fast, lands soft. The old cubic at 150ms read as a snap; the eye
// reads the long tail as "the camera settled" rather than "the world jumped".
const ease = 1 - Math.pow(1 - t, 5);
applyLive({
panX: start.panX + (target.panX - start.panX) * ease,
panY: start.panY + (target.panY - start.panY) * ease,
@@ -323,13 +330,49 @@ export function useCanvasControls(
// Cache "is this element a scrollable child" decision per node. The Cache getComputedStyle ancestor walks; uncached was the dominant cost of trackpad two-finger nav. ResizeObserver below invalidates on scroll-capacity change.
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
// Scroll containment: once a wheel GESTURE is being served by a scrollable surface, the rest of
// that gesture stays there even after it hits the surface's end. Without this, reaching the
// bottom of Settings (or any list) chains into a canvas pan, which reads as the whole world
// sliding out from under you. A new gesture (a pause, or a different surface) starts fresh.
const GESTURE_GAP_MS = 220;
let containedEl: HTMLElement | null = null;
let containedAt = 0;
const onWheel = (e: WheelEvent) => {
// Full size view owns the whole surface: any wheel that escapes the chat's scroll container
// (side gutters, header) must NOT zoom/pan the hidden canvas underneath, that read as a
// glitchy zoom while scrolling the chat. Fullscreen has no canvas nav, period. The selector's
// existence check matters: a stale tile entry for a removed card would wedge the wheel forever.
if (selectFullscreenCardId(store.getState())) return;
// Swallow it rather than just ignoring it: the host window now allows visual zoom (so macOS
// delivers pinch at all), which means an un-prevented pinch here would magnify the whole UI
// instead of doing nothing.
if (selectFullscreenCardId(store.getState())) {
if (e.ctrlKey || e.metaKey) e.preventDefault();
return;
}
// Same gesture, still over the surface that owns it: let it scroll (or hit its end) natively.
if (containedEl && Date.now() - containedAt < GESTURE_GAP_MS && containedEl.isConnected
&& (e.target instanceof Node) && containedEl.contains(e.target as Node) && !(e.ctrlKey || e.metaKey)) {
containedAt = Date.now();
return;
}
// App windows (Settings, Marketplace, app previews) own every wheel inside them. Their inner
// panels are often scroll containers whose exact hit target isn't itself scrollable, and the
// old walk-up handed those to the canvas: reaching the end of Settings zoomed the world out.
const windowEl = (e.target as HTMLElement | null)?.closest?.('[data-select-type]') as HTMLElement | null;
if (windowEl && !(e.ctrlKey || e.metaKey)) {
// An app WINDOW always owns its wheel; a canvas CARD only owns it once you have clicked in.
// That split is the whole rule. Requiring click-focus for windows too meant hovering over
// Settings and scrolling leaked straight to the canvas, because nothing had focused it yet,
// and windows do carry a select-id so a "no id means a window" test silently never fired.
const windowId = windowEl.getAttribute('data-select-id');
const isAppWindow = APP_WINDOW_SELECT_TYPES.has(windowEl.getAttribute('data-select-type') || '');
if (isAppWindow || !windowId || windowId === getScrollFocusedCard()) {
containedEl = windowEl;
containedAt = Date.now();
return;
}
}
// ctrl/cmd wheel is the zoom gesture on every surface: a physically held key or a trackpad pinch (which also sets ctrlKey). It bypasses scrollable children so zoom is always reachable, even over a chat you're typing in.
const isModifierWheel = e.ctrlKey || e.metaKey;
// The setting swaps which of the two a bare mouse notch does. A PINCH must keep zooming
@@ -372,31 +415,17 @@ export function useCanvasControls(
target = target.parentElement;
continue;
}
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
const canScrollY = target.scrollHeight > target.clientHeight;
const canScrollX = target.scrollWidth > target.clientWidth;
// Horizontal-dominant gestures over a container that only scrolls vertically (e.g., chat) should pan the canvas instead of being silently absorbed by the child's no-op horizontal handling.
if (Math.abs(dx) > Math.abs(dy) && !canScrollX) {
target = target.parentElement;
continue;
}
const atYBoundary = !canScrollY ||
(dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) ||
(dy < 0 && target.scrollTop <= 1);
const atXBoundary = !canScrollX ||
(dx > 0 && target.scrollLeft + target.clientWidth >= target.scrollWidth - 1) ||
(dx < 0 && target.scrollLeft <= 1);
if (atYBoundary && atXBoundary) {
target = target.parentElement;
continue;
}
// Past this point the gesture belongs to THIS surface for its whole life. Reaching the end
// of a chat, or swiping sideways in a list that only scrolls down, used to fall through to
// the canvas and drag the world out from under you; a scroll that starts inside a card now
// ends inside it. Zoom still gets through, because isModifierWheel never reaches here.
containedEl = target;
containedAt = Date.now();
return;
}
target = target.parentElement;
}
containedEl = null;
e.preventDefault();
if (inertiaFrameRef.current) {
@@ -6,6 +6,7 @@ import { isAgentDrivenBrowser } from '@/shared/isAgentDrivenBrowser';
import { expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import { REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
import type { useCanvasControls } from './useCanvasControls';
@@ -228,7 +229,9 @@ export function useDashboardInteractions({
report('dashboard', 'canvas_double_clicked');
// Double-tap on empty space = show me everything (Eric's call): the same animated fit the
// overview affordances use, instead of the old blind 0.55x zoom-out that just lost people.
canvas.actions.fitToView();
// Floored like tidy and reveal, because unfloored it clamped at MIN_ZOOM and a spread-out canvas
// landed at 15%, every card a postage stamp. That is the same "lost people" this was meant to fix.
canvas.actions.fitToView(REVEAL_MIN_ZOOM);
}, [canvas.actions]);
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
@@ -11,11 +11,18 @@ import { getActivity, isAnyBrowserBusy } from '@/shared/browserCommandHandler';
import { isKeepAliveBrowser } from '@/shared/browserFocus';
import { captureTabCapsule } from '@/shared/browserStateCapsule';
import { getMinimizedShot } from '../../desktop/minimizedShots';
import { guestBudgetHasRoom, wireBrowserLiveCounter } from '@/shared/appWebviewBudget';
import { useAppHidden } from './useAppHidden';
import { cardIntersectsViewport, distFromCenter, type Viewport } from './suspendGeometry';
const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron');
// Feed the global guest budget the live-browser count, so apps and browsers share ONE ceiling.
wireBrowserLiveCounter(() => {
const dl = store.getState().dashboardLayout;
return Object.keys(dl.browserCards).filter((id) => !dl.suspendedBrowserCards[id]).length;
});
const SETTLE_MS = 800;
// Hysteresis: suspend only well past the edge, resume just past it, so a card sitting on the boundary never flaps between webview and snapshot.
const SUSPEND_MARGIN_PX = 320;
@@ -23,6 +30,13 @@ const RESUME_MARGIN_PX = 96;
const SNAPSHOT_MAX_W = 1024;
// Below this on-screen width a live page is indistinguishable from its placeholder, so booted-parked cards on a zoomed-out canvas stay parked until zoomed into.
const RESUME_MIN_CARD_PX = 220;
// ...and the same rule on the way OUT. This used to be one-directional: a card too small to read was
// never woken, but one already awake stayed awake however far you zoomed out, so eight webviews kept
// rendering full pages into ~100px boxes on a zoomed-out canvas. That is the app's biggest GPU cost
// paid for pixels nobody can read, and it is the pressure that makes Chromium evict the wash tiles
// (the two-tone background band). Lower than the resume bar on purpose, so a card sitting near the
// threshold cannot flap between live and snapshot.
const SUSPEND_MAX_CARD_PX = 150;
// Hard ceiling on simultaneous live webviews; past it the farthest-from-center non-agent card gets parked, so heavy pages degrade gracefully instead of OOMing.
const MAX_LIVE_WEBVIEWS = 8;
@@ -161,12 +175,25 @@ export function useWebviewSuspend(
// Un-minimizing is an explicit "give it back", so it wakes the card wherever the camera happens to be pointing.
if (wasMinimized[id]) {
restoredAt.set(id, Date.now());
dispatch(resumeBrowserCard(id));
// Straight into a tile (rail -> fullscreen): attaching the guest is a SYNCHRONOUS 60-290ms
// main-thread IPC, and firing it inside the landing + camera glide is the browser-only jank
// (DOM windows never pay it). The snapshot is pixel-identical, so let it do the landing and
// attach once the motion is over; re-minimized in the gap means never attach at all.
if (store.getState().dashboardLayout.tiledCards[id]) {
window.setTimeout(() => {
const dl = store.getState().dashboardLayout;
if (!dl.minimizedCards[id] && dl.suspendedBrowserCards[id]) dispatch(resumeBrowserCard(id));
}, 600);
} else {
dispatch(resumeBrowserCard(id));
}
budget--;
continue;
}
const bigEnough = card.width * zoom >= RESUME_MIN_CARD_PX;
if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX)) {
// Passive wake also asks the GLOBAL budget: a free browser slot means nothing if apps already
// hold the machine at its ceiling. Explicit restores and working agents above never ask.
if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX) && guestBudgetHasRoom()) {
dispatch(resumeBrowserCard(id));
budget--;
}
@@ -178,6 +205,9 @@ export function useWebviewSuspend(
const wantsPark = (id: string, card: BrowserCardPosition): boolean =>
appHidden
|| isMinimized(id)
// Read zoom off the ref, never the closure: this fires 800ms after the effect ran, and
// zooming out is exactly the gesture that should be parking these.
|| (!withinRestoreGrace(id) && card.width * vpRef.current.zoom < SUSPEND_MAX_CARD_PX)
|| (!withinRestoreGrace(id) && !cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX));
await refreshVisibleFrames(browserCards, isSuspended, vpRef.current);
for (const [id, card] of Object.entries(browserCards)) {
@@ -48,7 +48,9 @@ const ShortcutRecorderChip: React.FC<{ value: string; onChange: (combo: string)
setRecording(false);
}}
onBlur={() => setRecording(false)}
onClick={() => setRecording(true)}
// Arming without taking focus meant the next blur disarmed it before any key could land, so
// the chip snapped back to its old value and looked like it refused to be rebound.
onClick={(e) => { (e.currentTarget as HTMLElement).focus(); setRecording(true); }}
sx={{
display: 'inline-flex',
alignItems: 'center',
@@ -5,36 +5,47 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import CountUp from './parts/CountUp';
import BarSeries from './parts/BarSeries';
import ActivityColumns from './parts/ActivityColumns';
import StatusDonut from './parts/StatusDonut';
type Window = '7d' | '30d' | 'all';
interface DayPoint { day: string; chats: number }
interface UsageSummary {
window: string;
excluded_automation_sessions: number;
total_sessions: number;
total_messages: number;
total_tool_calls: number;
total_run_seconds: number;
avg_duration_seconds: number;
completion_rate: number;
models_used: Record<string, number>;
top_tools: Record<string, number>;
total_prompt_tokens: number;
total_completion_tokens: number;
total_cost_usd: number;
}
function fmtDuration(seconds: number): string {
if (seconds >= 3600) return `${(seconds / 3600).toFixed(1)} hrs`;
if (seconds >= 60) return `${Math.round(seconds / 60)} min`;
return `${Math.round(seconds)}s`;
status_breakdown: Record<string, number>;
daily_activity: DayPoint[];
hourly_activity: number[];
}
function fmtCount(n: number): string {
return n >= 10000 ? `${(n / 1000).toFixed(1)}k` : n.toLocaleString();
return n >= 10000 ? `${(n / 1000).toFixed(1)}k` : Math.round(n).toLocaleString();
}
/** Your real activity, claude-flat: windowed, automation excluded, friendly names, honest scopes. */
function cleanToolName(t: string): string {
return t.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '');
}
function hourLabel(h: number): string {
if (h === 0) return '12am';
if (h === 12) return '12pm';
return h < 12 ? `${h}am` : `${h - 12}pm`;
}
/** Your real activity: windowed, automation excluded, and only numbers we actually measure. */
const UsageStats: React.FC = () => {
const c = useClaudeTokens();
const [win, setWin] = useState<Window>('30d');
@@ -49,17 +60,23 @@ const UsageStats: React.FC = () => {
return () => { alive = false; };
}, [win]);
const rowSx = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
py: 1.1, borderBottom: `1px solid ${c.border.subtle}`, '&:last-of-type': { borderBottom: 'none' },
const sectionSx = {
color: c.text.muted, fontSize: '0.71875rem', fontWeight: 650, letterSpacing: '0.05em',
textTransform: 'uppercase', mt: 3, mb: 1,
} as const;
const labelSx = { color: c.text.primary, fontSize: '0.8438rem', fontWeight: 500 } as const;
const valueSx = { color: c.text.primary, fontSize: '0.8438rem', fontVariantNumeric: 'tabular-nums' } as const;
const sectionSx = { color: c.text.muted, fontSize: '0.71875rem', fontWeight: 650, letterSpacing: '0.05em', textTransform: 'uppercase', mt: 2.5, mb: 0.5 } as const;
const cardSx = {
flex: 1, minWidth: 0, px: 1.75, py: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${c.border.subtle}`, background: c.bg.elevated,
} as const;
const bigSx = { color: c.text.primary, fontSize: '1.5rem', fontWeight: 600, lineHeight: 1.1, fontVariantNumeric: 'tabular-nums' } as const;
const capSx = { color: c.text.muted, fontSize: '0.75rem', mt: 0.4 } as const;
const peakHour = stats ? stats.hourly_activity.indexOf(Math.max(...stats.hourly_activity)) : 0;
const avgMsgs = stats && stats.total_sessions > 0 ? stats.total_messages / stats.total_sessions : 0;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Typography sx={{ color: c.text.secondary, fontSize: '0.8125rem' }}>
Your own sessions on this device{stats && stats.excluded_automation_sessions > 0
? `; ${fmtCount(stats.excluded_automation_sessions)} automated runs excluded`
@@ -82,54 +99,80 @@ const UsageStats: React.FC = () => {
<Box sx={{ py: 4, textAlign: 'center', color: c.text.ghost, fontSize: '0.8125rem' }}>Loading</Box>
) : (
<>
<Box sx={rowSx}>
<Typography sx={labelSx}>Chats</Typography>
<Typography sx={valueSx}>{fmtCount(stats.total_sessions)}</Typography>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Messages</Typography>
<Typography sx={valueSx}>{fmtCount(stats.total_messages)}</Typography>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Tool calls</Typography>
<Typography sx={valueSx}>{fmtCount(stats.total_tool_calls)}</Typography>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Agent time</Typography>
<Typography sx={valueSx}>{fmtDuration(stats.total_run_seconds)}</Typography>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Finished cleanly</Typography>
<Typography sx={valueSx}>{Math.round(stats.completion_rate * 100)}%</Typography>
<Box sx={{ display: 'flex', gap: 1.25 }}>
<Box sx={cardSx}>
<Typography sx={bigSx}><CountUp value={stats.total_sessions} format={fmtCount} /></Typography>
<Typography sx={capSx}>Chats</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={bigSx}><CountUp value={stats.total_messages} format={fmtCount} /></Typography>
<Typography sx={capSx}>Messages</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={bigSx}><CountUp value={stats.total_tool_calls} format={fmtCount} /></Typography>
<Typography sx={capSx}>Tool calls</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={bigSx}><CountUp value={avgMsgs} format={(n) => n.toFixed(1)} /></Typography>
<Typography sx={capSx}>Messages per chat</Typography>
</Box>
</Box>
{stats.daily_activity.length > 1 && (
<>
<Typography sx={sectionSx}>Chats per day</Typography>
<ActivityColumns
data={stats.daily_activity.map((d) => ({ key: d.day, value: d.chats, caption: d.day.slice(5) }))}
/>
</>
)}
<Typography sx={sectionSx}>When you work</Typography>
<ActivityColumns
height={64}
highlightIndex={peakHour}
data={stats.hourly_activity.map((v, h) => ({ key: String(h), value: v, caption: hourLabel(h) }))}
/>
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mt: 0.5 }}>
Busiest around {hourLabel(peakHour)}.
</Typography>
<Typography sx={sectionSx}>How chats end</Typography>
<StatusDonut
slices={[
{ label: 'Finished cleanly', value: stats.status_breakdown.completed ?? 0, color: c.accent.primary },
{ label: 'You stopped it', value: stats.status_breakdown.stopped ?? 0, color: c.text.ghost },
{ label: 'Hit an error', value: stats.status_breakdown.error ?? 0, color: c.status?.error ?? '#c2554d' },
].filter((s) => s.value > 0)}
/>
<Typography sx={sectionSx}>Models</Typography>
{Object.entries(stats.models_used).slice(0, 6).map(([model, count]) => (
<Box key={model} sx={rowSx}>
<Typography sx={labelSx}>{model}</Typography>
<Typography sx={{ ...valueSx, color: c.text.secondary }}>{fmtCount(count)} chats</Typography>
</Box>
))}
<BarSeries
data={Object.entries(stats.models_used).slice(0, 6).map(([label, value]) => ({ label, value, suffix: 'chats' }))}
/>
<Typography sx={sectionSx}>Most used tools</Typography>
{Object.entries(stats.top_tools).slice(0, 8).map(([tool, count]) => (
<Box key={tool} sx={rowSx}>
<Typography sx={labelSx}>{tool.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '')}</Typography>
<Typography sx={{ ...valueSx, color: c.text.secondary }}>{fmtCount(count)} calls</Typography>
</Box>
))}
<BarSeries
data={Object.entries(stats.top_tools).slice(0, 8).map(([t, value]) => ({ label: cleanToolName(t), value, suffix: 'calls' }))}
/>
<Typography sx={sectionSx}>Routed requests (all traffic, lifetime)</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 1 }}>
Everything routed through the local model router since install, including background helpers; not limited to the window above.
</Typography>
<Box sx={rowSx}>
<Typography sx={labelSx}>Tokens in / out</Typography>
<Typography sx={valueSx}>{fmtCount(stats.total_prompt_tokens)} / {fmtCount(stats.total_completion_tokens)}</Typography>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>API value covered</Typography>
<Typography sx={valueSx}>${stats.total_cost_usd.toFixed(2)}</Typography>
<Box sx={{ display: 'flex', gap: 1.25 }}>
<Box sx={cardSx}>
<Typography sx={bigSx}><CountUp value={stats.total_prompt_tokens} format={fmtCount} /></Typography>
<Typography sx={capSx}>Tokens in</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={bigSx}><CountUp value={stats.total_completion_tokens} format={fmtCount} /></Typography>
<Typography sx={capSx}>Tokens out</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={bigSx}>$<CountUp value={stats.total_cost_usd} format={(n) => n.toFixed(2)} /></Typography>
<Typography sx={capSx}>API value covered</Typography>
</Box>
</Box>
</>
)}
@@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface ColumnDatum {
key: string;
value: number;
caption?: string;
}
interface ActivityColumnsProps {
data: ColumnDatum[];
height?: number;
highlightIndex?: number;
}
/** Column chart with a staggered grow-in; heights ride CSS transitions so nothing animates per frame. */
const ActivityColumns: React.FC<ActivityColumnsProps> = ({ data, height = 84, highlightIndex }) => {
const c = useClaudeTokens();
const [grown, setGrown] = useState(false);
const [hover, setHover] = useState<number | null>(null);
useEffect(() => {
const id = requestAnimationFrame(() => setGrown(true));
return () => cancelAnimationFrame(id);
}, []);
const peak = Math.max(1, ...data.map((d) => d.value));
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: '3px', height }}>
{data.map((d, i) => (
<Box
key={d.key}
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', height: '100%', cursor: 'default' }}
>
<Box
sx={{
borderRadius: '3px 3px 0 0',
background: i === highlightIndex || i === hover ? c.accent.primary : c.text.ghost,
opacity: i === highlightIndex || i === hover ? 1 : 0.42,
height: grown ? `${Math.max(2, (d.value / peak) * 100)}%` : '0%',
transition: `height 600ms cubic-bezier(0.22,1,0.36,1) ${Math.min(i * 18, 420)}ms, background 140ms ease, opacity 140ms ease`,
}}
/>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.6, minHeight: 16 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.6875rem' }}>{data[0]?.caption ?? ''}</Typography>
<Typography sx={{ color: hover === null ? c.text.ghost : c.text.secondary, fontSize: '0.6875rem', fontVariantNumeric: 'tabular-nums' }}>
{hover === null ? (data[data.length - 1]?.caption ?? '') : `${data[hover].caption}: ${data[hover].value.toLocaleString()}`}
</Typography>
</Box>
</Box>
);
};
export default ActivityColumns;
@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface BarDatum {
label: string;
value: number;
suffix?: string;
}
interface BarSeriesProps {
data: BarDatum[];
max?: number;
}
/** Ranked horizontal bars that grow in on mount; width rides a CSS transition, so no JS runs per frame. */
const BarSeries: React.FC<BarSeriesProps> = ({ data, max }) => {
const c = useClaudeTokens();
const [grown, setGrown] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setGrown(true));
return () => cancelAnimationFrame(id);
}, []);
const peak = max ?? Math.max(1, ...data.map((d) => d.value));
return (
<Box>
{data.map((d, i) => (
<Box key={d.label} sx={{ py: 0.7 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.35 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8125rem', fontWeight: 500 }}>{d.label}</Typography>
<Typography sx={{ color: c.text.secondary, fontSize: '0.8125rem', fontVariantNumeric: 'tabular-nums' }}>
{d.value.toLocaleString()}{d.suffix ? ` ${d.suffix}` : ''}
</Typography>
</Box>
<Box sx={{ height: 5, borderRadius: 3, background: c.border.subtle, overflow: 'hidden' }}>
<Box
sx={{
height: '100%',
borderRadius: 3,
background: c.accent.primary,
opacity: 0.55 + 0.45 * (1 - i / Math.max(1, data.length)),
width: grown ? `${Math.max(2, (d.value / peak) * 100)}%` : '0%',
transition: `width 620ms cubic-bezier(0.22,1,0.36,1) ${i * 45}ms`,
}}
/>
</Box>
</Box>
))}
</Box>
);
};
export default BarSeries;
@@ -0,0 +1,38 @@
import React, { useEffect, useRef, useState } from 'react';
interface CountUpProps {
value: number;
durationMs?: number;
format?: (n: number) => string;
}
// One-shot rAF that parks when it lands; a permanent loop here would cost the whole machine 60fps forever.
const CountUp: React.FC<CountUpProps> = ({ value, durationMs = 650, format }) => {
const [shown, setShown] = useState(value);
const fromRef = useRef(0);
useEffect(() => {
const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced || durationMs <= 0) { setShown(value); return undefined; }
const from = fromRef.current;
const delta = value - from;
if (delta === 0) { setShown(value); return undefined; }
let raf = 0;
const t0 = performance.now();
const tick = (now: number): void => {
const p = Math.min(1, (now - t0) / durationMs);
const eased = 1 - Math.pow(1 - p, 4);
setShown(from + delta * eased);
if (p < 1) raf = requestAnimationFrame(tick);
else fromRef.current = value;
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [value, durationMs]);
useEffect(() => { fromRef.current = value; }, [value]);
return <>{format ? format(shown) : Math.round(shown).toLocaleString()}</>;
};
export default CountUp;
@@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface DonutSlice {
label: string;
value: number;
color: string;
}
interface StatusDonutProps {
slices: DonutSlice[];
size?: number;
}
const P_R = 42;
const P_CIRC = 2 * Math.PI * P_R;
/** Donut whose arcs sweep in via stroke-dashoffset; SVG stroke transitions run on the compositor. */
const StatusDonut: React.FC<StatusDonutProps> = ({ slices, size = 116 }) => {
const c = useClaudeTokens();
const [drawn, setDrawn] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setDrawn(true));
return () => cancelAnimationFrame(id);
}, []);
const total = Math.max(1, slices.reduce((a, s) => a + s.value, 0));
let offset = 0;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
<Box sx={{ position: 'relative', width: size, height: size, flexShrink: 0 }}>
<svg width={size} height={size} viewBox="0 0 100 100" style={{ transform: 'rotate(-90deg)' }}>
<circle cx="50" cy="50" r={P_R} fill="none" stroke={c.border.subtle} strokeWidth="11" />
{slices.map((s, i) => {
const frac = s.value / total;
const dash = drawn ? frac * P_CIRC : 0;
const rot = offset;
offset += frac;
return (
<circle
key={s.label}
cx="50" cy="50" r={P_R} fill="none"
stroke={s.color} strokeWidth="11" strokeLinecap="butt"
strokeDasharray={`${dash} ${P_CIRC}`}
style={{
transform: `rotate(${rot * 360}deg)`,
transformOrigin: '50% 50%',
transition: `stroke-dasharray 700ms cubic-bezier(0.22,1,0.36,1) ${i * 110}ms`,
}}
/>
);
})}
</svg>
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: c.text.primary, fontSize: '1.05rem', fontWeight: 600, lineHeight: 1 }}>
{Math.round((slices[0]?.value ?? 0) / total * 100)}%
</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.625rem', mt: 0.25 }}>clean</Typography>
</Box>
</Box>
<Box>
{slices.map((s) => (
<Box key={s.label} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.35 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '2px', background: s.color, flexShrink: 0 }} />
<Typography sx={{ color: c.text.secondary, fontSize: '0.78125rem' }}>{s.label}</Typography>
<Typography sx={{ color: c.text.primary, fontSize: '0.78125rem', fontVariantNumeric: 'tabular-nums', ml: 0.5 }}>
{s.value.toLocaleString()}
</Typography>
</Box>
))}
</Box>
</Box>
);
};
export default StatusDonut;
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict';
import {
requestAppSlot,
releaseAppSlot,
wireBrowserLiveCounter,
totalLiveGuests,
MAX_LIVE_APP_WEBVIEWS as MAX,
} from './appWebviewBudget.ts';
@@ -65,3 +67,25 @@ test('re-requesting an already-live card just updates it, no extra slot', () =>
assert.equal(requestAppSlot('f-far', 9999, false), false, 'still full after a re-request');
release([...keys, 'f-far']);
});
test('the global ceiling counts browsers and apps together', () => {
// 8 live browsers reported: only 2 of the 6 app slots may actually go live.
wireBrowserLiveCounter(() => 8);
const keys: string[] = [];
let granted = 0;
for (let i = 0; i < 6; i++) {
const k = `g${i}`;
if (requestAppSlot(k, i, false)) { granted++; keys.push(k); }
}
assert.equal(granted, 2, `apps must stop at the global ceiling, granted ${granted}`);
assert.equal(totalLiveGuests(), 10);
wireBrowserLiveCounter(() => 0);
keys.forEach(releaseAppSlot);
});
test('pinned cards ignore the global ceiling, a working agent is never throttled', () => {
wireBrowserLiveCounter(() => 99);
assert.equal(requestAppSlot('pinned-work', 0, true), true);
wireBrowserLiveCounter(() => 0);
releaseAppSlot('pinned-work');
});
+24 -1
View File
@@ -10,6 +10,29 @@
*/
export const MAX_LIVE_APP_WEBVIEWS = 6;
// The GLOBAL ceiling across every guest renderer, browsers and apps together. Each side already had
// its own cap (browsers 8, apps 6) but neither knew the other existed, so a busy canvas could still
// stack 14 live renderers, and renderer memory pressure is exactly what evicts the wash (the
// background band) and, at the limit, what OOMs the app. Pinned/working cards stay exempt: a
// throttle must never blind an agent mid-task.
export const MAX_LIVE_GUESTS = 10;
// Browsers live in redux, not in this map; the suspend hook wires in a counter so this module stays
// store-free (and its tests stay pure).
let p_browserLiveCount: () => number = () => 0;
export function wireBrowserLiveCounter(fn: () => number): void {
p_browserLiveCount = fn;
}
export function totalLiveGuests(): number {
return live.size + p_browserLiveCount();
}
export function guestBudgetHasRoom(): boolean {
return totalLiveGuests() < MAX_LIVE_GUESTS;
}
interface Slot {
priority: number; // squared distance from viewport center; smaller = closer = kept when slots are scarce
pinned: boolean; // actively used: never counted against the cap, never evicted
@@ -43,7 +66,7 @@ export function requestAppSlot(key: string, priority: number, pinned: boolean):
existing.pinned = pinned;
return true;
}
if (pinned || evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS) {
if (pinned || (evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS && guestBudgetHasRoom())) {
live.set(key, { priority, pinned });
return true;
}
@@ -0,0 +1,75 @@
/**
* Run: node --test frontend/src/shared/styles/washBackground.test.ts
*
* The wash is the app's biggest evictable GPU texture, and every case here is about NOT allocating
* one we don't need. Chromium can drop a texture's tiles under memory pressure (many webviews, an
* external display) and paints the element's background-color in their place, which is the
* hard-edged rectangle of flat tint users report. A background-color is a compositor solid-colour
* quad and can never be evicted, so when the wash is one flat colour the image must not exist at all.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { washIsUniform, washBackgroundLayers, washUnderlayColor, washOpaqueBackgroundUrl, DEFAULT_WASH_STOPS, effectiveWashStops } from './washBackground.ts';
const PAGE = '#F5F4ED';
test('a single accent is uniform, so it needs no image', () => {
assert.equal(washIsUniform(['#B7CDEA']), true);
assert.equal(washBackgroundLayers(['#B7CDEA'], 0.17, PAGE, null), null);
});
test('repeated identical stops are uniform too (the boot-paint shape)', () => {
assert.equal(washIsUniform(['#B7CDEA', '#B7CDEA']), true);
assert.equal(washIsUniform(['#b7cdea', '#B7CDEA']), true, 'hex case must not decide this');
});
test('a real multi-stop gradient is NOT uniform and still paints', () => {
const stops = ['#B7CDEA', '#EFE0D2', '#E7BDD1'];
assert.equal(washIsUniform(stops), false);
const layers = washBackgroundLayers(stops, 0.17, PAGE, null);
assert.ok(layers && layers.image.includes('linear-gradient'));
assert.equal(layers!.size, '100% 100%');
});
test('for a uniform wash the tint IS the colour, so dropping the image changes no pixel', () => {
// The whole safety argument for skipping the image rests on these two being the same colour, so
// compare the numbers rather than the spelling (#eaedec vs rgba(234, 237, 236, 1)).
for (const accent of ['#B7CDEA', '#E7BDD1', '#3D3D3A', '#FFFFFF']) {
const tint = washUnderlayColor([accent], 0.17, PAGE);
const rgb = washOpaqueBackgroundUrl([accent], 0.17, PAGE).match(/\d+/g)!.slice(1, 4).map(Number);
const hex = [1, 3, 5].map((i) => parseInt(tint.slice(i, i + 2), 16));
assert.deepEqual(rgb, hex, `${accent}: image paints ${rgb}, background-color is ${hex}`);
}
});
test('grain alone still paints when the wash is uniform', () => {
const layers = washBackgroundLayers(['#B7CDEA'], 0.17, PAGE, 'url(grain)');
assert.deepEqual(layers, { image: 'url(grain)', size: 'auto', repeat: 'repeat' });
});
test('grain stacks above the gradient, in that order', () => {
const layers = washBackgroundLayers(['#B7CDEA', '#E7BDD1'], 0.17, PAGE, 'url(grain)');
assert.ok(layers!.image.startsWith('url(grain), '), 'grain must be the top layer');
assert.equal(layers!.size, 'auto, 100% 100%');
assert.equal(layers!.repeat, 'repeat, no-repeat');
});
test('no stops and no grain means no background image at all', () => {
assert.equal(washBackgroundLayers([], 0.17, PAGE, null), null);
assert.equal(washIsUniform([]), true);
});
test('the STOCK theme is uniform, so a default install cannot tear', () => {
// Everyone who never opened the theme pad lands here. Making this multi-stop again would put a
// full-window texture back under every default install, which is the band, so it is asserted.
assert.equal(washIsUniform(DEFAULT_WASH_STOPS), true, 'default wash must stay one flat colour');
assert.equal(washBackgroundLayers(DEFAULT_WASH_STOPS, 0.17, PAGE, null), null);
assert.equal(washIsUniform(effectiveWashStops(null, null)), true, 'no accent, no gradient');
assert.equal(washIsUniform(effectiveWashStops(null, '#B7CDEA')), true, 'a picked accent is one stop');
});
test('a user who picks a real gradient still gets one, texture and all', () => {
const chosen = ['#B7CDEA', '#E7BDD1'];
assert.equal(washIsUniform(effectiveWashStops(chosen, null)), false);
assert.ok(washBackgroundLayers(chosen, 0.17, PAGE, null));
});
+60 -12
View File
@@ -1,16 +1,22 @@
// Theme wash as an SVG IMAGE, not a CSS linear-gradient: Chromium caches a decoded image as a GPU
// texture, while a window-sized procedural gradient re-rasterizes on resize and, under GPU memory
// pressure (webviews, external monitors), those rasters get DROPPED and paint as a half/blank
// rectangle (the same class as the 1.5.9 dot-grid white-patch bug; see DashboardCanvas's grid note).
// The theme wash. Anything painted here is a full-window layer, so it is the app's single biggest
// piece of evictable GPU texture: keep it as cheap as the theme allows (see washIsUniform).
export function washBackgroundUrl(stops: string[], washOpacity: number): string {
const alpha = Math.max(0, Math.min(1, washOpacity));
// A native CSS gradient, not an SVG data-URL. The data-URL version was a decoded IMAGE resource:
// Chromium can evict its tiles under GPU memory pressure (many webviews, external displays) and
// paints the layer's background-color there instead, which is the hard-edged band users report.
// A gradient is a paint op on the layer itself, so there is no separate texture to drop, and it
// also stops shipping a ~119KB data-URL string on every theme render.
const stopEls = stops.map((hex, i) => {
const offset = stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100;
return `<stop offset='${offset}%' stop-color='${hex}' stop-opacity='${alpha}'/>`;
}).join('');
// x2/y2 approximate the CSS 115deg direction (25 degrees below horizontal).
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' preserveAspectRatio='none'><defs><linearGradient id='w' x1='0%' y1='0%' x2='90%' y2='42%'>${stopEls}</linearGradient></defs><rect width='100' height='100' fill='url(%23w)'/></svg>`;
return `url("data:image/svg+xml,${svg.replace(/#/g, '%23').replace(/'/g, '%27')}")`;
return `${p_rgba(hex, alpha)} ${offset}%`;
}).join(', ');
return `linear-gradient(115deg, ${stopEls})`;
}
function p_rgba(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff}, ${alpha})`;
}
function mixHex(a: string, b: string, t: number): string {
@@ -27,6 +33,44 @@ export function washOpaqueBackgroundUrl(stops: string[], washOpacity: number, pa
return washBackgroundUrl(blended, 1);
}
/**
* True when the wash is one flat colour, so painting it as an image would be pure waste.
*
* A single-accent theme (the common case) resolves to `linear-gradient(115deg, C 100%)` while the
* element's background-color is already exactly C, measured delta 0/255. That redundant image still
* costs a full-window texture, and a texture is the only thing Chromium can EVICT: dropping its
* tiles is what paints the hard-edged rectangle of flat tint people report. A background-color is a
* compositor solid-colour quad, which can never be evicted, so skipping the image doesn't just save
* memory, it makes the band unrepresentable for these themes.
*/
export function washIsUniform(stops: string[]): boolean {
return stops.length < 2 || stops.every((s) => s.toLowerCase() === stops[0].toLowerCase());
}
export interface WashLayers {
image: string;
size: string;
repeat: string;
}
/**
* The background layers a full-window wash surface should paint, or null for "colour is enough".
*
* Both painters (the shell and the canvas viewport) need the identical stack, and getting it wrong
* is what brings the band back, so it is derived once here rather than re-spelled at each site.
*/
export function washBackgroundLayers(
stops: string[], washOpacity: number, pageBg: string, grainUrl: string | null,
): WashLayers | null {
const wash = stops.length > 0 && !washIsUniform(stops)
? washOpaqueBackgroundUrl(stops, washOpacity, pageBg)
: '';
if (!wash && !grainUrl) return null;
if (!wash) return { image: grainUrl as string, size: 'auto', repeat: 'repeat' };
if (!grainUrl) return { image: wash, size: '100% 100%', repeat: 'no-repeat' };
return { image: `${grainUrl}, ${wash}`, size: 'auto, 100% 100%', repeat: 'repeat, no-repeat' };
}
// What an evicted/unrastered wash tile should paint as: the wash's mean tint, never raw page color.
export function washUnderlayColor(stops: string[], washOpacity: number, pageBg: string): string {
const alpha = Math.max(0, Math.min(1, washOpacity));
@@ -35,9 +79,13 @@ export function washUnderlayColor(stops: string[], washOpacity: number, pageBg:
return mixHex(pageBg, mean, alpha);
}
// Stock wallpaper when the user hasn't picked an accent yet: a designed blue-to-cream-to-pink
// gradient (contrasting stops), so a fresh install and the onboarding stage never look flat/white.
export const DEFAULT_WASH_STOPS = ['#B7CDEA', '#EFE0D2', '#E7BDD1'];
// Stock wallpaper when the user hasn't picked an accent yet. ONE stop on purpose: a multi-stop
// default needs a full-window texture, and Chromium fills any tile it drops with the layer's single
// background colour, which is why the gradient used to tear into a hard-edged rectangle under GPU
// pressure. This is the mean of the old blue-cream-pink trio, i.e. exactly the colour those torn
// tiles already painted, so the stock look is now what the worst case used to be. Picking any accent
// is also one stop; only a user-chosen gradient opts back into the texture.
export const DEFAULT_WASH_STOPS = ['#DACEDA'];
export function effectiveWashStops(gradient: string[] | null, accent: string | null): string[] {
if (gradient && gradient.length > 0) return gradient;
+79
View File
@@ -0,0 +1,79 @@
// Silent-failure UX sensors: rage clicks (the user telling us a button did nothing) and renderer
// wedge recoveries. Threshold-emission only; nothing here runs per-frame or per-render.
import { report } from '@/shared/serviceClient';
const RAGE_COUNT = 3;
const RAGE_WINDOW_MS = 2000;
const RAGE_THROTTLE_MS = 60_000;
let p_lastTarget: EventTarget | null = null;
let p_clickTimes: number[] = [];
let p_lastRageReport = 0;
function describeTarget(el: Element | null): string {
if (!el) return 'unknown';
const sel = el.closest('[data-select-type]');
if (sel) return sel.getAttribute('data-select-type') || 'card';
const btn = el.closest('button, [role="button"]');
if (btn) return (btn.getAttribute('aria-label') || btn.textContent || 'button').trim().slice(0, 40);
return el.tagName.toLowerCase();
}
// A main-thread task this long IS the freeze the user felt; measured live at 40,001ms in a forced
// wedge where Chromium's own `unresponsive` never fired, so this is the primary wedge sensor.
const WEDGE_MS = 3000;
const WEDGE_THROTTLE_MS = 60_000;
let p_lastWedgeReport = 0;
function installWedgeObserver(): () => void {
if (typeof PerformanceObserver === 'undefined') return () => {};
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) return () => {};
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const now = Date.now();
if (entry.duration >= WEDGE_MS && now - p_lastWedgeReport > WEDGE_THROTTLE_MS) {
p_lastWedgeReport = now;
report('process', 'wedge_recovered', { wedge_ms: Math.round(entry.duration), source: 'longtask' });
}
}
});
obs.observe({ entryTypes: ['longtask'] });
return () => obs.disconnect();
}
export function installUxSignals(): () => void {
const onClick = (e: MouseEvent): void => {
const now = Date.now();
if (e.target !== p_lastTarget) {
p_lastTarget = e.target;
p_clickTimes = [now];
return;
}
p_clickTimes = [...p_clickTimes.filter((t) => now - t < RAGE_WINDOW_MS), now];
if (p_clickTimes.length >= RAGE_COUNT && now - p_lastRageReport > RAGE_THROTTLE_MS) {
p_lastRageReport = now;
report('ux', 'rage_click', {
target: describeTarget(e.target as Element | null),
clicks: p_clickTimes.length,
});
p_clickTimes = [];
}
};
window.addEventListener('click', onClick, true);
const bridge = window as unknown as { openswarm?: { onWedge?: (cb: (info: { ms: number }) => void) => () => void } };
// Chromium's own hang signal stays wired as a second, independent witness (it costs nothing and
// catches hangs the observer can't see, e.g. a renderer stuck outside a task).
const offWedge = bridge.openswarm?.onWedge?.((info) => {
report('process', 'wedge_recovered', { wedge_ms: info?.ms ?? -1, source: 'chromium' });
});
const offObserver = installWedgeObserver();
const offMem = (bridge.openswarm as { onMemoryAlert?: (cb: (i: Record<string, number | string>) => void) => () => void } | undefined)?.onMemoryAlert?.((info) => {
report('process', 'memory_alert', info);
});
return () => {
window.removeEventListener('click', onClick, true);
offWedge?.();
offObserver();
offMem?.();
};
}
@@ -4,6 +4,7 @@ import { useVoiceDictation } from './useVoiceDictation';
import { playVoiceCue, configureVoiceCues } from './voiceCues';
import { setManualDictionary } from './voiceDictionary';
import { VoiceContext } from './voiceContext';
import { injectAtFocus, snapshotInjectTarget } from './injectAtFocus';
import VoiceOverlay from './VoiceOverlay';
// One recorder for the whole app. Both mics (the Help pill and the spawn composer) plus the global
@@ -103,6 +104,20 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode
return () => { offHold?.(); };
}, [pressStart, pressEnd]);
// Dev-only seam so the focus-drift guarantee (ENG-176) can be proven against the REAL module in
// the running app, not a unit stand-in. Compiled out of production builds.
useEffect(() => {
if (process.env.NODE_ENV === 'production') return undefined;
const onSnap = (): void => snapshotInjectTarget();
const onInject = (e: Event): void => { injectAtFocus(((e as CustomEvent<{ text?: string }>).detail?.text) ?? ''); };
window.addEventListener('osw-test:snapshot', onSnap);
window.addEventListener('osw-test:inject', onInject as EventListener);
return () => {
window.removeEventListener('osw-test:snapshot', onSnap);
window.removeEventListener('osw-test:inject', onInject as EventListener);
};
}, []);
// Wispr grammar: Esc while the mic is hot throws the take away.
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
+21 -2
View File
@@ -1,5 +1,6 @@
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { getWebview } from '@/shared/browserRegistry';
import { takeInjectSnapshot, setInjectSnapshot, isUsableTarget } from './injectTargetSnapshot';
// Dictation lands where the user's cursor actually is, like every real dictation tool: a focused
// in-app field gets the text typed in (undo-friendly, fires React input events), a focused browser
@@ -7,7 +8,14 @@ import { getWebview } from '@/shared/browserRegistry';
export type InjectTarget = 'field' | 'webview' | 'composer' | null;
export function injectAtFocus(text: string): InjectTarget {
const active = document.activeElement as HTMLElement | null;
const snap = takeInjectSnapshot();
// The cursor wins, not where you started. Wispr's grammar, and Eric's call: you dictate, you click
// where you want it, it lands there. This deliberately reverts the snapshot-first version, which
// pinned the text to the origin field and dropped it outright when that field went away.
// The snapshot is still the fallback for the case it was really built for: focus drifting to
// nothing typeable (a button, the body) while you were talking.
const live = document.activeElement as HTMLElement | null;
const active = isUsableTarget(live) ? live : snap.el;
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
try {
active.focus();
@@ -33,7 +41,7 @@ export function injectAtFocus(text: string): InjectTarget {
try { void focusedTag.insertText(text); return 'webview'; } catch { /* fall through */ }
}
// Last-interacted browser card: the user clicked a page field, then hit the hotkey.
const browserId = getLastInteractedBrowser();
const browserId = snap.browserId || getLastInteractedBrowser();
if (browserId) {
const wv = getWebview(browserId) as unknown as { insertText?: (t: string) => Promise<void>; focus?: () => void } | undefined;
if (wv?.insertText) {
@@ -44,3 +52,14 @@ export function injectAtFocus(text: string): InjectTarget {
window.dispatchEvent(new CustomEvent('openswarm:dictation-fallback', { detail: { text } }));
return 'composer';
}
/** Called at press-start so the words land where the user was looking, not where focus drifted. */
export function snapshotInjectTarget(): void {
// Only a typeable element counts as "aimed at". document.activeElement is <body> when nothing is
// focused, and storing that would read as a lost target later and swallow the composer fallback.
const active = document.activeElement as HTMLElement | null;
setInjectSnapshot({
el: isUsableTarget(active) ? active : null,
browserId: getLastInteractedBrowser(),
});
}
@@ -0,0 +1,52 @@
// ENG-176: the transcript belongs to the field the user was in when they STARTED speaking.
// Run: node --test --experimental-strip-types frontend/src/shared/voice/injectTargetSnapshot.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { setInjectSnapshot, clearInjectSnapshot, takeInjectSnapshot, isUsableTarget } from './injectTargetSnapshot.ts';
const field = (connected = true) => ({ tagName: 'TEXTAREA', isConnected: connected, isContentEditable: false }) as unknown as HTMLElement;
beforeEach(() => clearInjectSnapshot());
test('the snapshot keeps the field it was given (injectAtFocus decides precedence, not this)', () => {
const a = field();
setInjectSnapshot({ el: a, browserId: null });
assert.equal(takeInjectSnapshot().el, a);
});
test('a detached field is refused, so a dead origin can never be the destination', () => {
setInjectSnapshot({ el: field(false), browserId: null });
assert.equal(takeInjectSnapshot().el, null);
});
test('a non-typeable element never wins', () => {
assert.equal(isUsableTarget({ tagName: 'DIV', isConnected: true, isContentEditable: false } as unknown as HTMLElement), false);
assert.equal(isUsableTarget({ tagName: 'DIV', isConnected: true, isContentEditable: true } as unknown as HTMLElement), true);
assert.equal(isUsableTarget({ tagName: 'WEBVIEW', isConnected: true, isContentEditable: false } as unknown as HTMLElement), true);
});
test('taking consumes it, so one take can never leak into the next', () => {
setInjectSnapshot({ el: field(), browserId: 'b1' });
assert.equal(takeInjectSnapshot().browserId, 'b1');
assert.equal(takeInjectSnapshot().el, null);
assert.equal(takeInjectSnapshot().browserId, null);
});
test('a cancelled take leaves nothing behind', () => {
setInjectSnapshot({ el: field(), browserId: 'b2' });
clearInjectSnapshot();
assert.equal(takeInjectSnapshot().el, null);
});
// Precedence lives in injectAtFocus, and Eric's call is Wispr's: the CURSOR wins, not the origin.
// injectAtFocus needs a live DOM, so what is pinned here is the predicate that decides whether the
// live element is allowed to win at all. Getting this wrong is how the text lands in a stranger's box.
test('a live click target only beats the origin when it is really typeable', () => {
const typeable = { tagName: 'INPUT', isConnected: true, isContentEditable: false } as unknown as HTMLElement;
const button = { tagName: 'BUTTON', isConnected: true, isContentEditable: false } as unknown as HTMLElement;
const body = { tagName: 'BODY', isConnected: true, isContentEditable: false } as unknown as HTMLElement;
assert.equal(isUsableTarget(typeable), true, 'clicking another field must take the text');
assert.equal(isUsableTarget(button), false, 'clicking a button must NOT take the text');
assert.equal(isUsableTarget(body), false, 'clicking empty space must NOT take the text');
assert.equal(isUsableTarget(null), false);
});
@@ -0,0 +1,36 @@
// Focus drifts during the seconds of decode + polish (click another field, another app), so the
// dictation destination is snapshotted when the user starts speaking and preferred at paste time.
export interface InjectSnapshot {
el: HTMLElement | null;
browserId: string | null;
}
let p_snapshot: InjectSnapshot | null = null;
export function setInjectSnapshot(snap: InjectSnapshot): void {
p_snapshot = snap;
}
export function clearInjectSnapshot(): void {
p_snapshot = null;
}
/** A snapshot is only worth honoring while its element is still attached and still typeable. */
export function isUsableTarget(el: HTMLElement | null): boolean {
if (!el || !el.isConnected) return false;
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'WEBVIEW' || el.isContentEditable;
}
export interface TakenSnapshot extends InjectSnapshot {
/** We aimed at a real field and it died mid-decode. Distinct from never having aimed anywhere. */
targetLost: boolean;
}
/** Consumes the snapshot: reading it once is the whole contract, so a stale one can never linger. */
export function takeInjectSnapshot(): TakenSnapshot {
const snap = p_snapshot;
p_snapshot = null;
if (!snap) return { el: null, browserId: null, targetLost: false };
const usable = isUsableTarget(snap.el);
return { el: usable ? snap.el : null, browserId: snap.browserId, targetLost: !!snap.el && !usable };
}
@@ -3,7 +3,8 @@ import { API_BASE } from '@/shared/config';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { encodeWav, VOICE_SAMPLE_RATE } from './encodeWav';
import { playVoiceCue } from './voiceCues';
import { injectAtFocus } from './injectAtFocus';
import { injectAtFocus, snapshotInjectTarget } from './injectAtFocus';
import { clearInjectSnapshot } from './injectTargetSnapshot';
import { createSilenceDetector } from './createSilenceDetector';
import { pushDictation } from './voiceHistory';
import { learnFromTranscript, isDictionaryEcho } from './voiceDictionary';
@@ -158,6 +159,8 @@ export function useVoiceDictation() {
}
}
setError(null);
// Where the words belong is decided NOW, while the user is looking at it, not seconds later.
snapshotInjectTarget();
// Warm on the DOWN edge, before the mic prompt, so the model load overlaps the user starting to speak.
void window.openswarm?.voiceWarmup?.();
setPartial(null);
@@ -328,6 +331,7 @@ export function useVoiceDictation() {
// The capsule's X: throw the take away. No transcription, no cue, straight back to idle.
const cancel = useCallback((): void => {
if (stateRef.current !== 'recording') return;
clearInjectSnapshot();
const rec = recRef.current;
if (rec?.streaming) window.openswarm?.voiceStreamCancel?.();
teardown();

Some files were not shown because too many files have changed in this diff Show More