feat: add official Codex skill and harden task execution

Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
This commit is contained in:
chenmingxuan committed 2026-10-18 14:18:37 +08:00
1 parent 2328a4df0c
commit e1ea099592
128 files changed
+4944 -700

No files matched your search

@@ -320,6 +320,7 @@ class SessionRepository:
return 0
llm_calls = prompt = completion = total = cached = 0
by_node_model: dict[tuple[str, str], dict] = {}
operator_context: int | None = None
operator_context_at: float | None = None
with db_session(self.db_path) as conn:
@@ -348,6 +349,27 @@ class SessionRepository:
total += t
cached += _int(payload.get("cached_tokens"))
node = str(payload.get("node") or "").strip().lower()
model_name = str(payload.get("model") or "unknown")
group = by_node_model.setdefault(
(node, model_name),
{
"node": node,
"model": model_name,
"llm_calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"cached_tokens": 0,
},
)
group["llm_calls"] += 1
group["prompt_tokens"] += p
group["completion_tokens"] += c
group["cached_tokens"] += _int(payload.get("cached_tokens"))
group["cache_hit_rate"] = (
group["cached_tokens"] / group["prompt_tokens"]
if group["prompt_tokens"]
else None
)
if node in OPERATOR_NODE_NAMES:
operator_context = _int(payload.get("context_base_tokens")) or p
operator_context_at = row["timestamp"]
@@ -375,6 +397,7 @@ class SessionRepository:
"completion_tokens": completion,
"total_tokens": total,
"cached_tokens": cached,
"by_node_model": list(by_node_model.values()),
"operator_context_tokens": operator_context,
"operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS,
"operator_context_updated_at": operator_context_at,
+17
View File
@@ -23,6 +23,23 @@ from apps.admin_console.services.exploration_service import exploration_service
router = APIRouter(prefix="/api/explorations", tags=["exploration"])
@router.get("/{exploration_id}/deletion-preview")
def preview_deletion(exploration_id: str):
from apps.admin_console.services.task_deletion import exploration_deletion_preview
with domain_errors():
return exploration_deletion_preview(exploration_id)
@router.delete("/{exploration_id}")
def delete_conversation(exploration_id: str):
from apps.admin_console.services.exploration_deletion import delete_exploration
with domain_errors():
deleted = delete_exploration(service.repository, exploration_id)
return {"status": "success", "deleted_session_ids": deleted}
@contextmanager
def domain_errors():
try:
+33 -1
View File
@@ -16,7 +16,7 @@ import asyncio
import json
import time
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from artemis.config import DB_PATH, TRACES_PATH
@@ -36,6 +36,23 @@ from apps.admin_console.services.exploration_listing import exploration_metadata
router = APIRouter(tags=["sessions"])
@router.get("/api/sessions/{session_id}/inspection")
def inspect_session(
session_id: str,
view: str = "report",
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100),
):
from apps.admin_console.services.task_inspection import inspect_task
try:
return inspect_task(session_id, view, offset=offset, limit=limit)
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/api/sessions/{session_id}/request-diagnostics")
def get_request_diagnostics(session_id: str, download: bool = False):
from apps.admin_console.services.request_diagnostics import request_diagnostics
@@ -281,6 +298,18 @@ async def cleanup_history_endpoint():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/sessions/{session_id}/deletion-preview")
def preview_session_deletion(session_id: str):
from apps.admin_console.services.task_deletion import deletion_preview
try:
return deletion_preview(session_id)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
@router.post("/api/sessions/{session_id}/delete")
async def delete_session_endpoint(session_id: str):
from artemis.exploration.store import ExplorationConflict, explorations
@@ -295,6 +324,7 @@ async def delete_session_endpoint(session_id: str):
deleted = delete_exploration(explorations, run["exploration_id"])
return {"status": "success", "deleted_session_ids": deleted}
preview_session_deletion(session_id)
storage = StorageManager(DB_PATH, TRACES_PATH)
storage.delete_session(UUID(session_id))
from apps.admin_console.services.automation_store import store
@@ -307,6 +337,8 @@ async def delete_session_endpoint(session_id: str):
}
except ExplorationConflict as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except HTTPException:
raise
except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
+4
View File
@@ -105,6 +105,10 @@ async def _lifespan(_app: "FastAPI"):
app.state.service_registration = registration
configure_local_daemon(state.port)
try:
from artemis.integrations.codex_skill import sync_on_startup
from artemis.config.paths import ROOT_DIR
await asyncio.to_thread(sync_on_startup, ROOT_DIR)
shared_tool_executor.start()
async with shared_mcp.session_manager.run():
await on_startup()
@@ -2,6 +2,7 @@
from artemis.config.agent import load_agent_config
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.case_prompt import require_resolved_case
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
from artemis.exploration.demonstrations import Demonstrations
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
@@ -10,8 +11,7 @@ from apps.admin_console.services.task_import import TaskOptions
def exploration_case_prompt(case: dict) -> str:
prefix = f"前置条件:\n{case['preconditions']}\n\n" if case.get("preconditions") else ""
return prefix + case["goal"]
return require_resolved_case(case)
class ExplorationService:
@@ -7,7 +7,9 @@ import time
from apps.admin_console.database.connection import db_session
def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = None) -> dict:
def request_diagnostics(
session_id: str, *, db_path=None, limit: int | None = None, offset: int = 0
) -> dict:
with db_session(db_path) as conn:
session = conn.execute(
"SELECT status FROM sessions WHERE session_id = ?", (session_id,)
@@ -17,9 +19,13 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
"ORDER BY timestamp DESC"
)
params: tuple = (session_id,)
total = conn.execute(
"SELECT count(*) FROM traces WHERE session_id=? AND type='llm_diagnostic'",
params,
).fetchone()[0]
if limit is not None:
sql += " LIMIT ?"
params += (limit,)
sql += " LIMIT ? OFFSET ?"
params += (limit, offset)
rows = conn.execute(sql, params).fetchall()
requests = []
seen = set()
@@ -34,6 +40,7 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
request_id = value.get("request_id")
value["active"] = (
running
and offset == 0
and request_id not in seen
and value.get("phase")
in {
@@ -64,6 +71,9 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
"session_status": session["status"] if session else "unknown",
"collected_at": time.time(),
"requests": requests,
"total": total,
"offset": offset,
"next_offset": offset + limit if limit is not None and offset + limit < total else None,
"note": "时间戳为 Unix 秒;timestamps_utc 为 UTC。HTTP 200 不代表完整输出。usage=null 表示服务未报告用量。历史任务没有记录的字段无法补推。",
}
@@ -0,0 +1,40 @@
"""Read the impact of deleting a task without deleting or stopping anything."""
from uuid import UUID
from artemis.exploration.store import ExplorationConflict, explorations
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.exploration_deletion import _check_runs
def deletion_preview(session_id: str) -> dict:
session_id = str(UUID(session_id))
run = explorations.run(session_id)
if run and run["kind"] == "exploration":
return exploration_deletion_preview(run["exploration_id"])
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
raise KeyError("任务不存在")
_check_runs([{"id": session_id}])
job = store.job(session_id) or {}
if job.get("status") in {"pending", "queued", "running", "task_paused", "resuming"}:
raise ExplorationConflict("任务仍在执行或排队,请先停止并等待结束")
return {
"session_ids": [session_id],
"preserved_session_ids": [],
"removes": ["任务历史", "报告", "截图、录像及附件"],
}
def exploration_deletion_preview(exploration_id: str) -> dict:
session = explorations.get(exploration_id)
runs = explorations.runs(exploration_id)
_check_runs(runs)
if session.get("active_run_id") or session.get("active_demonstration_id"):
raise ExplorationConflict("探索尚有活动任务或示范,请先停止并等待结束")
return {
"exploration_id": exploration_id,
"session_ids": [run["id"] for run in runs if run["kind"] == "exploration"],
"preserved_session_ids": [run["id"] for run in runs if run["kind"] != "exploration"],
"removes": ["探索对话", "用例版本", "探索执行历史及证据"],
}
@@ -0,0 +1,147 @@
"""Bounded, read-only task diagnostics shared by MCP and HTTP clients."""
import os
import re
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services import evidence_service, report_export
from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services.request_diagnostics import request_diagnostics
_SECRET = re.compile(
r"(?i)^(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|client_secret|token_key)$"
)
def public_value(value, *, max_chars: int = 60000):
"""Remove common credential forms and bound text without inventing missing evidence."""
remaining = max_chars
remaining_nodes = 2000
truncated = False
secrets = [
v
for k, v in os.environ.items()
if len(v) >= 8 and re.search(r"(?i)(api_?key|password|secret|token)$", k)
]
def clean(item, depth=0):
nonlocal remaining, remaining_nodes, truncated
remaining_nodes -= 1
if depth > 12:
truncated = True
return "[嵌套内容已截断]"
if isinstance(item, dict):
result = {}
for k, v in item.items():
if remaining_nodes <= 0 or len(result) >= 100:
truncated = True
break
key = clean(str(k), depth + 1)
result[key] = "[REDACTED]" if _SECRET.fullmatch(str(k)) else clean(v, depth + 1)
return result
if isinstance(item, list):
result = []
for v in item:
if remaining_nodes <= 0 or len(result) >= 100:
truncated = True
break
result.append(clean(v, depth + 1))
return result
if not isinstance(item, str):
return item
for secret in secrets:
item = item.replace(secret, "[REDACTED]")
item = re.sub(r"(?i)\bBearer\s+[^\s\"',;]+", "Bearer [REDACTED]", item)
item = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[REDACTED]", item)
item = re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", item)
item = re.sub(
r"""(?ix)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|token_key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)""",
r"\1[REDACTED]",
item,
)
allowance = min(8000, remaining)
if len(item) > allowance:
truncated = True
item = item[:allowance] + "…[已截断;按步骤或关键词缩小范围]"
remaining = max(0, remaining - min(len(item), allowance))
return item
result = clean(value)
return {**result, "text_truncated": truncated, "content_truncated": truncated}
def page(items: list, offset: int, limit: int) -> dict:
return {
"items": items[offset : offset + limit],
"total": len(items),
"offset": offset,
"limit": limit,
"next_offset": offset + limit if offset + limit < len(items) else None,
}
def inspect_task(session_id: str, view: str, *, offset: int = 0, limit: int = 20) -> dict:
identifier(session_id)
if offset < 0 or not 1 <= limit <= 100:
raise ValueError("offset 必须非负,limit 必须在 1–100 之间")
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
raise LookupError("任务记录不存在")
if view == "usage":
result = session_repo.get_session_usage(session_id)
result["cache_hit_rate"] = (
result["cached_tokens"] / result["prompt_tokens"] if result["prompt_tokens"] else None
)
result["note"] = (
"缓存率=已记录 cached_tokens / prompt_tokens;无用量不等于零消耗,未上报的请求无法补推。"
)
elif view == "requests":
result = request_diagnostics(session_id, limit=limit, offset=offset)
elif view == "logs":
root = evidence_service.task_root(session_id)
result = {"logs": []}
for name in ("stdout.log", "stderr.log"):
path = evidence_service.evidence_file(root, name)
if path is None:
result["logs"].append({"name": name, "available": False})
continue
with path.open("rb") as stream:
size = path.stat().st_size
start = max(0, size - 128 * 1024)
stream.seek(start)
raw = stream.read(128 * 1024)
lines = raw.decode("utf-8", errors="replace").splitlines()
if start and lines:
lines = lines[1:]
# Offset counts backward from the newest line within the bounded tail.
end = max(0, len(lines) - offset)
result["logs"].append(
{
"name": name,
"available": True,
"bytes": size,
"lines": lines[max(0, end - limit) : end],
"offset": offset,
"next_offset": offset + limit if end > limit else None,
"older_bytes_omitted": start,
"order": "oldest_to_newest",
}
)
elif view in {"report", "evidence"}:
report = report_export.session_report(session_id)
if view == "report":
result = {**report, "steps": page(report.get("steps", []), offset, limit)}
else:
index = evidence_service.export_index(
session_id, report, report_export.session_videos(report)
)
result = {
**index,
**{
key: page(index.get(key, []), offset, limit)
for key in ("steps", "calls", "recordings", "android_videos", "files")
},
}
else:
raise ValueError("view 需为 report / usage / requests / logs / evidence")
return public_value({"session_id": session_id, **result})
@@ -120,7 +120,7 @@ limitations under the License.
<div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') {
@if (!liveStreamError()) {
@if (!liveStreamError() && pageVisible()) {
<div class="live-stream-container">
<img
class="live-screen-img"
@@ -98,6 +98,24 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture.detectChanges();
}
it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
hidden.and.returnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).toBeNull();
hidden.and.returnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
expect(component.liveStreamError()).toBeFalse();
});
function playButton(): HTMLButtonElement {
return fixture.nativeElement.querySelector('.play-btn');
}
@@ -70,6 +70,21 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public sources = computed(() => this.agentService.recordingSources?.() || []);
public liveStreamUrl = signal<string>('/api/stream/device-live');
public liveStreamError = signal<boolean>(false);
public pageVisible = signal(!document.hidden);
@HostListener('document:visibilitychange')
@HostListener('window:pageshow')
onPageVisibilityChange(): void {
this.pageVisible.set(!document.hidden);
if (!document.hidden && this.agentService.recordingPlaybackStatus() === 'live') {
this.retryLiveStream();
}
}
@HostListener('window:pagehide')
onPageHide(): void {
this.pageVisible.set(false);
}
public activeSegmentIndex = signal<number>(0);
public currentVideoUrl = computed(() => {
const segments = this.agentService.activeVideoSegments();
@@ -1,7 +1,8 @@
import { Component, inject, OnInit, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs';
import { finalize, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
interface Capabilities {
recording?: { codec: string; max_long_edge: number; fps: number; h265_kbps: number; h264_kbps: number; computer_scope: string; minimum_disk_mb: number; hevc_playback: boolean };
@@ -98,7 +99,7 @@ export class CapabilitySettingsComponent implements OnInit {
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
});
}
@@ -1,4 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ModelSettingsComponent } from './model-settings.component';
@@ -17,6 +18,18 @@ describe('持久化模型与功能设置', () => {
});
afterEach(() => http.verify());
it('配置请求排队超时后退出加载状态,不会发送保存请求', fakeAsync(() => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
const request = http.expectOne('/api/system/model-settings');
tick(UI_READ_TIMEOUT_MS);
expect(request.cancelled).toBeTrue();
expect(fixture.componentInstance.loading()).toBeFalse();
expect(fixture.componentInstance.error()).toBeTruthy();
expect(fixture.componentInstance.valid()).toBeFalse();
http.expectNone('/api/system/model-settings');
}));
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
@@ -1,7 +1,8 @@
import { Component, OnInit, inject, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs';
import { finalize, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
interface ModelSettings {
@@ -128,7 +129,7 @@ export class ModelSettingsComponent implements OnInit {
message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<ModelSettings>('/api/system/model-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
this.http.get<ModelSettings>('/api/system/model-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => this.apply(config),
error: () => this.error.set('配置加载失败,请刷新页面重试。')
});
@@ -0,0 +1,3 @@
// Local metadata reads must also time out when the browser is waiting for a
// connection. This is independent of model execution and long-running actions.
export const UI_READ_TIMEOUT_MS = 15_000;
@@ -0,0 +1,120 @@
import { provideHttpClient, withXhr } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
import { AgentService } from './agent.service';
import { SharedLiveStream } from './shared-live-stream';
describe('AgentService browser connection lifecycle', () => {
let service: AgentService;
let http: HttpTestingController;
let hidden: jasmine.Spy;
let streams: (EventTarget & { close: jasmine.Spy })[];
beforeEach(() => {
hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
streams = [];
spyOn(SharedLiveStream, 'open').and.callFake(() => {
const stream = Object.assign(new EventTarget(), { close: jasmine.createSpy('close') });
streams.push(stream);
return stream as unknown as SharedLiveStream;
});
// Keep this test independent of the user's cached task selection.
spyOn<any>(AgentService.prototype, 'restoreSessionsCache');
TestBed.configureTestingModule({ providers: [provideHttpClient(withXhr()), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
service?.destroy();
http.verify({ ignoreCancelled: true });
});
function flushSnapshots(): void {
http.expectOne('/api/sessions').flush([]);
http.expectOne('/api/status').flush({ status: 'idle', queue: [], active_tasks: [] });
// The initial offline -> idle transition may request a newer session list.
for (const request of http.match('/api/sessions')) request.flush([]);
}
it('does not open SSE in a hidden tab and releases it when the tab is hidden again', () => {
hidden.and.returnValue(true);
service = TestBed.inject(AgentService);
flushSnapshots();
expect(streams.length).toBe(0);
hidden.and.returnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
service.ensureLiveStream();
expect(streams.length).toBe(1);
flushSnapshots();
hidden.and.returnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
expect(streams[0].close).toHaveBeenCalledTimes(1);
service.ensureLiveStream();
expect(streams.length).toBe(1);
hidden.and.returnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
expect(streams.length).toBe(2);
flushSnapshots();
service.destroy();
document.dispatchEvent(new Event('visibilitychange'));
service.ensureLiveStream();
expect(streams.length).toBe(2);
});
it('releases SSE before entering the back-forward cache and reconnects on return', () => {
service = TestBed.inject(AgentService);
flushSnapshots();
window.dispatchEvent(new Event('pagehide'));
expect(streams[0].close).toHaveBeenCalledTimes(1);
service.ensureLiveStream();
expect(streams.length).toBe(1);
window.dispatchEvent(new Event('pageshow'));
expect(streams.length).toBe(2);
flushSnapshots();
});
it('reconciles steps, verdicts and guidance after reconnecting without selecting another task', () => {
service = TestBed.inject(AgentService);
flushSnapshots();
service.currentSessionId.set('selected-task');
const steps = spyOn<any>(service, 'backfillSessionSteps');
const checks = spyOn(service, 'fetchChecks');
const notes = spyOn(service, 'fetchNotes');
const guidance = spyOn(service, 'fetchGuidance');
streams[0].dispatchEvent(new MessageEvent('info', { data: '{}' }));
flushSnapshots();
expect(steps).toHaveBeenCalledWith('selected-task');
expect(checks).toHaveBeenCalledWith('selected-task');
expect(notes).toHaveBeenCalledWith('selected-task');
expect(guidance).toHaveBeenCalledWith('selected-task');
expect(service.currentSessionId()).toBe('selected-task');
});
it('does not accumulate polling requests while the browser connection pool is blocked', fakeAsync(() => {
service = TestBed.inject(AgentService);
service.currentSessionId.set('selected-task');
for (let index = 0; index < 10; index++) {
service.fetchStatus();
service.fetchSessions();
service.fetchGuidance('selected-task');
}
const status = http.expectOne('/api/status');
const sessions = http.expectOne('/api/sessions');
const guidance = http.expectOne('/api/tasks/selected-task/guidance');
tick(UI_READ_TIMEOUT_MS);
expect(status.cancelled).toBeTrue();
expect(sessions.cancelled).toBeTrue();
expect(guidance.cancelled).toBeTrue();
// A timeout frees each guard so the next foreground refresh can recover.
service.fetchStatus();
service.fetchSessions();
service.fetchGuidance('selected-task');
http.expectOne('/api/tasks/selected-task/guidance').flush([]);
flushSnapshots();
service.destroy();
}));
});
@@ -16,7 +16,9 @@
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { Observable, finalize, tap, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
import { SharedLiveStream } from './shared-live-stream';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
@@ -452,7 +454,12 @@ export class AgentService {
: status;
});
private eventSource: EventSource | null = null;
private eventSource: SharedLiveStream | null = null;
private destroyed = false;
private pageHidden = false;
private statusRequestPending = false;
private sessionsRequestPending = false;
private guidanceRequestsPending = new Set<string>();
private statusInterval: any = null;
private sessionLoadGeneration = 0;
private sessionSnapshotRequestId = 0;
@@ -467,11 +474,22 @@ export class AgentService {
private lastActiveTasksSignature: string | null = null;
private lastPersistedSessionsJson: string | null = null;
private onVisibilityChange = () => {
if (typeof document !== 'undefined' && !document.hidden) {
if (document.hidden || this.pageHidden) {
this.closeLiveStream();
} else {
this.fetchStatus();
this.fetchSessions();
this.ensureLiveStream();
}
};
private onPageHide = () => {
this.pageHidden = true;
this.closeLiveStream();
};
private onPageShow = () => {
this.pageHidden = false;
this.onVisibilityChange();
};
constructor() {
this.restoreSessionsCache();
@@ -480,6 +498,8 @@ export class AgentService {
this.ensureLiveStream();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
window.addEventListener('pagehide', this.onPageHide);
window.addEventListener('pageshow', this.onPageShow);
}
inject(DestroyRef).onDestroy(() => this.destroy());
}
@@ -700,7 +720,12 @@ export class AgentService {
* Fetch all past and active sessions from the backend
*/
public fetchSessions(): void {
this.http.get<Session[]>('/api/sessions').subscribe({
if (this.destroyed || this.sessionsRequestPending) return;
this.sessionsRequestPending = true;
this.http.get<Session[]>('/api/sessions').pipe(
timeout(UI_READ_TIMEOUT_MS),
finalize(() => this.sessionsRequestPending = false)
).subscribe({
next: (data) => {
this.rawSessions.set(data);
this.persistSessionsCache(data);
@@ -850,26 +875,43 @@ export class AgentService {
}
/**
* Ensure persistent single-channel live stream is established via /api/stream.
* Eliminates reconnect tearing, race conditions, and disconnect dropouts.
* Keep a live connection only while this page can display updates. Hidden
* tabs otherwise exhaust HTTP/1.1's per-origin connection pool, blocking
* configuration and history requests in every tab of the same browser.
*/
public ensureLiveStream(): void {
if (this.eventSource) {
if (this.destroyed || this.pageHidden || document.hidden || this.eventSource) {
return;
}
console.debug('Establishing persistent unified live stream via /api/stream');
const eventTypes = [
'llm_stream', 'llm_stream_reset', 'trace_recorded', 'step_recorded',
'step_updated', 'background_tasks_updated', 'task_paused', 'task_resumed',
'llm_retrying', 'startup_progress', 'session_started', 'session_ended',
'recording_ready', 'recording_failed', 'checker_event'
];
// The stream is registered outside the Angular zone: high-frequency SSE
// callbacks must not schedule a change-detection pass each. Signal writes
// still notify the render scheduler, so the UI stays live.
this.zone.runOutsideAngular(() => {
this.eventSource = new EventSource('/api/stream');
this.eventSource = SharedLiveStream.open('/api/stream', ['info', 'keep-alive', ...eventTypes]);
const source = this.eventSource;
this.eventSource.addEventListener('info', () => {
// Reconcile current session if active
if (this.eventSource !== source) return;
// Events may have been missed while hidden or during a server restart.
this.fetchStatus();
this.fetchSessions();
const curId = this.currentSessionId();
if (curId) {
this.backfillSessionSteps(curId);
this.fetchChecks(curId);
this.fetchNotes(curId);
this.fetchGuidance(curId);
}
if (this.isVideoWindowOpen() && ['live', 'processing'].includes(this.recordingPlaybackStatus())) {
this.refreshActiveRecording(false);
}
});
@@ -877,26 +919,9 @@ export class AgentService {
// Keep-alive tick
});
const eventTypes = [
'llm_stream',
'llm_stream_reset',
'trace_recorded',
'step_recorded',
'step_updated',
'background_tasks_updated',
'task_paused',
'task_resumed',
'llm_retrying',
'startup_progress',
'session_started',
'session_ended',
'recording_ready',
'recording_failed',
'checker_event'
];
eventTypes.forEach((eventType) => {
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
this.eventSource?.addEventListener(eventType, ((event: MessageEvent) => {
if (this.eventSource !== source) return;
try {
const parsedData = JSON.parse(event.data);
const evtSessionId = parsedData?.session_id;
@@ -1197,7 +1222,7 @@ export class AgentService {
}
]);
}
});
}) as EventListener);
});
this.eventSource.onerror = (err) => {
@@ -1206,6 +1231,12 @@ export class AgentService {
});
}
private closeLiveStream(): void {
this.eventSource?.close();
this.eventSource = null;
this.flushStreamChunks();
}
/**
* Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
* once per short window (and immediately before any non-stream event so the
@@ -1375,8 +1406,12 @@ export class AgentService {
}
public fetchGuidance(sessionId: string): void {
if (this.currentSessionId() !== sessionId) return;
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
if (this.destroyed || this.currentSessionId() !== sessionId || this.guidanceRequestsPending.has(sessionId)) return;
this.guidanceRequestsPending.add(sessionId);
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).pipe(
timeout(UI_READ_TIMEOUT_MS),
finalize(() => this.guidanceRequestsPending.delete(sessionId))
).subscribe({
next: (items) => {
if (this.currentSessionId() !== sessionId) return;
const signature = JSON.stringify(items);
@@ -1618,7 +1653,12 @@ export class AgentService {
* Fetch current agent runner process status
*/
public fetchStatus(): void {
this.http.get<any>('/api/status').subscribe({
if (this.destroyed || this.statusRequestPending) return;
this.statusRequestPending = true;
this.http.get<any>('/api/status').pipe(
timeout(UI_READ_TIMEOUT_MS),
finalize(() => this.statusRequestPending = false)
).subscribe({
next: (data) => {
if (data && data.status) {
const oldStatus = this.agentStatus();
@@ -1718,6 +1758,7 @@ export class AgentService {
* Clean up event source connection and timers on service destruction
*/
public destroy(): void {
this.destroyed = true;
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
@@ -1729,6 +1770,8 @@ export class AgentService {
this.discardPendingStreamChunks();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('pagehide', this.onPageHide);
window.removeEventListener('pageshow', this.onPageShow);
}
this.cancelVideoRetry();
}
@@ -0,0 +1,25 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
import { ExplorationService } from './exploration.service';
describe('ExplorationService settings loading', () => {
it('cancels a stalled read and lets a subsequent read succeed', fakeAsync(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
const http = TestBed.inject(HttpTestingController);
const service = TestBed.inject(ExplorationService);
let failed = false;
service.settings().catch(() => failed = true);
const stalled = http.expectOne('/api/explorations/settings');
tick(UI_READ_TIMEOUT_MS);
expect(stalled.cancelled).toBeTrue();
expect(failed).toBeTrue();
let model = '';
service.settings().then(config => model = config.model);
http.expectOne('/api/explorations/settings').flush({ model: 'test-model' });
tick();
expect(model).toBe('test-model');
http.verify();
}));
});
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
import { CaseTestResult } from '../core/models/session.model';
export interface ExplorationConfig {
@@ -28,7 +29,7 @@ export class ExplorationService {
caseDrafts = new Map<string, {name: string; goal: string; preconditions: string; version: number}>();
private base = '/api/explorations';
list() { return firstValueFrom(this.http.get<ExplorationSession[]>(this.base)); }
settings() { return firstValueFrom(this.http.get<ExplorationConfig>(this.base + '/settings')); }
settings() { return firstValueFrom(this.http.get<ExplorationConfig>(this.base + '/settings').pipe(timeout(UI_READ_TIMEOUT_MS))); }
saveSettings(value: ExplorationConfig) { return firstValueFrom(this.http.put<ExplorationConfig>(this.base + '/settings', value)); }
get(id: string, after = 0) { return firstValueFrom(this.http.get<ExplorationSession>(`${this.base}/${encodeURIComponent(id)}?after=${after}`)); }
create(goal: string, config: ExplorationConfig | undefined, task_config: Record<string, unknown>, request_id: string) {
@@ -0,0 +1,64 @@
import { SharedLiveStream } from './shared-live-stream';
describe('SharedLiveStream multi-window transport', () => {
let connections: (EventTarget & { close: jasmine.Spy })[];
let clients: SharedLiveStream[];
let socketReady: (() => void) | null;
beforeEach(() => {
connections = [];
clients = [];
socketReady = null;
spyOn(window, 'EventSource').and.callFake(function () {
const socket = Object.assign(new EventTarget(), { close: jasmine.createSpy('close') });
connections.push(socket);
socketReady?.();
return socket as unknown as EventSource;
});
});
afterEach(() => clients.forEach(client => client.close()));
function nextSocket(): Promise<void> {
return new Promise(resolve => socketReady = resolve);
}
function event(client: SharedLiveStream, type: string): Promise<string> {
return new Promise(resolve => client.addEventListener(type,
value => resolve((value as MessageEvent).data), { once: true }));
}
function open(url: string): SharedLiveStream {
const client = SharedLiveStream.open(url, ['info', 'step_recorded']);
clients.push(client);
return client;
}
it('shares a single socket across eight pages and recovers when the owner leaves', async () => {
const url = `/api/stream?test=${crypto.randomUUID()}`;
const connected = nextSocket();
for (let index = 0; index < 8; index++) open(url);
const infos = clients.map(client => event(client, 'info'));
await connected;
connections[0].dispatchEvent(new MessageEvent('info', { data: '{}' }));
await Promise.all(infos);
expect(connections.length).toBe(1);
const steps = clients.map(client => event(client, 'step_recorded'));
connections[0].dispatchEvent(new MessageEvent('step_recorded', { data: '{"step_id":"one"}' }));
expect(await Promise.all(steps)).toEqual(Array(8).fill('{"step_id":"one"}'));
// A page opened after the initial info event still receives a snapshot cue.
const late = open(url);
expect(await event(late, 'info')).toBe('{}');
expect(connections.length).toBe(1);
// A closed follower must not acquire the socket when the owner leaves.
clients[1].close();
const successor = nextSocket();
clients[0].close();
await successor;
expect(connections[0].close).toHaveBeenCalledTimes(1);
expect(connections.length).toBe(2);
const resumed = event(clients[2], 'step_recorded');
connections[1].dispatchEvent(new MessageEvent('step_recorded', { data: '{"step_id":"two"}' }));
expect(await resumed).toContain('two');
});
});
@@ -0,0 +1,84 @@
/** One SSE socket per origin, even with several visible ARTEMIS windows. */
export class SharedLiveStream extends EventTarget {
private source: EventSource | null = null;
private channel: BroadcastChannel | null = null;
private abort = new AbortController();
private release: (() => void) | null = null;
private closed = false;
private ready = false;
private readonly id = typeof crypto.randomUUID === 'function'
? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
onerror: ((event: Event) => void) | null = null;
static open(url: string, eventTypes: readonly string[]): SharedLiveStream {
return new SharedLiveStream(url, eventTypes);
}
private constructor(private url: string, private eventTypes: readonly string[]) {
super();
if (!navigator.locks || typeof BroadcastChannel === 'undefined') {
this.connect();
return;
}
const name = `artemis.live:${url}`;
this.channel = new BroadcastChannel(name);
this.channel.onmessage = ({ data }) => {
if (this.closed || !data || typeof data !== 'object') return;
if (data.kind === 'join' && this.ready) {
// A new follower needs a snapshot even if the current owner connected
// long ago. Reuse the same info/reconciliation path as a reconnect.
this.channel?.postMessage({ kind: 'event', type: 'info', data: '{}', recipient: data.id });
} else if (data.kind === 'event' && (!data.recipient || data.recipient === this.id)
&& this.eventTypes.includes(data.type) && typeof data.data === 'string') {
this.dispatchEvent(new MessageEvent(data.type, { data: data.data }));
}
};
this.channel.postMessage({ kind: 'join', id: this.id });
// The browser releases this lock if its owning page is closed or crashes.
// Waiting tabs acquire it in turn; hidden/destroyed tabs cancel their wait.
void navigator.locks.request(name, { signal: this.abort.signal }, async () => {
if (this.closed) return;
const lifetime = new Promise<void>(resolve => this.release = resolve);
this.connect();
await lifetime;
}).catch(error => {
if (this.closed) return;
// Older/restricted browser contexts may reject Web Locks. Visibility
// gating still applies to the per-page fallback.
console.warn('Shared live connection unavailable:', error);
this.channel?.close();
this.channel = null;
this.connect();
});
}
private connect(): void {
if (this.closed || this.source) return;
this.source = new EventSource(this.url);
for (const type of this.eventTypes) {
this.source.addEventListener(type, (event: MessageEvent) => {
if (this.closed) return;
if (type === 'info') this.ready = true;
this.channel?.postMessage({ kind: 'event', type, data: event.data });
this.dispatchEvent(new MessageEvent(type, { data: event.data }));
});
}
this.source.onerror = event => {
this.ready = false;
this.onerror?.(event);
};
}
close(): void {
if (this.closed) return;
this.closed = true;
this.ready = false;
this.source?.close();
this.source = null;
this.channel?.close();
this.channel = null;
this.abort.abort();
this.release?.();
this.release = null;
}
}
@@ -16,7 +16,8 @@
import { Injectable, signal, computed, inject, DestroyRef, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, finalize, shareReplay, tap } from 'rxjs';
import { Observable, finalize, shareReplay, tap, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
import {
AdbServerConnectionResponse,
AdbServerStatus,
@@ -239,6 +240,7 @@ export class SystemService {
const request$ = this.http.get<SystemReadinessReport>('/api/system/readiness', {
params: forceRefresh ? { force: true } : {}
}).pipe(
timeout(UI_READ_TIMEOUT_MS),
tap({
next: (report) => {
this.applyReadinessReport(report);
@@ -544,6 +546,7 @@ export class SystemService {
*/
public fetchModelConfigEnv(): Observable<ModelConfigEnvResponse> {
return this.http.get<ModelConfigEnvResponse>('/api/system/model-config-env').pipe(
timeout(UI_READ_TIMEOUT_MS),
tap({
next: (data) => {
this.modelConfigEnv.set(data);