fix: improve cross-device evidence and exploration lifecycle

Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns.

Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
This commit is contained in:
chenmingxuan committed 2026-10-17 19:43:20 +08:00
1 parent f1bb5d02aa
commit 2328a4df0c
32 files changed
+748 -79

No files matched your search

+14
View File
@@ -283,9 +283,18 @@ async def cleanup_history_endpoint():
@router.post("/api/sessions/{session_id}/delete")
async def delete_session_endpoint(session_id: str):
from artemis.exploration.store import ExplorationConflict, explorations
try:
from artemis.data_engine.storage import StorageManager
session_id = str(UUID(session_id))
run = explorations.run(session_id)
if run and run["kind"] == "exploration":
from apps.admin_console.services.exploration_deletion import delete_exploration
deleted = delete_exploration(explorations, run["exploration_id"])
return {"status": "success", "deleted_session_ids": deleted}
storage = StorageManager(DB_PATH, TRACES_PATH)
storage.delete_session(UUID(session_id))
from apps.admin_console.services.automation_store import store
@@ -294,6 +303,11 @@ async def delete_session_endpoint(session_id: str):
return {
"status": "success",
"message": f"Session {session_id} deleted successfully",
"deleted_session_ids": [session_id],
}
except ExplorationConflict as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,48 @@
"""Delete an inactive conversation while keeping independently executed reruns."""
import shutil
from uuid import UUID
from apps.admin_console.core.state import state
from apps.admin_console.services import report_export
from artemis.data_engine.storage import StorageManager
from artemis.exploration.store import ExplorationConflict, ExplorationStore
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import read_state
def _check_runs(runs: list[dict]) -> None:
queued = {
item.get("session_id")
for item in state.queue_items
if item.get("status") in {"pending", "queued", "running"}
}
for run in runs:
run_id = run["id"]
if (
run_id in state.active_runs
or run_id in queued
or pid_is_alive(read_state(run_id).get("pid"))
):
raise ExplorationConflict("探索工作进程尚未结束,请停止任务并稍后重试删除")
def delete_exploration(repository: ExplorationStore, exploration_id: str) -> list[str]:
runs = repository.begin_delete(exploration_id, _check_runs)
# Validation sessions and batch copies remain ordinary Flash/Pro history.
deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
storage = StorageManager(repository.database.path, report_export.TRACES_PATH)
with report_export.EXPORT_LOCK:
for run_id in deleted:
safe_id = str(UUID(run_id))
storage.delete_session(UUID(safe_id))
repository.database.delete_history(safe_id)
for root in (
report_export.EXPORT_ROOT,
report_export.EXPORT_ROOT.parent / "mcp_exports",
):
folder = (root / "sessions" / safe_id).resolve()
if folder.is_relative_to(root.resolve()) and folder.is_dir():
shutil.rmtree(folder)
repository.finish_delete(exploration_id)
return deleted
@@ -62,6 +62,7 @@ limitations under the License.
<span>任务队列</span>
</div>
<app-task-batch-filter />
@if (deleteError()) { <p class="item-error" role="alert">{{ deleteError() }}</p> }
<div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span>
<span>当前队列 ({{ activeQueue().length }})</span>
@@ -101,7 +102,7 @@ limitations under the License.
<span class="item-time-divider">·</span>
}
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
@if (!agentService.canDeleteSession(session) && ['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button
type="button"
class="btn-stop-card"
@@ -112,7 +113,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
@if (agentService.canDeleteSession(session)) {
<button
type="button"
class="btn-delete-card"
@@ -294,6 +294,7 @@ export class AgentStreamComponent implements AfterViewInit {
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false);
public deleteError = signal('');
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with.
@@ -997,12 +998,13 @@ export class AgentStreamComponent implements AfterViewInit {
public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) {
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return;
}
this.deleteError.set('');
this.agentService.deleteSession(sessionId).subscribe({
error: (err) => {
console.error(`删除会话失败:${sessionId}:`, err);
this.deleteError.set(err.error?.detail || "删除失败,请稍后重试");
}
});
}
@@ -59,6 +59,7 @@ limitations under the License.
<!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') {
<app-task-batch-filter />
@if (errorMessage()) { <p class="error-message" role="alert">{{ errorMessage() }}</p> }
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
@@ -99,7 +100,7 @@ limitations under the License.
<span class="task-time-divider">·</span>
}
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
@if (!agentService.canDeleteSession(session) && (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused')) {
<button
type="button"
class="btn-stop-card"
@@ -110,7 +111,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
@if (agentService.canDeleteSession(session)) {
<button
type="button"
class="btn-delete-card"
@@ -162,7 +162,7 @@ export class ChatInterfaceComponent {
*/
public deleteTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) {
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return;
}
this.isSubmitting.set(true);
@@ -1,4 +1,5 @@
import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { Clipboard } from '@angular/cdk/clipboard';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
@@ -10,11 +11,46 @@ describe('历史任务检索', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.delete-history');
expect(button).not.toBeNull();
const confirm = spyOn(window, 'confirm').and.returnValue(false);
button.click();
expect(TestBed.inject(AgentService).deleteSession).not.toHaveBeenCalled();
expect(confirm.calls.mostRecent().args[0]).toContain('复跑与批次保留');
confirm.and.returnValue(true);
button.click();
expect(TestBed.inject(AgentService).deleteSession).toHaveBeenCalledOnceWith('stopped');
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.delete-history')).toBeNull();
fixture.destroy();
});
it('服务端拒绝删除时保留记录并显示具体原因', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[
{session_id:'stopped',status:'cancelled',exploration_kind:'exploration',start_time:100}
]});
spyOn(window, 'confirm').and.returnValue(true);
(TestBed.inject(AgentService).deleteSession as jasmine.Spy).and.returnValue(throwError(() => ({error:{detail:'探索正在分析,请稍后删除'}})));
fixture.detectChanges();
fixture.nativeElement.querySelector('.delete-history').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('探索正在分析');
expect(fixture.nativeElement.querySelector('.delete-history')).not.toBeNull();
fixture.destroy();
});
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
@@ -14,7 +14,7 @@ import { AgentService } from '../../services/agent.service';
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(row.exploration_kind !== "exploration"){<button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
@@ -38,7 +38,7 @@ export class TaskHistoryComponent {
});
const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
remove(id:string, kind?:string){if(!confirm(this.agent.deletionPrompt(id,kind)))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:e=>this.error.set(e.error?.detail || "删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
@@ -1,5 +1,5 @@
import { signal, computed } from '@angular/core';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { AgentService } from './agent.service';
@@ -531,3 +531,67 @@ describe('Queued exploration identity',()=>{
expect(fields.exploration_id).toBe('conversation');expect(fields.model_info?.architecture).toBe('explore');expect(fields.exploration_latest_run_id).toBe('new');
});
});
describe('Exploration deletion', () => {
function createService() {
const service = Object.create(AgentService.prototype) as AgentService;
const records: any[] = [
{session_id:'old',status:'completed',exploration_kind:'exploration'},
{session_id:'stopped',status:'cancelled',exploration_status:'paused',exploration_kind:'exploration'},
{session_id:'validation',status:'completed',exploration_kind:'validation'}
];
(service as any).rawSessions = signal(records);
(service as any).activeSessionTracking = new Map(records.map(row => [row.session_id,row]));
(service as any).pendingQueue = signal([]);
service.currentSessionId = signal('stopped');
service.userPinnedSessionId = signal('stopped');
service.isVideoWindowOpen = signal(false);
service.runningSessionId = signal(null);
service.resultRevision = signal(0);
(service as any).invalidateStatusSignatures = jasmine.createSpy();
(service as any).persistSessionsCache = jasmine.createSpy();
service.selectSession = jasmine.createSpy();
(service as any).fetchSessions = jasmine.createSpy();
(service as any).fetchStatus = jasmine.createSpy();
const response = new Subject<any>();
(service as any).http = {post:jasmine.createSpy().and.returnValue(response)};
return {service,response};
}
it('shows delete for ended resumable conversations but not live paused workers', () => {
const {service} = createService();
expect(service.canDeleteSession({status:'cancelled'})).toBeTrue();
expect(service.canDeleteSession({status:'completed'})).toBeTrue();
for(const status of ['running','paused','pending','queued','resuming']) {
expect(service.canDeleteSession({status})).toBeFalse();
}
expect(service.deletionPrompt('stopped')).toContain('全部探索执行记录');
expect(service.deletionPrompt('stopped')).toContain('复跑与批次任务将保留');
});
it('waits for success and removes all deleted runs without removing reruns', () => {
const {service,response} = createService();
service.deleteSession('stopped').subscribe();
expect((service as any).rawSessions().length).toBe(3);
expect(service.selectSession).not.toHaveBeenCalled();
response.next({deleted_session_ids:['old','stopped']});
expect((service as any).rawSessions().map((s:any)=>s.session_id)).toEqual(['validation']);
expect((service as any).activeSessionTracking.has('old')).toBeFalse();
expect(service.selectSession).toHaveBeenCalledWith('',false);
expect(service.userPinnedSessionId()).toBeNull();
expect(service.resultRevision()).toBe(1);
});
it('keeps selection and history when server refuses deletion', () => {
const {service,response} = createService();
const error = jasmine.createSpy();
service.deleteSession('stopped').subscribe({error});
response.error({status:409,error:{detail:'探索仍在执行'}});
expect(error).toHaveBeenCalled();
expect((service as any).rawSessions().length).toBe(3);
expect(service.selectSession).not.toHaveBeenCalled();
expect(service.userPinnedSessionId()).toBe('stopped');
expect(service.resultRevision()).toBe(0);
});
});
@@ -16,7 +16,7 @@
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, tap } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
@@ -718,45 +718,39 @@ export class AgentService {
/**
* Delete an individual session / task
*/
public canDeleteSession(session: Pick<Session, 'status'>): boolean {
return ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || '');
}
public deletionPrompt(sessionId: string, explorationKind?: string): string {
const kind = explorationKind || this.rawSessions().find(s => s.session_id === sessionId)?.exploration_kind;
return kind === 'exploration'
? '确定删除此探索的对话、用例版本及全部探索执行记录和文件?已生成的复跑与批次任务将保留。此操作无法撤销。'
: '确定删除此历史任务?此操作无法撤销。';
}
public deleteSession(sessionId: string): Observable<any> {
// 1. Optimistically update local session state immediately
this.invalidateStatusSignatures();
this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId));
this.persistSessionsCache(this.rawSessions());
this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId));
if (this.userPinnedSessionId() === sessionId) {
this.userPinnedSessionId.set(null);
}
if (this.currentSessionId() === sessionId) {
this.selectSession('', false);
if (this.isVideoWindowOpen()) {
this.closeVideoPlayer();
}
const runningId = this.runningSessionId();
if (runningId && this.agentStatus() === 'running') {
this.selectSession(runningId, false);
}
}
// 2. Send API request
return new Observable((obs) => {
this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).subscribe({
next: (res) => {
this.fetchSessions();
this.fetchStatus();
obs.next(res);
obs.complete();
},
error: (err) => {
console.error(`删除会话失败:${sessionId}:`, err);
this.fetchSessions();
this.fetchStatus();
obs.error(err);
// Keep the task selected until the server confirms the entire deletion.
return this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).pipe(tap(res => {
const deleted = new Set<string>(res.deleted_session_ids || [sessionId]);
this.invalidateStatusSignatures();
for (const id of deleted) this.activeSessionTracking.delete(id);
this.rawSessions.update(list => list.filter(s => !deleted.has(s.session_id)));
this.pendingQueue.update(list => list.filter(s => !deleted.has(s.session_id)));
this.persistSessionsCache(this.rawSessions());
if (deleted.has(this.userPinnedSessionId() || '')) this.userPinnedSessionId.set(null);
if (deleted.has(this.currentSessionId() || '')) {
this.selectSession('', false);
if (this.isVideoWindowOpen()) this.closeVideoPlayer();
const runningId = this.runningSessionId();
if (runningId && !deleted.has(runningId) && this.agentStatus() === 'running') {
this.selectSession(runningId, false);
}
});
});
}
this.resultRevision.update(value => value + 1);
this.fetchSessions();
this.fetchStatus();
}));
}
/**