diff --git a/coding-agent/docs/adr/0044-bee-launch-surfaces.md b/coding-agent/docs/adr/0044-bee-launch-surfaces.md index d0baf44f0..10a4594b0 100644 --- a/coding-agent/docs/adr/0044-bee-launch-surfaces.md +++ b/coding-agent/docs/adr/0044-bee-launch-surfaces.md @@ -146,13 +146,14 @@ Keep deferred: - [x] `test_bee_task_lifecycle_resume_retry_cancel_abort` - [x] `test_scheduled_bee_launch_creates_task_and_links_schedule` - [x] `test_proactive_signal_bee_launch_creates_task_and_links_signal` -- [ ] `test_console_bee_launch_renders_safe_launch_summary` -- [ ] `test_bee_launch_metrics_omit_high_cardinality_ids` -- [ ] `test_bee_launch_e2e_smoke` +- [x] `test_console_bee_launch_renders_safe_launch_summary` +- [x] `test_console_bee_launch_renders_durable_launch_store_summary` +- [x] `test_observability_platform_bee_launch_metrics_are_low_cardinality` +- [x] `test_bee_launch_e2e_smoke` - [x] `uv run pytest tests/coding_agent/test_bee_launch.py -v` - [x] `uv run pytest tests/coding_agent/test_scheduled_runs.py -v` -- [ ] `uv run pytest tests/ui/test_developer_console.py -v` -- [ ] `git diff --check -- .` +- [x] `uv run pytest tests/ui/test_developer_console.py -v` +- [x] `git diff --check -- .` ## References diff --git a/coding-agent/docs/bee_launch/GOAL_PROGRESS.md b/coding-agent/docs/bee_launch/GOAL_PROGRESS.md index f18341891..cfd909748 100644 --- a/coding-agent/docs/bee_launch/GOAL_PROGRESS.md +++ b/coding-agent/docs/bee_launch/GOAL_PROGRESS.md @@ -558,3 +558,85 @@ This ledger tracks G118-G126 for the Bee Launch / Scheduled Bee Task Integration - Proactive replay validates existing launch source, signal_id, session_id, and topic_id before returning an existing result. - Remaining risks: - Console launch views and launch metrics remain pending. + +## G126_BEE_LAUNCH_CONSOLE_OBSERVABILITY_E2E + +### Before + +- Goal id: G126_BEE_LAUNCH_CONSOLE_OBSERVABILITY_E2E +- Intended files: + - `docs/bee_launch/GOAL_PROGRESS.md` + - `docs/bee_launch/IMPLEMENTATION_REPORT.md` + - `docs/bee_launch/USAGE.md` + - `docs/adr/0044-bee-launch-surfaces.md` + - `src/coding_agent/bee_launch.py` + - `src/coding_agent/observability.py` + - `src/coding_agent/ui/developer_console.py` + - `src/coding_agent/ui/http_server.py` + - `tests/coding_agent/test_bee_launch.py` + - `tests/coding_agent/test_observability_platform_smoke.py` + - `tests/ui/test_developer_console.py` +- Verification commands: + - `uv run pytest tests/coding_agent/test_bee_launch.py -v` + - `uv run pytest tests/coding_agent/test_observability_platform_smoke.py -v` + - `uv run pytest tests/ui/test_developer_console.py -v` + - `uv run pytest tests/coding_agent/test_bee_command_bridge.py -v` + - `uv run pytest tests/coding_agent/test_bee_workspace.py -v` + - `uv run pytest tests/coding_agent/test_bee_runtime.py -v` + - `uv run pytest tests/coding_agent/test_scheduled_runs.py -v` + - `uv run pytest tests/coding_agent/test_topic_layer_smoke.py -v` + - `uv run pytest tests/integration/test_durable_runtime_smoke.py -v` + - `uv run pytest tests/coding_agent/test_context_system_smoke.py -v` + - `uv run pytest tests/coding_agent/action_safety/test_safe_action_smoke.py -v` + - `uv run ruff format --check src/coding_agent/bee_launch.py src/coding_agent/observability.py src/coding_agent/ui/developer_console.py src/coding_agent/ui/http_server.py tests/coding_agent/test_bee_launch.py tests/coding_agent/test_observability_platform_smoke.py tests/ui/test_developer_console.py` + - `uv run ruff check src/coding_agent/bee_launch.py src/coding_agent/observability.py src/coding_agent/ui/developer_console.py src/coding_agent/ui/http_server.py tests/coding_agent/test_bee_launch.py tests/coding_agent/test_observability_platform_smoke.py tests/ui/test_developer_console.py` + - `git diff --check -- .` +- Stop criteria: + - Console Bee page renders safe launch list/detail summary with launch source, status, and linked template/task/topic/schedule/signal fields. + - Prometheus launch metrics use only low-cardinality source/status/kind labels. + - Manual, scheduled, proactive, lifecycle, and safety smoke coverage remains deterministic. + - Final implementation and usage docs exist. + - No external executor, homelab-specific, Argo/K8s, nmem, desktop, bridge, or multi-agent work is introduced. + +### After + +- Changed files: + - `docs/bee_launch/GOAL_PROGRESS.md` + - `docs/bee_launch/IMPLEMENTATION_REPORT.md` + - `docs/bee_launch/USAGE.md` + - `docs/adr/0044-bee-launch-surfaces.md` + - `src/coding_agent/bee_launch.py` + - `src/coding_agent/observability.py` + - `src/coding_agent/ui/developer_console.py` + - `src/coding_agent/ui/http_server.py` + - `tests/coding_agent/test_bee_launch.py` + - `tests/coding_agent/test_observability_platform_smoke.py` + - `tests/ui/test_developer_console.py` +- Tests run: + - `uv run pytest tests/coding_agent/test_bee_launch.py -q` + - `uv run pytest tests/coding_agent/test_observability_platform_smoke.py -q` + - `uv run pytest tests/ui/test_developer_console.py -q` + - `uv run pytest tests/coding_agent/test_bee_command_bridge.py -q` + - `uv run pytest tests/coding_agent/test_bee_workspace.py -q` + - `uv run pytest tests/coding_agent/test_bee_runtime.py -q` + - `uv run pytest tests/coding_agent/test_scheduled_runs.py -q` + - `uv run pytest tests/coding_agent/test_topic_layer_smoke.py -q` + - `uv run pytest tests/integration/test_durable_runtime_smoke.py -q` + - `uv run pytest tests/coding_agent/test_context_system_smoke.py -q` + - `uv run pytest tests/coding_agent/action_safety/test_safe_action_smoke.py -q` + - `uv run pytest tests/coding_agent/evaluation/ -q` + - `uv run pytest tests/dogfood/test_workspace_provider_demo.py -q` + - `uv run pytest tests/agentkit/runtime/test_pipeline.py -k "build_context or runtime_stage_spans" -q` + - `uv run pytest tests/coding_agent/test_observability.py -q` + - `uv run pytest tests/coding_agent/test_observability_local_stack.py -q` + - `uv run ruff format --check src/coding_agent/bee_launch.py src/coding_agent/observability.py src/coding_agent/ui/developer_console.py src/coding_agent/ui/http_server.py tests/coding_agent/test_bee_launch.py tests/coding_agent/test_observability_platform_smoke.py tests/ui/test_developer_console.py` + - `uv run ruff check src/coding_agent/bee_launch.py src/coding_agent/observability.py src/coding_agent/ui/developer_console.py src/coding_agent/ui/http_server.py tests/coding_agent/test_bee_launch.py tests/coding_agent/test_observability_platform_smoke.py tests/ui/test_developer_console.py` + - `git diff --check -- .` +- Results: + - Console Bee page now renders Bee launch summaries from the durable Bee launch store, with run metadata as fallback, including launch source, status, template, task, topic, schedule, signal, and safe error fields. + - Launch orchestration records fail-open Prometheus launch metrics, and Prometheus exposes `bee_launches_total`, `bee_launch_duration_seconds`, `scheduled_bee_launches_total`, and `proactive_bee_launches_total` using only low-cardinality labels. + - Added final Bee launch smoke coverage across manual, scheduled, and proactive launch paths. + - Added final Bee launch usage and implementation report documentation. +- Remaining risks: + - Console launch visibility is list-oriented; a deeper per-launch detail route can be added later without changing launch semantics. + - External executor adapters remain intentionally deferred. diff --git a/coding-agent/docs/bee_launch/IMPLEMENTATION_REPORT.md b/coding-agent/docs/bee_launch/IMPLEMENTATION_REPORT.md new file mode 100644 index 000000000..916d734f6 --- /dev/null +++ b/coding-agent/docs/bee_launch/IMPLEMENTATION_REPORT.md @@ -0,0 +1,66 @@ +# Bee Launch Implementation Report + +## Scope + +G118-G126 completed the Bee Launch / Scheduled Bee Task Integration phase. +The phase adds a safe, durable, topic-aware launch surface for manual, +scheduled, and proactive Bee task creation. + +The implementation stays in the Coding Agent product layer. AgentKit Core, +durable runtime semantics, topic semantics, workspace policy, action safety, +Bee command bridge behavior, and observability contracts remain unchanged. + +## Landed Goals + +- G118 mapped current Bee runtime, workspace contract, command bridge, schedule, + proactive signal, console, and observability surfaces. +- G119 accepted ADR-0044 for Bee launch surfaces. +- G120 added durable Bee launch records and store behavior. +- G121 added workspace template resolution and input binding. +- G122 added manual Bee launch through the product-layer orchestrator. +- G123 added bounded resume, retry, cancel, and abort lifecycle controls. +- G124 connected schedules to Bee launch. +- G125 connected proactive signals to Bee launch. +- G126 added console launch visibility, low-cardinality launch metrics, final + smoke coverage, and usage documentation. + +## Safety Boundaries + +- Launch does not execute arbitrary commands. +- Bee nodes remain evidence-gated and policy-bound through the Bee command + bridge before execution. +- Schedules and proactive signals cannot bypass command policy, workspace + policy, action safety, validation, approval, or HITL. +- Prometheus metrics do not use `launch_id`, `task_id`, `topic_id`, `run_id`, + `session_id`, `schedule_id`, `signal_id`, or `node_id` labels. +- Console output avoids raw prompt, message, content, command output, stdout, + stderr, env, and secrets. +- The Developer Console reads durable Bee launch records when a PG store is + configured and falls back to sanitized run metadata otherwise. + +## Verification + +Primary verification commands for the final state: + +```bash +uv run pytest tests/coding_agent/test_bee_launch.py -v +uv run pytest tests/coding_agent/test_observability_platform_smoke.py -v +uv run pytest tests/ui/test_developer_console.py -v +uv run pytest tests/coding_agent/test_bee_command_bridge.py -v +uv run pytest tests/coding_agent/test_bee_workspace.py -v +uv run pytest tests/coding_agent/test_bee_runtime.py -v +uv run pytest tests/coding_agent/test_scheduled_runs.py -v +uv run pytest tests/coding_agent/test_topic_layer_smoke.py -v +uv run pytest tests/integration/test_durable_runtime_smoke.py -v +uv run pytest tests/coding_agent/test_context_system_smoke.py -v +uv run pytest tests/coding_agent/action_safety/test_safe_action_smoke.py -v +uv run ruff format --check src/coding_agent/bee_launch.py src/coding_agent/observability.py src/coding_agent/ui/developer_console.py src/coding_agent/ui/http_server.py tests/coding_agent/test_bee_launch.py tests/coding_agent/test_observability_platform_smoke.py tests/ui/test_developer_console.py +uv run ruff check src/coding_agent/bee_launch.py src/coding_agent/observability.py src/coding_agent/ui/developer_console.py src/coding_agent/ui/http_server.py tests/coding_agent/test_bee_launch.py tests/coding_agent/test_observability_platform_smoke.py tests/ui/test_developer_console.py +git diff --check -- . +``` + +## Deferred + +External executor adapters, Docker/Kubernetes/Argo execution, Argo CD +integration, nmem integration, homelab-specific templates, desktop app, bridge, +and multi-agent task graph remain out of scope. diff --git a/coding-agent/docs/bee_launch/USAGE.md b/coding-agent/docs/bee_launch/USAGE.md new file mode 100644 index 000000000..7ac97ff21 --- /dev/null +++ b/coding-agent/docs/bee_launch/USAGE.md @@ -0,0 +1,83 @@ +# Bee Launch Usage + +Bee launch is the product-layer entry point for starting workspace-local Bee +tasks. It resolves a `.bee/templates/` template, binds safe inputs, +creates or continues a Topic, creates a durable BeeTask, and writes `task.json` +when workspace artifacts are enabled. + +Launch itself does not execute command intents. Bee nodes remain pending until +execution goes through the Bee command bridge, workspace binding, command policy, +action safety, validation, and HITL where required. + +## Launch Sources + +- Manual launch uses `BeeLaunchOrchestrator.launch_manual()`. +- Scheduled launch uses `ScheduledBeeLaunchOrchestrator.launch_due()`. +- Proactive signal launch uses `ProactiveBeeLaunchOrchestrator.launch_signal()`. + +All launch sources create a `BeeLaunchRecord` with `launch_id`, `source`, +`template_id`, `status`, optional `task_id`, optional `topic_id`, and optional +schedule or signal linkage. + +## Lifecycle Controls + +`BeeTaskLifecycleController` provides bounded controls: + +- `resume_task()` marks only incomplete ready work for continuation. +- `retry_node()` retries failed nodes and preserves prior evidence. +- `cancel_task()` records a cancelled terminal task. +- `abort_task()` records an aborted terminal task and preserves task metadata. + +Lifecycle controls do not execute commands and do not bypass existing safety +policy. + +## Console + +The Developer Console Bee page is available at: + +```text +/console/bee +``` + +It shows Bee tasks, node launches, workspace templates, workspace run artifacts, +command intents, and Bee launch summaries. Launch summaries include source, +status, template, task, topic, schedule, and signal links where available. Raw +prompt, message, command output, stdout, stderr, env, and secrets are not +rendered. + +When PostgreSQL-backed stores are configured, launch summaries come from the +durable Bee launch store. In local fixture mode the console can still fall back +to sanitized run metadata. + +## Metrics + +Bee launch metrics are low cardinality: + +```text +bee_launches_total{source,status} +bee_launch_duration_seconds{source,status} +scheduled_bee_launches_total{status} +proactive_bee_launches_total{kind,status} +``` + +Identifier labels such as `launch_id`, `task_id`, `topic_id`, `run_id`, +`session_id`, `schedule_id`, `signal_id`, and `node_id` are intentionally not +exported. + +## Verification + +Representative local checks: + +```bash +uv run pytest tests/coding_agent/test_bee_launch.py -v +uv run pytest tests/coding_agent/test_observability_platform_smoke.py -v +uv run pytest tests/ui/test_developer_console.py -v +uv run pytest tests/coding_agent/test_bee_command_bridge.py -v +uv run pytest tests/coding_agent/test_bee_workspace.py -v +uv run pytest tests/coding_agent/test_bee_runtime.py -v +uv run pytest tests/coding_agent/test_scheduled_runs.py -v +git diff --check -- . +``` + +External executor, Kubernetes, Argo, nmem, desktop, bridge, and multi-agent +execution are deferred. diff --git a/coding-agent/src/coding_agent/bee_launch.py b/coding-agent/src/coding_agent/bee_launch.py index 149c93c63..1624fea05 100644 --- a/coding-agent/src/coding_agent/bee_launch.py +++ b/coding-agent/src/coding_agent/bee_launch.py @@ -31,6 +31,7 @@ load_bee_workspace_template, write_bee_workspace_run_artifacts, ) +from coding_agent.observability import record_bee_launch_metric from coding_agent.scheduled_runs import ScheduledLaunchIntent, ScheduleTriggerRecord from coding_agent.topic_lifecycle import TopicLifecycle from coding_agent.topic_store import JSONObject, JSONValue, TopicRecord @@ -800,6 +801,15 @@ async def _launch( launched_at=launch_started_at, metadata={"phase": "task_created", "node_count": len(nodes)}, ) + record_bee_launch_metric( + source=attached.source, + status=attached.status, + duration_ms=max( + 0.0, + (launch_started_at - plan.requested_at).total_seconds() * 1000.0, + ), + proactive_kind="unknown", + ) return BeeLaunchResult( launch_id=attached.launch_id, task_id=task_id, diff --git a/coding-agent/src/coding_agent/observability.py b/coding-agent/src/coding_agent/observability.py index 2574b0138..ff6460217 100644 --- a/coding-agent/src/coding_agent/observability.py +++ b/coding-agent/src/coding_agent/observability.py @@ -10,7 +10,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from threading import RLock -from typing import Any +from typing import Any, Final import httpx @@ -221,6 +221,15 @@ "topic_status": frozenset({"open", "finalized", "aborted"}), "trigger_kind": frozenset({"proactive_signal", "schedule", "unknown"}), } +_BEE_LAUNCH_SOURCES: Final[frozenset[str]] = frozenset( + {"manual", "schedule", "proactive_signal"} +) +_BEE_LAUNCH_STATUSES: Final[frozenset[str]] = frozenset( + {"planned", "launching", "launched", "failed", "cancelled"} +) +_BEE_PROACTIVE_SIGNAL_KINDS: Final[frozenset[str]] = frozenset( + {"repo_activity", "unknown"} +) _PROMETHEUS_HISTOGRAM_BUCKETS = (5.0, 10.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 5000.0) _PROMETHEUS_HTTP_HISTOGRAM_BUCKETS = ( 1.0, @@ -535,6 +544,39 @@ def record_storage_operation( max(0.0, float(duration_ms)), ) + def record_bee_launch( + self, + *, + source: str, + status: str, + duration_ms: float, + proactive_kind: str = "unknown", + ) -> None: + labels = { + "source": _prometheus_metric_part(source, _BEE_LAUNCH_SOURCES), + "status": _prometheus_metric_part(status, _BEE_LAUNCH_STATUSES), + } + with self._lock: + self._inc("bee_launches_total", labels) + self._observe( + "bee_launch_duration_seconds", + labels, + max(0.0, float(duration_ms)) / 1000.0, + ) + if source == "schedule": + self._inc("scheduled_bee_launches_total", {"status": labels["status"]}) + elif source == "proactive_signal": + self._inc( + "proactive_bee_launches_total", + { + "kind": _prometheus_metric_part( + proactive_kind, + _BEE_PROACTIVE_SIGNAL_KINDS, + ), + "status": labels["status"], + }, + ) + def exposition_text(self) -> str: lines: list[str] = [] with self._lock: @@ -669,6 +711,56 @@ def exposition_text(self) -> str: buckets=_PROMETHEUS_HISTOGRAM_BUCKETS, ) ) + bee_launch_counters = self._bee_launch_counters + bee_launch_histograms = self._bee_launch_histograms + if bee_launch_counters: + lines.extend( + ( + "# HELP bee_launches_total Bee launches by source and status.", + "# TYPE bee_launches_total counter", + ) + ) + lines.extend( + self._format_metric("bee_launches_total", bee_launch_counters) + ) + lines.extend( + ( + "# HELP scheduled_bee_launches_total Scheduled Bee launches.", + "# TYPE scheduled_bee_launches_total counter", + ) + ) + lines.extend( + self._format_metric( + "scheduled_bee_launches_total", + bee_launch_counters, + ) + ) + lines.extend( + ( + "# HELP proactive_bee_launches_total Proactive Bee launches.", + "# TYPE proactive_bee_launches_total counter", + ) + ) + lines.extend( + self._format_metric( + "proactive_bee_launches_total", + bee_launch_counters, + ) + ) + if bee_launch_histograms: + lines.extend( + ( + "# HELP bee_launch_duration_seconds Bee launch duration in seconds.", + "# TYPE bee_launch_duration_seconds histogram", + ) + ) + lines.extend( + self._format_histograms( + histograms=bee_launch_histograms, + metric_name="bee_launch_duration_seconds", + buckets=_PROMETHEUS_HISTOGRAM_BUCKETS, + ) + ) return "\n".join(lines) + ("\n" if lines else "") def reset(self) -> None: @@ -748,6 +840,31 @@ def _storage_operation_histograms( if key[0] == "coding_agent_storage_operation_duration_ms" } + @property + def _bee_launch_counters( + self, + ) -> dict[tuple[str, tuple[tuple[str, str], ...]], float]: + return { + key: value + for key, value in self._counters.items() + if key[0] + in { + "bee_launches_total", + "scheduled_bee_launches_total", + "proactive_bee_launches_total", + } + } + + @property + def _bee_launch_histograms( + self, + ) -> dict[tuple[str, tuple[tuple[str, str], ...]], list[float]]: + return { + key: value + for key, value in self._histograms.items() + if key[0] == "bee_launch_duration_seconds" + } + def _inc(self, metric: str, labels: Mapping[str, str], amount: float = 1.0) -> None: key = (metric, tuple(sorted(labels.items()))) self._counters[key] = self._counters.get(key, 0.0) + amount @@ -877,6 +994,24 @@ def record_storage_operation_metric( return +def record_bee_launch_metric( + *, + source: str, + status: str, + duration_ms: float, + proactive_kind: str = "unknown", +) -> None: + try: + _DEFAULT_PROMETHEUS_RECORDER.record_bee_launch( + source=source, + status=status, + duration_ms=duration_ms, + proactive_kind=proactive_kind, + ) + except Exception: + return + + def reset_prometheus_metrics() -> None: _DEFAULT_PROMETHEUS_RECORDER.reset() diff --git a/coding-agent/src/coding_agent/ui/developer_console.py b/coding-agent/src/coding_agent/ui/developer_console.py index bb4c1ef39..0a9f8d33d 100644 --- a/coding-agent/src/coding_agent/ui/developer_console.py +++ b/coding-agent/src/coding_agent/ui/developer_console.py @@ -359,6 +359,19 @@ class ConsoleBeeCommandIntentSummary: evidence_status: str | None = None +@dataclass(frozen=True) +class ConsoleBeeLaunchSummary: + launch_id: str + source: str + status: str + template_id: str | None + task_id: str | None + topic_id: str | None + schedule_id: str | None = None + signal_id: str | None = None + error_summary: str | None = None + + @dataclass(frozen=True) class ConsoleBeePage: tasks: tuple[ConsoleBeeTaskSummary, ...] @@ -366,6 +379,7 @@ class ConsoleBeePage: templates: tuple[ConsoleBeeTemplateSummary, ...] = () run_artifacts: tuple[ConsoleBeeRunArtifactSummary, ...] = () commands: tuple[ConsoleBeeCommandIntentSummary, ...] = () + launches: tuple[ConsoleBeeLaunchSummary, ...] = () @dataclass(frozen=True) @@ -704,6 +718,7 @@ def render_console_bee_page(page_summary: ConsoleBeePage) -> str: f"{_bee_template_table(page_summary.templates)}" f"{_bee_run_artifact_table(page_summary.run_artifacts)}" f"{_bee_command_intent_table(page_summary.commands)}" + f"{_bee_launch_table(page_summary.launches)}" ) return _html_document(title=page.title, body=body, active_path=page.path) @@ -1581,6 +1596,40 @@ def _bee_command_intent_table( ) +def _bee_launch_table(launches: tuple[ConsoleBeeLaunchSummary, ...]) -> str: + if not launches: + return _empty_state( + "Bee Launches", + "No data loaded yet. No Bee launches are available.", + ) + rows = [] + for launch in launches: + rows.append( + "" + f"{escape(launch.launch_id)}" + f"{escape(launch.source)}" + f'{escape(launch.status)}' + f"{escape(launch.template_id or '-')}" + f"{escape(launch.task_id or '-')}" + f"{escape(launch.topic_id or '-')}" + f"{escape(launch.schedule_id or '-')}" + f"{escape(launch.signal_id or '-')}" + f"{escape(launch.error_summary or '-')}" + "" + ) + return ( + '
' + "

Bee Launches

" + '' + "" + "" + "" + f"{''.join(rows)}" + "
Launch IDSourceStatusTemplate IDTask IDTopic IDSchedule IDSignal IDError
" + "
" + ) + + def _topic_related_runs_section(runs: tuple[ConsoleRunSummary, ...]) -> str: if not runs: return _empty_state("Runs Under Topic", "No related runs are available.") diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 857abfcd2..719b2742e 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -14,7 +14,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, Literal, cast -from collections.abc import AsyncIterator, Mapping +from collections.abc import AsyncIterator, Iterable, Mapping from urllib.parse import urlsplit import httpx @@ -38,6 +38,7 @@ discover_bee_workspace_templates, load_bee_workspace_command_intents, ) +from coding_agent.bee_launch import BeeLaunchRecord, PGBeeLaunchStore from coding_agent.runtime_store import ( AgentRunRecord, RunMessageSnapshotRecord, @@ -88,6 +89,7 @@ from coding_agent.ui.developer_console import ( ConsoleActionSummary, ConsoleActionValidationSummary, + ConsoleBeeLaunchSummary, ConsoleBeeNodeSummary, ConsoleBeePage, ConsoleBeeCommandIntentSummary, @@ -3321,6 +3323,7 @@ async def _console_schedules_page( async def _console_bee_page(auth_context: AuthContext | None) -> ConsoleBeePage: runs = await _visible_console_runs(auth_context) nodes = tuple(node for run in runs for node in _bee_node_summaries_from_run(run)) + launch_summaries = await _bee_launch_summaries(auth_context, runs) tasks_by_id: dict[str, ConsoleBeeTaskSummary] = {} for node in nodes: current = tasks_by_id.get(node.task_id) @@ -3350,6 +3353,7 @@ async def _console_bee_page(auth_context: AuthContext | None) -> ConsoleBeePage: return ConsoleBeePage( tasks=tuple(sorted(tasks_by_id.values(), key=lambda item: item.task_id)), nodes=tuple(sorted(nodes, key=lambda item: (item.task_id, item.node_id))), + launches=launch_summaries, templates=( _console_bee_workspace_template_summaries() if can_view_workspace_artifacts @@ -3368,6 +3372,84 @@ async def _console_bee_page(auth_context: AuthContext | None) -> ConsoleBeePage: ) +async def _bee_launch_summaries( + auth_context: AuthContext | None, + runs: Iterable[AgentRunRecord], +) -> tuple[ConsoleBeeLaunchSummary, ...]: + launch_summaries = { + launch.launch_id: launch + for launch in await _bee_launch_summaries_from_store(auth_context) + } + for launch in _bee_launch_summaries_from_runs(runs): + launch_summaries.setdefault(launch.launch_id, launch) + return tuple(sorted(launch_summaries.values(), key=lambda item: item.launch_id)) + + +async def _bee_launch_summaries_from_store( + auth_context: AuthContext | None, +) -> tuple[ConsoleBeeLaunchSummary, ...]: + store = _console_bee_launch_store() + if store is None: + return () + launches: list[BeeLaunchRecord] = [] + try: + if auth_context is None or auth_context.scope == "admin": + launches = await store.list_launches(limit=100) + else: + for session_id in await _visible_console_session_ids(auth_context): + launches.extend( + await store.list_launches(session_id=session_id, limit=100) + ) + except Exception: + logger.exception( + "Console Bee launch store list failed; falling back to run metadata" + ) + return () + return tuple(_bee_launch_summary_from_record(launch) for launch in launches) + + +def _bee_launch_summary_from_record( + launch: BeeLaunchRecord, +) -> ConsoleBeeLaunchSummary: + return ConsoleBeeLaunchSummary( + launch_id=safe_id_value(launch.launch_id), + source=safe_label_value(launch.source), + status=safe_label_value(launch.status), + template_id=safe_id_value(launch.template_id), + task_id=safe_id_value(launch.task_id), + topic_id=safe_id_value(launch.topic_id), + schedule_id=safe_id_value(launch.schedule_id), + signal_id=safe_id_value(launch.signal_id), + error_summary=safe_error_summary(launch.error_message or launch.error_type), + ) + + +def _bee_launch_summaries_from_runs( + runs: Iterable[AgentRunRecord], +) -> tuple[ConsoleBeeLaunchSummary, ...]: + launches: dict[str, ConsoleBeeLaunchSummary] = {} + for run in runs: + metadata = run.metadata + launch_id = safe_id_value(metadata.get("launch_id")) + if not launch_id: + continue + launch_source = safe_label_value(metadata.get("launch_source")) + if launch_source not in {"manual", "schedule", "proactive_signal"}: + continue + launches[launch_id] = ConsoleBeeLaunchSummary( + launch_id=launch_id, + source=launch_source, + status=safe_label_value(metadata.get("launch_status")) or run.status, + template_id=safe_id_value(metadata.get("template_id")), + task_id=safe_id_value(metadata.get("task_id")), + topic_id=safe_id_value(metadata.get("topic_id")), + schedule_id=safe_id_value(metadata.get("schedule_id")), + signal_id=safe_id_value(metadata.get("signal_id")), + error_summary=safe_error_summary(metadata.get("launch_error")), + ) + return tuple(sorted(launches.values(), key=lambda item: item.launch_id)) + + def _can_view_global_console_artifacts(auth_context: AuthContext | None) -> bool: return auth_context is None or auth_context.scope == "admin" @@ -3569,6 +3651,21 @@ def _console_topic_store() -> PGTopicStore | None: return None +def _console_bee_launch_store() -> PGBeeLaunchStore | None: + try: + storage_config = _load_storage_config() + except Exception: + logger.exception("Unable to load storage config for console Bee launch store") + return None + if not _storage_uses_pg_http_sessions(storage_config): + return None + try: + return PGBeeLaunchStore(pool=session_manager.pg_pool) + except Exception: + logger.exception("Unable to initialize console Bee launch store") + return None + + async def _auth_context_can_access_topic( auth_context: AuthContext | None, topic: TopicRecord, diff --git a/coding-agent/tests/coding_agent/test_bee_launch.py b/coding-agent/tests/coding_agent/test_bee_launch.py index 3f0648eb8..85f520e5c 100644 --- a/coding-agent/tests/coding_agent/test_bee_launch.py +++ b/coding-agent/tests/coding_agent/test_bee_launch.py @@ -21,6 +21,7 @@ build_bee_launch_plan, ) from coding_agent.bee_runtime import BeeNodeRecord, BeeTaskLifecycle, BeeTaskRecord +from coding_agent.observability import prometheus_metrics_text, reset_prometheus_metrics from coding_agent.scheduled_runs import ScheduledLaunchIntent, ScheduleTriggerRecord from coding_agent.topic_lifecycle import TopicLifecycle from coding_agent.topic_store import JSONObject, TopicAnchorRecord, TopicRecord @@ -1403,6 +1404,109 @@ async def test_proactive_signal_bee_launch_rejects_existing_launch_mismatch( assert trigger_store.triggers == {} +@pytest.mark.asyncio +async def test_bee_launch_e2e_smoke(tmp_path: Path) -> None: + reset_prometheus_metrics() + _write_template(tmp_path, template_id="template-alpha") + launch_store = PGBeeLaunchStore(pool=FakeBeeLaunchPool()) # type: ignore[arg-type] + task_store = FakeBeeTaskStore() + topic_store = FakeTopicStore() + trigger_store = FakeScheduleTriggerStore() + clock = FakeClock() + launcher = BeeLaunchOrchestrator( + launch_store=launch_store, + task_store=task_store, + topic_store=topic_store, + topic_lifecycle=TopicLifecycle( + store=topic_store, + now=clock, + topic_id_factory=lambda: f"topic-{len(topic_store.topics) + 1}", + ), + now=clock, + task_id_factory=lambda plan, topic: f"bee-task-{plan.launch_id}", + ) + scheduled_launcher = ScheduledBeeLaunchOrchestrator( + launcher=launcher, + trigger_store=trigger_store, + launch_id_factory=lambda intent: f"launch-{intent.trigger_id}", + ) + signal_launcher = ProactiveBeeLaunchOrchestrator( + launcher=launcher, + trigger_store=trigger_store, + launch_id_factory=lambda intent: f"launch-{intent.trigger_id}", + ) + tape = Tape(tape_id="tape-alpha") + + manual = await launcher.launch_manual( + BeeLaunchRequest( + launch_id="launch-manual", + source="manual", + template_id="template-alpha", + workspace_root=tmp_path, + inputs={"region": "us-test-1"}, + workspace_policy={"artifact_mode": "enabled"}, + requested_at=_dt(9), + ), + tape=tape, + write_workspace_artifacts=True, + ) + scheduled = await scheduled_launcher.launch_due( + _scheduled_intent(topic_id=None), + template_id="template-alpha", + workspace_root=tmp_path, + tape=tape, + inputs={"region": "us-test-1"}, + ) + proactive = await signal_launcher.launch_signal( + _signal_intent(topic_id=None), + template_id="template-alpha", + workspace_root=tmp_path, + tape=tape, + inputs={"region": "us-test-1"}, + ) + + launches = await launch_store.list_launches() + assert {launch.source for launch in launches} == { + "manual", + "schedule", + "proactive_signal", + } + assert all(launch.status == "launched" for launch in launches) + assert manual.task_id in task_store.tasks + assert scheduled.task_id in task_store.tasks + assert proactive.task_id in task_store.tasks + assert trigger_store.triggers["trigger-1"].metadata["launch_id"] == ( + scheduled.launch_id + ) + assert trigger_store.triggers["signal-trigger-signal-1"].metadata["launch_id"] == ( + proactive.launch_id + ) + assert (tmp_path / ".bee" / "runs" / manual.task_id / "task.json").exists() + for task_id in (manual.task_id, scheduled.task_id, proactive.task_id): + nodes = await task_store.list_nodes(task_id) + assert [node.status for node in nodes] == ["pending"] + metrics = prometheus_metrics_text() + assert 'bee_launches_total{source="manual",status="launched"} 1' in metrics + assert 'bee_launches_total{source="schedule",status="launched"} 1' in metrics + assert ( + 'bee_launches_total{source="proactive_signal",status="launched"} 1' in metrics + ) + assert 'scheduled_bee_launches_total{status="launched"} 1' in metrics + assert 'proactive_bee_launches_total{kind="unknown",status="launched"} 1' in metrics + for forbidden in ( + "launch_id", + "task_id", + "topic_id", + "run_id", + "session_id", + "schedule_id", + "signal_id", + "node_id", + ): + assert forbidden not in metrics + reset_prometheus_metrics() + + @pytest.mark.asyncio async def test_bee_task_lifecycle_resume_partially_completed_task() -> None: store = _lifecycle_task_store() diff --git a/coding-agent/tests/coding_agent/test_observability_platform_smoke.py b/coding-agent/tests/coding_agent/test_observability_platform_smoke.py index a83eb060c..7fcf49db2 100644 --- a/coding-agent/tests/coding_agent/test_observability_platform_smoke.py +++ b/coding-agent/tests/coding_agent/test_observability_platform_smoke.py @@ -11,6 +11,7 @@ CompositeObservationSink, OtlpHttpObservationSink, PrometheusMetricsObservationSink, + record_bee_launch_metric, prometheus_metrics_text, record_evaluation_case_metric, record_hitl_interaction_metric, @@ -27,7 +28,12 @@ "trace_id", "event_id", "interaction_id", + "launch_id", + "node_id", + "schedule_id", "tool_call_id", + "task_id", + "topic_id", "file_path", "prompt", "message", @@ -173,6 +179,35 @@ def test_observability_platform_composite_tracing_and_metrics_smoke() -> None: reset_prometheus_metrics() +def test_observability_platform_bee_launch_metrics_are_low_cardinality() -> None: + reset_prometheus_metrics() + + record_bee_launch_metric(source="manual", status="launched", duration_ms=1200) + record_bee_launch_metric(source="schedule", status="launched", duration_ms=250) + record_bee_launch_metric( + source="proactive_signal", + status="failed", + duration_ms=10, + proactive_kind="repo_activity", + ) + + metrics = prometheus_metrics_text() + assert 'bee_launches_total{source="manual",status="launched"} 1' in metrics + assert 'bee_launches_total{source="schedule",status="launched"} 1' in metrics + assert 'bee_launches_total{source="proactive_signal",status="failed"} 1' in metrics + assert 'scheduled_bee_launches_total{status="launched"} 1' in metrics + assert ( + 'proactive_bee_launches_total{kind="repo_activity",status="failed"} 1' + in metrics + ) + assert ( + 'bee_launch_duration_seconds_count{source="manual",status="launched"} 1' + in metrics + ) + _assert_no_prometheus_leak(metrics) + reset_prometheus_metrics() + + def _assert_no_prometheus_leak(text: str) -> None: for forbidden in FORBIDDEN_PROMETHEUS_PARTS: assert forbidden not in text diff --git a/coding-agent/tests/ui/test_developer_console.py b/coding-agent/tests/ui/test_developer_console.py index a0f191f0f..f429d9074 100644 --- a/coding-agent/tests/ui/test_developer_console.py +++ b/coding-agent/tests/ui/test_developer_console.py @@ -10,6 +10,7 @@ from httpx import ASGITransport, AsyncClient from coding_agent.environment import WorkspaceProviderCapabilities +from coding_agent.bee_launch import BeeLaunchRecord from coding_agent.bee_workspace import ( BeeWorkspaceRunArtifacts, BeeWorkspaceRunNode, @@ -478,6 +479,33 @@ async def list_signals( return signals[:limit] +class _ConsoleBeeLaunchStore: + def __init__(self, launches: list[BeeLaunchRecord]) -> None: + self.launches = launches + + async def list_launches( + self, + *, + source: str | None = None, + status: str | None = None, + session_id: str | None = None, + topic_id: str | None = None, + limit: int = 100, + ) -> list[BeeLaunchRecord]: + launches = [] + for launch in self.launches: + if source is not None and launch.source != source: + continue + if status is not None and launch.status != status: + continue + if session_id is not None and launch.session_id != session_id: + continue + if topic_id is not None and launch.topic_id != topic_id: + continue + launches.append(launch) + return launches[:limit] + + @pytest.fixture(autouse=True) async def clear_console_state() -> AsyncIterator[None]: session_manager.configure_runtime_store(None) @@ -720,11 +748,16 @@ def _bee_runtime_run(run_id: str = "run-bee") -> AgentRunRecord: metadata.update( { "bee_runtime": "task_launch", + "launch_id": "launch-alpha", + "launch_source": "schedule", + "launch_status": "launched", "launch_kind": "durable_run", "task_id": "bee-task-alpha", "node_id": "node-validate", "topic_id": "topic-auth", "session_id": "session-alpha", + "template_id": "launch-blueprint-alpha", + "schedule_id": "schedule-alpha", "task_kind": "maintenance", "task_profile": "local", "node_kind": "validation", @@ -1538,6 +1571,97 @@ async def test_console_bee_renders_safe_task_and_node_summaries() -> None: assert forbidden not in response.text +@pytest.mark.asyncio +async def test_console_bee_launch_renders_safe_launch_summary() -> None: + _register_console_session("session-alpha") + session_manager.configure_runtime_store(_ConsoleRuntimeStore([_bee_runtime_run()])) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/console/bee") + + assert response.status_code == 200 + assert "Bee Launches" in response.text + assert "launch-alpha" in response.text + assert "schedule" in response.text + assert "launched" in response.text + assert "launch-blueprint-alpha" in response.text + assert "bee-task-alpha" in response.text + assert "topic-auth" in response.text + assert "schedule-alpha" in response.text + for forbidden in FORBIDDEN_RENDERED_TEXT: + assert forbidden not in response.text + + +@pytest.mark.asyncio +async def test_console_bee_launch_renders_durable_launch_store_summary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _register_console_session("session-alpha") + session_manager.configure_runtime_store(_ConsoleRuntimeStore([])) + monkeypatch.setattr( + http_server, + "_console_bee_launch_store", + lambda: _ConsoleBeeLaunchStore( + [ + BeeLaunchRecord( + launch_id="launch-manual", + source="manual", + template_id="template-manual", + status="launched", + requested_at=datetime(2026, 5, 20, 1, 0, tzinfo=UTC), + task_id="bee-task-manual", + topic_id="topic-manual", + session_id="session-alpha", + launched_at=datetime(2026, 5, 20, 1, 1, tzinfo=UTC), + ), + BeeLaunchRecord( + launch_id="launch-scheduled", + source="schedule", + template_id="template-scheduled", + status="launched", + requested_at=datetime(2026, 5, 20, 2, 0, tzinfo=UTC), + task_id="bee-task-scheduled", + topic_id="topic-scheduled", + session_id="session-alpha", + schedule_id="schedule-alpha", + launched_at=datetime(2026, 5, 20, 2, 1, tzinfo=UTC), + ), + BeeLaunchRecord( + launch_id="launch-signal", + source="proactive_signal", + template_id="template-signal", + status="failed", + requested_at=datetime(2026, 5, 20, 3, 0, tzinfo=UTC), + topic_id="topic-signal", + session_id="session-alpha", + signal_id="signal-alpha", + error_type="policy_denied", + error_message="safe policy denied", + ), + ] + ), + ) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/console/bee") + + assert response.status_code == 200 + assert "Bee Launches" in response.text + assert "launch-manual" in response.text + assert "launch-scheduled" in response.text + assert "launch-signal" in response.text + assert "manual" in response.text + assert "schedule" in response.text + assert "proactive_signal" in response.text + assert "schedule-alpha" in response.text + assert "signal-alpha" in response.text + assert "safe policy denied" in response.text + for forbidden in FORBIDDEN_RENDERED_TEXT: + assert forbidden not in response.text + + @pytest.mark.asyncio async def test_console_bee_renders_workspace_template_artifacts_and_commands( monkeypatch: pytest.MonkeyPatch, @@ -1595,8 +1719,10 @@ async def test_console_bee_does_not_emit_task_or_node_ids_as_metric_labels( assert response.status_code == 200 metrics = prometheus_metrics_text() assert 'route="console_bee"' in metrics + assert "launch-alpha" not in metrics assert "bee-task-alpha" not in metrics assert "node-validate" not in metrics + assert "launch_id" not in metrics assert "task_id" not in metrics assert "node_id" not in metrics