diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index 7266b6a4..cd6f5e91 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -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,
diff --git a/backend/apps/agents/core/flight_recorder.py b/backend/apps/agents/core/flight_recorder.py
new file mode 100644
index 00000000..07171eb0
--- /dev/null
+++ b/backend/apps/agents/core/flight_recorder.py
@@ -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
diff --git a/backend/apps/agents/core/is_router_unavailable_error.py b/backend/apps/agents/core/is_router_unavailable_error.py
new file mode 100644
index 00000000..cc3be237
--- /dev/null
+++ b/backend/apps/agents/core/is_router_unavailable_error.py
@@ -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,
+ ))
diff --git a/backend/apps/agents/manager/configure_provider_env.py b/backend/apps/agents/manager/configure_provider_env.py
index a7fa44f5..028263c5 100644
--- a/backend/apps/agents/manager/configure_provider_env.py
+++ b/backend/apps/agents/manager/configure_provider_env.py
@@ -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}. "
diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py
index de67d6a6..dc90f6b1 100644
--- a/backend/apps/agents/manager/run/RunOptions.py
+++ b/backend/apps/agents/manager/run/RunOptions.py
@@ -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}")
diff --git a/backend/apps/agents/manager/run/TurnRunner.py b/backend/apps/agents/manager/run/TurnRunner.py
index 38d59121..cc247713 100644
--- a/backend/apps/agents/manager/run/TurnRunner.py
+++ b/backend/apps/agents/manager/run/TurnRunner.py
@@ -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} "
diff --git a/backend/apps/agents/manager/run/client_pool.py b/backend/apps/agents/manager/run/client_pool.py
index 910fd6ab..1f281527 100644
--- a/backend/apps/agents/manager/run/client_pool.py
+++ b/backend/apps/agents/manager/run/client_pool.py
@@ -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.
diff --git a/backend/apps/agents/manager/run/empty_finish.py b/backend/apps/agents/manager/run/empty_finish.py
index 908c2bb7..1a1d0b79 100644
--- a/backend/apps/agents/manager/run/empty_finish.py
+++ b/backend/apps/agents/manager/run/empty_finish.py
@@ -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
diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py
index 92b5a4c3..3ebac036 100644
--- a/backend/apps/agents/manager/run/handle_run_error.py
+++ b/backend/apps/agents/manager/run/handle_run_error.py
@@ -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()
diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py
index 9e5a4cd1..72dbf00c 100644
--- a/backend/apps/agents/manager/run/run_options_helpers.py
+++ b/backend/apps/agents/manager/run/run_options_helpers.py
@@ -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 (
diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py
index 5b958144..ca23310d 100644
--- a/backend/apps/agents/manager/session/SessionLifecycle.py
+++ b/backend/apps/agents/manager/session/SessionLifecycle.py
@@ -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"
diff --git a/backend/apps/agents/manager/streaming/note_provider_retry.py b/backend/apps/agents/manager/streaming/note_provider_retry.py
new file mode 100644
index 00000000..75166a6d
--- /dev/null
+++ b/backend/apps/agents/manager/streaming/note_provider_retry.py
@@ -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)
diff --git a/backend/apps/agents/manager/streaming/state.py b/backend/apps/agents/manager/streaming/state.py
index 96ca7c72..ae1b8562 100644
--- a/backend/apps/agents/manager/streaming/state.py
+++ b/backend/apps/agents/manager/streaming/state.py
@@ -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
diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py
index d1f735a9..e0cfa999 100644
--- a/backend/apps/agents/schedule_mcp_server.py
+++ b/backend/apps/agents/schedule_mcp_server.py
@@ -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:
diff --git a/backend/apps/help/bundle.py b/backend/apps/help/bundle.py
index 48e7caf1..07a19415 100644
--- a/backend/apps/help/bundle.py
+++ b/backend/apps/help/bundle.py
@@ -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:
diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py
new file mode 100644
index 00000000..5f5ab08b
--- /dev/null
+++ b/backend/apps/help/changelog.py
@@ -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}
diff --git a/backend/apps/help/knowledge.py b/backend/apps/help/knowledge.py
index 78db0d59..2031d865 100644
--- a/backend/apps/help/knowledge.py
+++ b/backend/apps/help/knowledge.py
@@ -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,
"",
"",
+ "",
+ "What actually changed in this build. Answer \"what's new\" from THIS, never from memory.",
+ help_context_block(app_version),
+ "",
+ "",
"",
"The complete list of issues shipped with this build. You cannot see live bug reports.",
p_issues_block(),
diff --git a/backend/apps/memory/distill.py b/backend/apps/memory/distill.py
index e4f46e2f..ad8c8423 100644
--- a/backend/apps/memory/distill.py
+++ b/backend/apps/memory/distill.py
@@ -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\n" + tail + "\n\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\n" + tail + "\n\n\nExtract the facts."
chunks: List[str] = []
async with client.messages.stream(
model=aux_model,
diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py
index b08128dd..5a95d91b 100644
--- a/backend/apps/nine_router/process.py
+++ b/backend/apps/nine_router/process.py
@@ -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()
diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md
index 4d2e2346..9b3bc21c 100644
--- a/backend/apps/outputs/app_builder_skill.md
+++ b/backend/apps/outputs/app_builder_skill.md
@@ -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
diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py
index 604e3d2b..f18654de 100644
--- a/backend/apps/outputs/outputs.py
+++ b/backend/apps/outputs/outputs.py
@@ -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
diff --git a/backend/apps/outputs/reap_ghost_runtimes.py b/backend/apps/outputs/reap_ghost_runtimes.py
new file mode 100644
index 00000000..a72344b9
--- /dev/null
+++ b/backend/apps/outputs/reap_ghost_runtimes.py
@@ -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=`, 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
diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py
index 84192ee1..0f69e874 100644
--- a/backend/apps/outputs/runtime.py
+++ b/backend/apps/outputs/runtime.py
@@ -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.
diff --git a/backend/apps/outputs/runtime_proc.py b/backend/apps/outputs/runtime_proc.py
index e5c56a48..8498f1dc 100644
--- a/backend/apps/outputs/runtime_proc.py
+++ b/backend/apps/outputs/runtime_proc.py
@@ -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
diff --git a/backend/apps/outputs/webapp_template/backend/apps/store/__init__.py b/backend/apps/outputs/webapp_template/backend/apps/store/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/outputs/webapp_template/backend/apps/store/store.py b/backend/apps/outputs/webapp_template/backend/apps/store/store.py
new file mode 100644
index 00000000..24849a90
--- /dev/null
+++ b/backend/apps/outputs/webapp_template/backend/apps/store/store.py
@@ -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
diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py
index 3a84ae29..705f340b 100644
--- a/backend/apps/service/client.py
+++ b/backend/apps/service/client.py
@@ -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})
diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py
index 2da912b8..b7c12422 100644
--- a/backend/apps/service/service.py
+++ b/backend/apps/service/service.py
@@ -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)),
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index 320b9a01..c8c1d5e0 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -33,8 +33,10 @@ DEFAULT_SYSTEM_PROMPT = (
"4. **Unsure which server.** `MCPList` for a cheap survey, or "
'`MCPSearch("")` 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 "
diff --git a/backend/apps/settings/store.py b/backend/apps/settings/store.py
index 00ddcacd..12fc6afb 100644
--- a/backend/apps/settings/store.py
+++ b/backend/apps/settings/store.py
@@ -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",
diff --git a/backend/apps/skill_registry/parse_install_command.py b/backend/apps/skill_registry/parse_install_command.py
new file mode 100644
index 00000000..99e0bd05
--- /dev/null
+++ b/backend/apps/skill_registry/parse_install_command.py
@@ -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 `, 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 | npm i skills | bunx skills add
+ 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
diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py
index 5822b64b..9a5e447b 100644
--- a/backend/apps/skill_registry/skill_registry.py
+++ b/backend/apps/skill_registry/skill_registry.py
@@ -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.
diff --git a/backend/apps/workflows/default_model.py b/backend/apps/workflows/default_model.py
new file mode 100644
index 00000000..8be7f88e
--- /dev/null
+++ b/backend/apps/workflows/default_model.py
@@ -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"
diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py
index b1a3ab55..27f22ca5 100644
--- a/backend/apps/workflows/executor.py
+++ b/backend/apps/workflows/executor.py
@@ -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.
diff --git a/backend/apps/workflows/storage.py b/backend/apps/workflows/storage.py
index fced0452..01cda893 100644
--- a/backend/apps/workflows/storage.py
+++ b/backend/apps/workflows/storage.py
@@ -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.
diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py
index 6375f154..d3349fed 100644
--- a/backend/apps/workflows/workflows.py
+++ b/backend/apps/workflows/workflows.py
@@ -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
diff --git a/backend/tests/test_agent_wont_run_paused_workflow.py b/backend/tests/test_agent_wont_run_paused_workflow.py
new file mode 100644
index 00000000..4348dd1d
--- /dev/null
+++ b/backend/tests/test_agent_wont_run_paused_workflow.py
@@ -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")
diff --git a/backend/tests/test_capacity_retry.py b/backend/tests/test_capacity_retry.py
index 1f06a7c3..8a2e7004 100644
--- a/backend/tests/test_capacity_retry.py
+++ b/backend/tests/test_capacity_retry.py
@@ -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"
diff --git a/backend/tests/test_changelog.py b/backend/tests/test_changelog.py
new file mode 100644
index 00000000..54bf9e4b
--- /dev/null
+++ b/backend/tests/test_changelog.py
@@ -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
diff --git a/backend/tests/test_default_prompt_migration.py b/backend/tests/test_default_prompt_migration.py
new file mode 100644
index 00000000..dd9963d5
--- /dev/null
+++ b/backend/tests/test_default_prompt_migration.py
@@ -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
diff --git a/backend/tests/test_deleted_workflow_stays_dead.py b/backend/tests/test_deleted_workflow_stays_dead.py
new file mode 100644
index 00000000..cfa2f75a
--- /dev/null
+++ b/backend/tests/test_deleted_workflow_stays_dead.py
@@ -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
diff --git a/backend/tests/test_disabled_workflow_never_runs.py b/backend/tests/test_disabled_workflow_never_runs.py
new file mode 100644
index 00000000..265dc604
--- /dev/null
+++ b/backend/tests/test_disabled_workflow_never_runs.py
@@ -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
diff --git a/backend/tests/test_every_error_carries_an_envelope.py b/backend/tests/test_every_error_carries_an_envelope.py
new file mode 100644
index 00000000..082a7b53
--- /dev/null
+++ b/backend/tests/test_every_error_carries_an_envelope.py
@@ -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"
diff --git a/backend/tests/test_flight_envelope_is_complete.py b/backend/tests/test_flight_envelope_is_complete.py
new file mode 100644
index 00000000..d9a90e71
--- /dev/null
+++ b/backend/tests/test_flight_envelope_is_complete.py
@@ -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}"
diff --git a/backend/tests/test_flight_recorder.py b/backend/tests/test_flight_recorder.py
new file mode 100644
index 00000000..fc7a3ded
--- /dev/null
+++ b/backend/tests/test_flight_recorder.py
@@ -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)
diff --git a/backend/tests/test_parse_install_command.py b/backend/tests/test_parse_install_command.py
new file mode 100644
index 00000000..42563728
--- /dev/null
+++ b/backend/tests/test_parse_install_command.py
@@ -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
diff --git a/backend/tests/test_parse_install_command_refusals.py b/backend/tests/test_parse_install_command_refusals.py
new file mode 100644
index 00000000..d8328f63
--- /dev/null
+++ b/backend/tests/test_parse_install_command_refusals.py
@@ -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
diff --git a/backend/tests/test_paused_workflow_leaves_nothing_queued.py b/backend/tests/test_paused_workflow_leaves_nothing_queued.py
new file mode 100644
index 00000000..606c5b6f
--- /dev/null
+++ b/backend/tests/test_paused_workflow_leaves_nothing_queued.py
@@ -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"
diff --git a/backend/tests/test_provider_retry_ledger.py b/backend/tests/test_provider_retry_ledger.py
new file mode 100644
index 00000000..a62e5014
--- /dev/null
+++ b/backend/tests/test_provider_retry_ledger.py
@@ -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
diff --git a/backend/tests/test_reap_ghost_runtimes.py b/backend/tests/test_reap_ghost_runtimes.py
new file mode 100644
index 00000000..ec9647fa
--- /dev/null
+++ b/backend/tests/test_reap_ghost_runtimes.py
@@ -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=, 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"
diff --git a/backend/tests/test_router_unavailable_envelope.py b/backend/tests/test_router_unavailable_envelope.py
new file mode 100644
index 00000000..3f84a3c8
--- /dev/null
+++ b/backend/tests/test_router_unavailable_envelope.py
@@ -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
diff --git a/backend/tests/test_scheduled_stop_on_pause_trash.py b/backend/tests/test_scheduled_stop_on_pause_trash.py
index ea2bd7ae..211f060e 100644
--- a/backend/tests/test_scheduled_stop_on_pause_trash.py
+++ b/backend/tests/test_scheduled_stop_on_pause_trash.py
@@ -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):
diff --git a/backend/tests/test_step_edit_survives_dead_aux.py b/backend/tests/test_step_edit_survives_dead_aux.py
new file mode 100644
index 00000000..8cf17472
--- /dev/null
+++ b/backend/tests/test_step_edit_survives_dead_aux.py
@@ -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
diff --git a/backend/tests/test_sync_workflow_test.py b/backend/tests/test_sync_workflow_test.py
new file mode 100644
index 00000000..1afdebf5
--- /dev/null
+++ b/backend/tests/test_sync_workflow_test.py
@@ -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
diff --git a/backend/tests/test_workflow_run_admission.py b/backend/tests/test_workflow_run_admission.py
new file mode 100644
index 00000000..f002abf4
--- /dev/null
+++ b/backend/tests/test_workflow_run_admission.py
@@ -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()
diff --git a/electron/crashDumpScan.js b/electron/crashDumpScan.js
new file mode 100644
index 00000000..ca2b6de9
--- /dev/null
+++ b/electron/crashDumpScan.js
@@ -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 };
diff --git a/electron/crashDumpScan.test.js b/electron/crashDumpScan.test.js
new file mode 100644
index 00000000..2f41df79
--- /dev/null
+++ b/electron/crashDumpScan.test.js
@@ -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`);
diff --git a/electron/main.js b/electron/main.js
index d746424a..c73c63b7 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -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;
diff --git a/electron/memorySensor.js b/electron/memorySensor.js
new file mode 100644
index 00000000..d0402406
--- /dev/null
+++ b/electron/memorySensor.js
@@ -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 };
diff --git a/electron/memorySensor.test.js b/electron/memorySensor.test.js
new file mode 100644
index 00000000..d4708c62
--- /dev/null
+++ b/electron/memorySensor.test.js
@@ -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);
+});
diff --git a/electron/package-lock.json b/electron/package-lock.json
index 8594350e..55d9f92a 100644
--- a/electron/package-lock.json
+++ b/electron/package-lock.json
@@ -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": {
diff --git a/electron/package.json b/electron/package.json
index 4ace0993..d44895af 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -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": {
diff --git a/electron/preload.js b/electron/preload.js
index 978d18bb..a8011822 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -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'),
diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js
index a642bd0c..5def232f 100644
--- a/electron/voiceHotkey.js
+++ b/electron/voiceHotkey.js
@@ -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 };
diff --git a/electron/voiceHotkeyStray.test.js b/electron/voiceHotkeyStray.test.js
new file mode 100644
index 00000000..2011fd8d
--- /dev/null
+++ b/electron/voiceHotkeyStray.test.js
@@ -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');
diff --git a/frontend/public/index.html b/frontend/public/index.html
index cc93caf7..a651cecf 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -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;
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index c7abb24c..7b9ece9f 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -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(() => {
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index 1cbdc6f9..753cba04 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -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 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. */}
+
+
);
diff --git a/frontend/src/app/components/Layout/WhatsNewCard.tsx b/frontend/src/app/components/Layout/WhatsNewCard.tsx
new file mode 100644
index 00000000..a366aac0
--- /dev/null
+++ b/frontend/src/app/components/Layout/WhatsNewCard.tsx
@@ -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(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 (
+
+
+
+ {`What's new in ${note.version}`}
+
+
+ {note.headline}
+
+
+ {lines.slice(0, 5).map((l) => (
+
+ {l.t}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts b/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts
index dcb807d4..ec5b1ac2 100644
--- a/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts
+++ b/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts
@@ -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 [
diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx
index 29e66e20..b6a57533 100644
--- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx
+++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx
@@ -222,6 +222,10 @@ export const DefaultToolBubble: React.FC = ({
) : (
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(
- ({ 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(
onNewAgent();
}}
onAddBrowser={onAddBrowser}
- onAddApp={handleOpenViewPicker}
+ onAddApp={onOpenApplications}
onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
onHistory={handleOpenHistory}
/>
diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
index 95e75390..4a5a3168 100644
--- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
+++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
@@ -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 = ({
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(
``,
)}")`, [dotSpacing, dotSize, c.border.medium]);
@@ -425,6 +428,15 @@ const DashboardCanvas: React.FC = ({
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 = ({
: '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 && (
-
- )}
{/* 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. */}
= ({
onToolbarCancel={onToolbarCancel}
onToolbarSend={onToolbarSend}
onAddView={onAddView}
+ onOpenApplications={handleToggleApps}
onHistoryResume={onHistoryResume}
onAddBrowser={onAddBrowser}
onNewAgentBounceEnd={onNewAgentBounceEnd}
diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx
index b8ae2ae0..fe85adbf 100644
--- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx
+++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx
@@ -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 = ({
onToolbarCancel,
onToolbarSend,
onAddView,
+ onOpenApplications,
onHistoryResume,
onAddBrowser,
onNewAgentBounceEnd,
@@ -97,6 +99,7 @@ const DashboardOverlays: React.FC = ({
onCancel={onToolbarCancel}
onSend={onToolbarSend}
onAddView={onAddView}
+ onOpenApplications={onOpenApplications}
onHistoryResume={onHistoryResume}
onAddBrowser={onAddBrowser}
dashboardId={dashboardId}
diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardWindowCards.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardWindowCards.tsx
index 3dff7cba..2b8f178a 100644
--- a/frontend/src/app/pages/Dashboard/canvas/DashboardWindowCards.tsx
+++ b/frontend/src/app/pages/Dashboard/canvas/DashboardWindowCards.tsx
@@ -42,6 +42,10 @@ const DashboardWindowCards: React.FC = ({
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 = ({
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 = ({
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 = ({
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 = ({
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}
diff --git a/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts b/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts
index 8627671d..98099eda 100644
--- a/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts
+++ b/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts
@@ -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;
diff --git a/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts b/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts
index 66fd3564..10bc4d95 100644
--- a/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts
+++ b/frontend/src/app/pages/Dashboard/canvas/tiledGeometry.ts
@@ -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(`[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);
}
diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx
index 97d0d6a2..9a10ac2f 100644
--- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx
+++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx
@@ -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 = ({
}, [activeUrl, activeTabId]);
const webviewMap = useRef