Add session scoped runs and cancellation

This commit is contained in:
武阳
2026-05-07 16:26:20 +08:00
parent a4a0677ce4
commit 9d7d496443
6 changed files with 542 additions and 76 deletions
+304 -48
View File
@@ -19,6 +19,7 @@ from pathlib import Path
from threading import Lock, RLock
from typing import Any
from urllib import error, request
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
@@ -167,6 +168,129 @@ class AgentInstanceConfig:
allow_write: bool
class RunProcessRegistry:
def __init__(self) -> None:
self._lock = Lock()
self._processes: set[Any] = set()
def add(self, process: Any) -> None:
with self._lock:
self._processes.add(process)
def discard(self, process: Any) -> None:
with self._lock:
self._processes.discard(process)
def kill_all(self) -> None:
with self._lock:
processes = list(self._processes)
for process in processes:
try:
if process.poll() is None:
process.terminate()
except OSError:
continue
deadline = time.monotonic() + 1.0
for process in processes:
try:
remaining = max(0.0, deadline - time.monotonic())
process.wait(timeout=remaining)
except Exception:
try:
process.kill()
except OSError:
pass
@dataclass
class RunRecord:
run_id: str
account_key: str
session_id: str
status: str
started_at: float
updated_at: float
cancel_event: threading.Event
process_registry: RunProcessRegistry
current_stage: str = ''
error: str = ''
class RunManager:
def __init__(self) -> None:
self._lock = RLock()
self._runs: dict[str, RunRecord] = {}
self._latest_by_session: dict[tuple[str, str], str] = {}
def start(self, account_key: str, session_id: str) -> RunRecord:
now = time.time()
record = RunRecord(
run_id=uuid4().hex,
account_key=account_key,
session_id=session_id,
status='queued',
started_at=now,
updated_at=now,
cancel_event=threading.Event(),
process_registry=RunProcessRegistry(),
)
with self._lock:
self._runs[record.run_id] = record
self._latest_by_session[(account_key, session_id)] = record.run_id
return record
def update(self, run_id: str, *, status: str | None = None, stage: str | None = None, error: str | None = None) -> None:
with self._lock:
record = self._runs.get(run_id)
if record is None:
return
if status is not None:
record.status = status
if stage is not None:
record.current_stage = stage
if error is not None:
record.error = error
record.updated_at = time.time()
def finish(self, run_id: str, status: str) -> None:
self.update(run_id, status=status)
def cancel_latest(self, account_key: str, session_id: str) -> bool:
with self._lock:
run_id = self._latest_by_session.get((account_key, session_id))
record = self._runs.get(run_id or '')
return self.cancel_record(record)
def cancel_run(self, run_id: str) -> bool:
with self._lock:
record = self._runs.get(run_id)
return self.cancel_record(record)
def cancel_record(self, record: RunRecord | None) -> bool:
if record is None or record.status in {'completed', 'failed', 'cancelled'}:
return False
record.cancel_event.set()
record.process_registry.kill_all()
self.update(record.run_id, status='cancelled', stage='用户已取消')
return True
def snapshot_latest(self, account_key: str, session_id: str) -> dict[str, Any] | None:
with self._lock:
run_id = self._latest_by_session.get((account_key, session_id))
record = self._runs.get(run_id or '')
if record is None:
return None
return {
'run_id': record.run_id,
'session_id': record.session_id,
'status': record.status,
'current_stage': record.current_stage,
'started_at': record.started_at,
'updated_at': record.updated_at,
'error': record.error,
}
class AgentState:
"""Holds account-scoped agent instances, config, and execution locks."""
@@ -186,6 +310,7 @@ class AgentState:
self._agents: dict[str, LocalCodingAgent] = {}
self._run_locks: dict[str, Lock] = {}
self._account_configs: dict[str, AgentInstanceConfig] = {}
self.run_manager = RunManager()
self._default_config = AgentInstanceConfig(
cwd=cwd.resolve(),
model=model,
@@ -222,6 +347,10 @@ class AgentState:
def _account_key(self, account_id: str | None) -> str:
return _safe_account_id(account_id) if account_id else '__default__'
def _session_key(self, account_id: str | None, session_id: str | None = None) -> str:
session_part = _safe_session_id(session_id) or '__shared__'
return f'{self._account_key(account_id)}:{session_part}'
def _config_for(self, account_id: str | None) -> AgentInstanceConfig:
if not account_id:
return self._default_config
@@ -298,17 +427,25 @@ class AgentState:
runtime_config=runtime_config,
)
def agent_for(self, account_id: str | None = None) -> LocalCodingAgent:
def agent_for(
self,
account_id: str | None = None,
session_id: str | None = None,
) -> LocalCodingAgent:
with self._lock:
key = self._account_key(account_id)
key = self._session_key(account_id, session_id)
agent = self._agents.get(key)
if agent is None:
agent = self._build_agent(account_id)
self._agents[key] = agent
return agent
def run_lock_for(self, account_id: str | None = None) -> Lock:
key = self._account_key(account_id)
def run_lock_for(
self,
account_id: str | None = None,
session_id: str | None = None,
) -> Lock:
key = self._session_key(account_id, session_id)
with self._lock:
lock = self._run_locks.get(key)
if lock is None:
@@ -345,7 +482,10 @@ class AgentState:
if allow_write is not None:
config = replace(config, allow_write=allow_write)
self._set_config_for(account_id, config)
self._agents.pop(self._account_key(account_id), None)
account_prefix = f'{self._account_key(account_id)}:'
for key in list(self._agents):
if key.startswith(account_prefix):
self._agents.pop(key, None)
def snapshot(self, account_id: str | None = None) -> dict[str, Any]:
with self._lock:
@@ -391,6 +531,12 @@ class ChatRequest(BaseModel):
session_id: str | None = None
class RunCancelRequest(BaseModel):
session_id: str = Field(min_length=1)
account_id: str | None = None
run_id: str | None = None
class StateUpdate(BaseModel):
model: str | None = None
base_url: str | None = None
@@ -598,57 +744,136 @@ def create_app(state: AgentState) -> FastAPI:
)
return _serialize_token_budget(snapshot)
@app.get('/api/runs/latest')
async def latest_run(
session_id: str,
account_id: str | None = None,
) -> dict[str, Any]:
safe_id = _safe_session_id(session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
snapshot = state.run_manager.snapshot_latest(
state._account_key(account_id),
safe_id,
)
return snapshot or {'session_id': safe_id, 'status': 'idle'}
@app.post('/api/runs/cancel')
async def cancel_run(payload: RunCancelRequest) -> dict[str, Any]:
safe_id = _safe_session_id(payload.session_id)
if safe_id is None:
raise HTTPException(status_code=400, detail='Invalid session id')
cancelled = (
state.run_manager.cancel_run(payload.run_id)
if payload.run_id
else state.run_manager.cancel_latest(
state._account_key(payload.account_id),
safe_id,
)
)
return {'session_id': safe_id, 'cancelled': cancelled}
def _run_chat_payload(
request: ChatRequest,
event_sink: Any | None = None,
) -> dict[str, Any]:
run_lock = state.run_lock_for(request.account_id)
with run_lock:
agent = state.agent_for(request.account_id)
config = state.config_for(request.account_id)
session_directory = state.account_paths(request.account_id)['sessions']
requested_session_id = _safe_session_id(
request.resume_session_id or request.session_id
requested_session_id = _safe_session_id(
request.resume_session_id or request.session_id
) or uuid4().hex
account_key = state._account_key(request.account_id)
run_record = state.run_manager.start(account_key, requested_session_id)
run_lock = state.run_lock_for(request.account_id, requested_session_id)
agent = state.agent_for(request.account_id, requested_session_id)
config = state.config_for(request.account_id)
session_directory = state.account_paths(request.account_id)['sessions']
if request.resume_session_id is None:
_save_in_progress_session(
directory=session_directory,
agent=agent,
session_id=requested_session_id,
prompt=request.prompt.strip(),
)
previous_title = _read_session_title(
session_directory,
requested_session_id,
previous_title = _read_session_title(
session_directory,
requested_session_id,
)
if run_lock.locked():
_emit_runtime_event(
event_sink,
{
'type': 'run_queued',
'run_id': run_record.run_id,
'session_id': requested_session_id or '',
'account_id': request.account_id or '',
},
)
with run_lock:
if run_record.cancel_event.is_set():
state.run_manager.finish(run_record.run_id, 'cancelled')
return {
'final_output': '已取消排队中的请求。',
'turns': 0,
'tool_calls': 0,
'transcript': (),
'session_id': requested_session_id,
'usage': {},
'total_cost_usd': 0.0,
'stop_reason': 'cancelled',
}
state.run_manager.update(run_record.run_id, status='running', stage='后端开始执行')
_emit_runtime_event(
event_sink,
{
'type': 'run_started',
'run_id': run_record.run_id,
'session_id': requested_session_id or '',
'account_id': request.account_id or '',
},
)
agent.tool_context = replace(
agent.tool_context,
cancel_event=run_record.cancel_event,
process_registry=run_record.process_registry,
)
started_at = time.perf_counter()
if request.resume_session_id is not None:
if requested_session_id is None:
raise HTTPException(
status_code=404,
detail='Session to resume not found',
)
try:
try:
stored = load_agent_session(
requested_session_id,
directory=session_directory,
if request.resume_session_id is not None:
try:
stored = load_agent_session(
requested_session_id,
directory=session_directory,
)
except FileNotFoundError:
raise HTTPException(
status_code=404,
detail='Session to resume not found',
)
result = agent.resume(
request.prompt.strip(),
stored,
runtime_context=request.runtime_context,
event_sink=event_sink,
)
else:
result = agent.run(
request.prompt.strip(),
session_id=requested_session_id,
runtime_context=request.runtime_context,
event_sink=event_sink,
)
except Exception as exc:
state.run_manager.update(
run_record.run_id,
status='failed',
error=str(exc),
)
except FileNotFoundError:
raise HTTPException(
status_code=404,
detail='Session to resume not found',
)
result = agent.resume(
request.prompt.strip(),
stored,
runtime_context=request.runtime_context,
event_sink=event_sink,
)
else:
_save_in_progress_session(
directory=session_directory,
agent=agent,
session_id=requested_session_id,
prompt=request.prompt.strip(),
)
result = agent.run(
request.prompt.strip(),
session_id=requested_session_id,
runtime_context=request.runtime_context,
event_sink=event_sink,
raise
finally:
agent.tool_context = replace(
agent.tool_context,
cancel_event=None,
process_registry=None,
)
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
payload = _serialize_run_result(result)
@@ -668,6 +893,10 @@ def create_app(state: AgentState) -> FastAPI:
previous_title=previous_title,
)
_annotate_transcript_elapsed(payload, elapsed_ms)
state.run_manager.finish(
run_record.run_id,
'cancelled' if run_record.cancel_event.is_set() else 'completed',
)
return payload
# ------------- chat ------------------------------------------------------
@@ -732,9 +961,27 @@ def create_app(state: AgentState) -> FastAPI:
threading.Thread(target=worker, daemon=True).start()
stream_started = time.perf_counter()
def generate() -> Any:
while True:
item = event_queue.get()
try:
item = event_queue.get(timeout=15)
except queue.Empty:
yield json.dumps(
{
'kind': 'event',
'event': {
'type': 'server_heartbeat',
'elapsed_ms': max(
0,
int((time.perf_counter() - stream_started) * 1000),
),
},
},
ensure_ascii=False,
) + '\n'
continue
if item is None:
break
yield json.dumps(item, ensure_ascii=False) + '\n'
@@ -790,6 +1037,15 @@ def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
}
def _emit_runtime_event(
event_sink: Any | None,
event: dict[str, object],
) -> None:
if event_sink is None:
return
event_sink(event)
def _save_in_progress_session(
*,
directory: Path,