diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 7b24fc0b..b9248375 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -29,13 +29,13 @@ from backend.config.paths import SESSIONS_DIR from backend.apps.agents.core.error_classify import ( _NON_TRANSIENT_PATTERNS, _TRANSIENT_CAPACITY_PATTERNS, - _is_auth_error, - _is_free_trial_exhausted, - _is_long_context_error, - _is_out_of_tokens, - _is_transient_capacity_error, - _is_unknown_model_error, - _extract_reset_hint, + p_is_auth_error, + p_is_free_trial_exhausted, + p_is_long_context_error, + p_is_out_of_tokens, + p_is_transient_capacity_error, + p_is_unknown_model_error, + p_extract_reset_hint, ) from backend.apps.agents.manager.session.session_store import ( _delete_session_file, @@ -606,7 +606,7 @@ class AgentManager: # Native-scheduler tools that commit or mutate a recurring schedule. # Always-on MCP servers fall through to the always_allow default, so # these would otherwise fire silently; force them through ApprovalBar. - _SCHEDULE_GATED = { + p_SCHEDULE_GATED = { "mcp__openswarm-schedule__ScheduleWorkflow", "mcp__openswarm-schedule__UpdateScheduledWorkflow", "mcp__openswarm-schedule__DeleteScheduledWorkflow", @@ -744,7 +744,7 @@ class AgentManager: # twin of the crontab gate above: real, user-visible, hard-to-undo, # so it goes through ApprovalBar every time regardless of the # always_allow default that always-on MCP servers fall through to. - if tool_name in _SCHEDULE_GATED: + if tool_name in p_SCHEDULE_GATED: return "ask", None if tool_name == "Bash" and isinstance(tool_input, dict): bash_match = _match_bash_catastrophic_pattern(str(tool_input.get("command") or "")) @@ -866,7 +866,7 @@ class AgentManager: # Record which tools each step touched (in-memory; the executor/test # path persists step_usage once at run end). Captures every tool the # gate sees so a step's tool set is complete, not only the ones that - # prompted. No-op outside a workflow run or before a step is set. + # prompted. mem = p_approval_memory.get(session_id) if mem is None or mem.current_step_id is None: return @@ -3087,7 +3087,7 @@ class AgentManager: _ticker_task = None stderr_snapshot = "\n".join(_stderr_buffer[-50:]) if ( - _is_transient_capacity_error(e, extra_text=stderr_snapshot) + p_is_transient_capacity_error(e, extra_text=stderr_snapshot) and capacity_retry_attempt < len(_CAPACITY_BACKOFFS) ): wait = _CAPACITY_BACKOFFS[capacity_retry_attempt] @@ -3177,7 +3177,7 @@ class AgentManager: # subsequent step (title gen, follow-up tool turn, etc.). # Don't blast a "context exceeded" card over a completed reply. _streamed_substantive = bool(stream_text_msg_id) and _current_turn_emitted - if _streamed_substantive and _is_long_context_error(e, extra_text=_stderr_tail): + if _streamed_substantive and p_is_long_context_error(e, extra_text=_stderr_tail): # Mark the session completed (not error), keep the assistant # reply visible, and skip the overflow card. The next user # turn will properly hit the pre-send guard if the chat is @@ -3192,7 +3192,7 @@ class AgentManager: except Exception: pass return - if _is_long_context_error(e, extra_text=_stderr_tail): + if p_is_long_context_error(e, extra_text=_stderr_tail): friendly_msg = ( "This conversation has grown too large for your account's " "standard context window. Long-context requests require an " @@ -3236,7 +3236,7 @@ class AgentManager: }) except Exception: logger.debug("submit_diagnostic for context_overflow failed", exc_info=True) - elif _is_free_trial_exhausted(e, extra_text=_stderr_tail): + elif p_is_free_trial_exhausted(e, extra_text=_stderr_tail): # Free runs spent. Flip back to own_key and show a friendly # "connect a model" upsell instead of a raw 402. try: @@ -3259,12 +3259,12 @@ class AgentManager: "session_id": session_id, "message": error_msg.model_dump(mode="json"), }) - elif _is_out_of_tokens(e, extra_text=_stderr_tail): + elif p_is_out_of_tokens(e, extra_text=_stderr_tail): # Usage/quota spent (plan cap, API credit balance, provider quota). # Not a bug and not transient: tell the user plainly it's a token # limit and let them wait for the reset or switch models. Drives the # blocking out_of_tokens card (same slot as auth/context_overflow). - _reset = _extract_reset_hint(f"{e!s}\n{_stderr_tail}") + _reset = p_extract_reset_hint(f"{e!s}\n{_stderr_tail}") friendly_msg = ( f"You're out of tokens on {session.model or 'this model'}. " "This isn't a bug, and nothing's wrong on your end. Your usage " @@ -3284,7 +3284,7 @@ class AgentManager: "session_id": session_id, "message": error_msg.model_dump(mode="json"), }) - elif _is_auth_error(e, extra_text=_stderr_tail): + elif p_is_auth_error(e, extra_text=_stderr_tail): # Three sub-cases the user can hit, with distinct fixes: # 1. "No credentials for provider: claude", user picked a # -cc route but doesn't have Claude Pro/Max connected @@ -3350,7 +3350,7 @@ class AgentManager: "session_id": session_id, "message": error_msg.model_dump(mode="json"), }) - elif _is_unknown_model_error(e, extra_text=_stderr_tail): + elif p_is_unknown_model_error(e, extra_text=_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. @@ -3372,7 +3372,7 @@ class AgentManager: "session_id": session_id, "message": error_msg.model_dump(mode="json"), }) - elif _is_transient_capacity_error(e, extra_text=_stderr_tail): + elif p_is_transient_capacity_error(e, extra_text=_stderr_tail): friendly_msg = ( "provider_rate_limit: This model hit your account or " "session rate limit. Wait until the reset time shown by " diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 2a84bd90..95a8ad21 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -37,7 +37,7 @@ _NON_TRANSIENT_PATTERNS = re.compile( ) -def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool: +def p_is_long_context_error(exc: BaseException, extra_text: str = "") -> bool: """True when the upstream error is the 'long context tier required' 429. Used by the catch-all error path to emit a friendly context-overflow @@ -54,7 +54,7 @@ def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool: )) -def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool: +def p_is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool: """True when the cloud says the machine's free runs are spent (a 402 with type free_trial_exhausted). The catch-all path uses this to flip back to own_key and show a friendly connect-a-model upsell instead of a raw error. @@ -69,13 +69,7 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool: )) -def _is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool: - """True when the turn was rejected because the user's usage/quota is spent: a - plan cap, API credit balance, or provider quota. This is a 'wait for the reset - window or switch models' situation, NOT a transient rate-limit blip (handled by - _is_transient_capacity_error) and NOT an auth/connection failure. Drives the - friendly 'just a token issue' card. - """ +def p_is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool: combined = f"{exc!s}\n{extra_text}".strip() if not combined: return False @@ -93,11 +87,9 @@ def _is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool: )) -def _extract_reset_hint(text: str) -> str: +def p_extract_reset_hint(text: str) -> str: """Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of a provider usage error so we can tell the user when their limit comes back. - Keeps the leading preposition so it slots into 'It resets .'. Empty when - the provider didn't say. """ if not text: return "" @@ -109,13 +101,10 @@ def _extract_reset_hint(text: str) -> str: return m.group(1).strip() if m else "" -def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: +def p_is_auth_error(exc: BaseException, extra_text: str = "") -> bool: """True when the upstream error is a 401/403 auth failure. - Used by the catch-all error path to surface a friendly "subscription - expired / reconnect" card instead of dumping the raw 401 JSON. The most - common cause: the OpenSwarm Pro bearer or 9Router OAuth token has expired - while the UI still shows the connection as 'connected'. + expired / reconnect" card instead of dumping the raw 401 JSON. """ combined = f"{exc!s}\n{extra_text}".strip() if not combined: @@ -133,7 +122,7 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: )) -def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool: +def p_is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool: """True when the upstream rejects the model code itself (e.g. a ChatGPT/Codex subscription whose plan doesn't expose the GPT model id we send: code 1211 'Unknown Model, please check the model code'). The fix isn't retry, it's a @@ -153,7 +142,7 @@ def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool: )) -def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool: +def p_is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool: # The Claude CLI's underlying ProcessError stringifies to a generic # "Command failed with exit code 1 / Check stderr output for details"; # the real cause (rate_limit_error / No pool capacity available / 429 diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index ac2ef86a..a9f2228f 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -24,7 +24,7 @@ PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") -def _local_timezone_name() -> str: +def p_local_timezone_name() -> str: name = os.environ.get("OPENSWARM_TIMEZONE", "").strip() if not name: try: @@ -311,8 +311,8 @@ def _call(method: str, path: str, body=None) -> dict: def _build_schedule_from_preset(preset: str, args: dict) -> dict: - local_tz = _local_timezone_name() - base = {"timezone": args.get("timezone") or local_tz, "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0} + local_tz = p_local_timezone_name() + base = {"timezone": args.get("timezone") or local_tz, "ends_at": None, "max_runs": None, "runs_count": 0} if preset == "custom": return { **base, diff --git a/backend/tests/test_free_trial.py b/backend/tests/test_free_trial.py index d553e044..4c77f446 100644 --- a/backend/tests/test_free_trial.py +++ b/backend/tests/test_free_trial.py @@ -5,8 +5,8 @@ import backend # noqa: F401 (path sanity asserted below) from backend.apps.settings.models import AppSettings from backend.apps.settings.credentials import proxy_auth from backend.apps.agents.core.error_classify import ( - _is_free_trial_exhausted, - _is_transient_capacity_error, + p_is_free_trial_exhausted, + p_is_transient_capacity_error, ) from backend.apps.agents.providers.registry import resolve_model_id_for_sdk from backend.apps.subscription.free_trial import _has_own_model @@ -41,11 +41,11 @@ def test_free_trial_resolves_to_a_bare_anthropic_id(): def test_exhaustion_is_classified_and_not_retried(): - assert _is_free_trial_exhausted(Exception("error type free_trial_exhausted")) - assert _is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs")) - assert not _is_free_trial_exhausted(Exception("overloaded, try again")) + assert p_is_free_trial_exhausted(Exception("error type free_trial_exhausted")) + assert p_is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs")) + assert not p_is_free_trial_exhausted(Exception("overloaded, try again")) # Must NOT look transient, or the agent loop would retry a spent trial forever. - assert not _is_transient_capacity_error(Exception("free_trial_exhausted")) + assert not p_is_transient_capacity_error(Exception("free_trial_exhausted")) def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits(): @@ -53,7 +53,7 @@ def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits(): '{"subtype":"api_retry","data":{"error_status":429,' '"error":"rate_limit","max_retries":10}}' ) - assert _is_transient_capacity_error( + assert p_is_transient_capacity_error( Exception("Command failed with exit code 1"), extra_text=system_event_tail, ) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6b7f0bf3..89d6c27a 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -592,8 +592,8 @@ def test_router_auth_pattern_does_not_falsely_match_normal_text(): def test_is_auth_error_classifier(): - """The classifier at agent_manager.py:_is_auth_error covers many shapes.""" - from backend.apps.agents.agent_manager import _is_auth_error + """The classifier at agent_manager.py:p_is_auth_error covers many shapes.""" + from backend.apps.agents.agent_manager import p_is_auth_error # Real shapes that must be caught matches = [ @@ -606,7 +606,7 @@ def test_is_auth_error_classifier(): Exception("Provider not configured: gemini"), ] for e in matches: - assert _is_auth_error(e), f"should match: {e}" + assert p_is_auth_error(e), f"should match: {e}" # Non-auth errors must not match non_matches = [ @@ -616,15 +616,15 @@ def test_is_auth_error_classifier(): Exception("File not found"), ] for e in non_matches: - assert not _is_auth_error(e), f"should NOT match: {e}" + assert not p_is_auth_error(e), f"should NOT match: {e}" def test_is_auth_error_with_stderr_tail(): """The classifier also reads stderr buffer text.""" - from backend.apps.agents.agent_manager import _is_auth_error + from backend.apps.agents.agent_manager import p_is_auth_error e = Exception("Command failed with exit code 1") stderr = "...\n[codex/gpt-5.5] [401]: Provided authentication token is expired" - assert _is_auth_error(e, extra_text=stderr) + assert p_is_auth_error(e, extra_text=stderr) # =========================================================================== diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 5a220063..dc09a23d 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -35,6 +35,13 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const dispatch = useAppDispatch(); const workflows = useAppSelector((s) => Object.values(s.workflows.items)); const allPaused = useAppSelector((s) => s.workflows.paused); + // Live clock for the "now" line; a snapshot would drift and refDate may be + // a navigated week, so it can't double as the current moment. + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(id); + }, []); // Right-click menu: pinned position + the workflow whose pill was // clicked. Same anchor pattern as MUI's menu examples. const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null); @@ -163,6 +170,8 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const start = startOfWeek(today); const days = Array.from({ length: 7 }, (_, i) => addDays(start, i)); const HOURS = HOURS_24; + const nowColIdx = days.findIndex((d) => sameDay(d, now)); + const nowTopPx = (now.getHours() + now.getMinutes() / 60) * SLOT_H; // Prefer the short zone name ("PDT", "EST", "JST") so the label // reads in plain English instead of "GMT-7". formatToParts is wide- // supported; if it ever fails we degrade silently rather than show @@ -194,7 +203,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD ); })} - + {HOURS.map((hour, hourIdx) => ( {/* Hour label sits inside its row (top-aligned) rather than @@ -247,6 +256,17 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD })} ))} + {nowColIdx >= 0 && ( + + + + )} {ctxMenuEl} @@ -264,7 +284,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD reads cleanly in both light and dark themes. */} {WEEKDAY_LABEL_SHORT.map((l, i) => ( - {l} + {l} ))} @@ -357,7 +377,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer', '&:hover .ev-title': { color: accent }, }}> - + {e.workflow.title} {formatTime(e.date.getHours(), e.date.getMinutes())} @@ -412,7 +432,7 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext bgcolor: accent + '14', color: c.text.primary, borderLeft: `3px solid ${accent}`, - borderRadius: '4px', + borderRadius: c.radius.sm, px: 0.65, py: 0, fontSize: eventFontSize, fontWeight: 500, overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', @@ -435,7 +455,7 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext minWidth: 20, px: 0.4, bgcolor: accent + '22', color: accent, - borderRadius: '4px', + borderRadius: c.radius.sm, fontSize: eventFontSize, fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', '&:hover': { bgcolor: accent + '33' },