feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream
- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization) - Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration - Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery - Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution - Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges - Add comprehensive unit test coverage and edge-case verification scripts
This commit is contained in:
1 parent
a36a66ce6d
commit
c2c90a6378
84 files changed
+5612
-645
No files matched your search
@@ -68,6 +68,9 @@ export class AgentService {
|
||||
// Signals to expose state to components
|
||||
private rawSessions = signal<Session[]>([]);
|
||||
private pendingQueue = signal<Session[]>([]);
|
||||
public activeTasks = signal<any[]>([]);
|
||||
// Persistent tracking of active/pending sessions across polling boundaries
|
||||
private activeSessionTracking = new Map<string, Session>();
|
||||
|
||||
public sessions = computed(() => {
|
||||
const raw = this.rawSessions();
|
||||
@@ -75,47 +78,111 @@ export class AgentService {
|
||||
const status = this.agentStatus();
|
||||
const goal = this.runningGoal();
|
||||
const runId = this.runningSessionId();
|
||||
const activeList = this.activeTasks();
|
||||
|
||||
const sessionMap = new Map<string, Session>();
|
||||
|
||||
// 1. First add raw sessions from DB
|
||||
raw.forEach((s) => {
|
||||
const isCurrentActive = (status === 'running' || status === 'paused') && runId === s.session_id;
|
||||
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
|
||||
let sStatus = isCurrentActive ? status : (isPending ? 'pending' : s.status);
|
||||
if ((sStatus === 'running' || sStatus === 'paused') && !isCurrentActive) {
|
||||
const activeRun = raw.find(r => r.session_id === runId);
|
||||
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
|
||||
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
||||
|| activeList.some(at => at.session_id === s.session_id);
|
||||
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
||||
let sStatus = s.status;
|
||||
if (!isTerminal) {
|
||||
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
|
||||
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
||||
} else {
|
||||
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
||||
}
|
||||
sessionMap.set(s.session_id, {
|
||||
const activeMatch = activeList.find(at => at.session_id === s.session_id);
|
||||
const pendingMatch = this.pendingQueue().find(p => p.session_id === s.session_id);
|
||||
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
|
||||
if (!serial && s.device_info) {
|
||||
try {
|
||||
const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info;
|
||||
serial = info?.device_id || info?.device_serial || null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const finalSession: Session = {
|
||||
...s,
|
||||
status: sStatus,
|
||||
device_serial: serial,
|
||||
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||
});
|
||||
};
|
||||
sessionMap.set(s.session_id, finalSession);
|
||||
if (isTerminal) {
|
||||
this.activeSessionTracking.delete(s.session_id);
|
||||
} else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') {
|
||||
this.activeSessionTracking.set(s.session_id, finalSession);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Add pending queue sessions if not yet in raw sessions
|
||||
pending.forEach((p) => {
|
||||
if (!sessionMap.has(p.session_id)) {
|
||||
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
|
||||
sessionMap.set(p.session_id, {
|
||||
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|
||||
|| activeList.some(at => at.session_id === p.session_id);
|
||||
const activeMatch = activeList.find(at => at.session_id === p.session_id);
|
||||
const finalPending: Session = {
|
||||
...p,
|
||||
status: isCurrentRunning ? status : (p.status || 'pending')
|
||||
});
|
||||
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||
};
|
||||
sessionMap.set(p.session_id, finalPending);
|
||||
this.activeSessionTracking.set(p.session_id, finalPending);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Ensure currently active running session is present
|
||||
if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
||||
sessionMap.set(runId, {
|
||||
// 3. Ensure all currently active running sessions are present (multi-device & external runs)
|
||||
if (activeList.length > 0) {
|
||||
activeList.forEach((at) => {
|
||||
const sid = at.session_id || `active-${at.device_id}`;
|
||||
const existing = sessionMap.get(sid);
|
||||
if (!existing) {
|
||||
const newSession: Session = {
|
||||
session_id: sid,
|
||||
initial_goal: at.goal || goal || '',
|
||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||
status: 'running',
|
||||
model_info: this.activeModel() || undefined,
|
||||
device_serial: at.device_id || null
|
||||
};
|
||||
sessionMap.set(sid, newSession);
|
||||
this.activeSessionTracking.set(sid, newSession);
|
||||
} else if (existing.status === 'pending') {
|
||||
const updated: Session = {
|
||||
...existing,
|
||||
status: 'running',
|
||||
device_serial: at.device_id || existing.device_serial
|
||||
};
|
||||
sessionMap.set(sid, updated);
|
||||
this.activeSessionTracking.set(sid, updated);
|
||||
}
|
||||
});
|
||||
} else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
||||
const activeSession: Session = {
|
||||
session_id: runId,
|
||||
initial_goal: goal || '',
|
||||
start_time: Date.now() / 1000,
|
||||
status,
|
||||
model_info: this.activeModel() || undefined
|
||||
});
|
||||
};
|
||||
sessionMap.set(runId, activeSession);
|
||||
this.activeSessionTracking.set(runId, activeSession);
|
||||
}
|
||||
|
||||
// 4. Bridge transient queue-to-running transition gaps
|
||||
this.activeSessionTracking.forEach((ts, sid) => {
|
||||
if (!sessionMap.has(sid)) {
|
||||
sessionMap.set(sid, {
|
||||
...ts,
|
||||
status: 'running'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
|
||||
});
|
||||
|
||||
@@ -137,8 +204,14 @@ export class AgentService {
|
||||
public currentStartupProgress = computed(() => {
|
||||
const sessionId = this.currentSessionId();
|
||||
if (sessionId) {
|
||||
const sessionEvents = this.startupProgressBySession()[sessionId];
|
||||
if (sessionEvents?.length) return sessionEvents;
|
||||
const normalized = String(sessionId).trim().toLowerCase();
|
||||
const bySession = this.startupProgressBySession();
|
||||
const direct = bySession[sessionId];
|
||||
if (direct?.length) return direct;
|
||||
const matched = Object.entries(bySession).find(
|
||||
([key]) => key.trim().toLowerCase() === normalized
|
||||
);
|
||||
if (matched?.[1]?.length) return matched[1];
|
||||
}
|
||||
return this.pendingStartupProgress();
|
||||
});
|
||||
@@ -609,7 +682,10 @@ export class AgentService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evtSessionId && String(evtSessionId) !== String(sessionId)) {
|
||||
const isDifferentSession = evtSessionId
|
||||
&& String(evtSessionId).trim().toLowerCase() !== String(sessionId).trim().toLowerCase();
|
||||
|
||||
if (isDifferentSession) {
|
||||
if (eventType === 'session_started') {
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(parsedData.session_id);
|
||||
@@ -617,13 +693,30 @@ export class AgentService {
|
||||
this.runningGoal.set(parsedData.initial_goal);
|
||||
}
|
||||
this.fetchSessions();
|
||||
// Only auto-select if user hasn't explicitly chosen to inspect another task
|
||||
const pinnedId = this.userPinnedSessionId();
|
||||
if (!pinnedId && parsedData?.session_id) {
|
||||
// Only auto-select if user has no current session at all
|
||||
const currentId = this.currentSessionId();
|
||||
if (!currentId && parsedData?.session_id) {
|
||||
this.selectSession(parsedData.session_id, false);
|
||||
}
|
||||
} else if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(evtSessionId, parsedData);
|
||||
this.activeTasks.update(list => list.filter(at => at.session_id !== evtSessionId));
|
||||
const remaining = this.activeTasks();
|
||||
if (remaining.length > 0) {
|
||||
if (this.runningSessionId() === evtSessionId) {
|
||||
this.runningSessionId.set(remaining[0].session_id);
|
||||
this.runningGoal.set(remaining[0].goal || null);
|
||||
}
|
||||
} else if (this.pendingQueue().length > 0) {
|
||||
const nextPending = this.pendingQueue()[0];
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(nextPending.session_id);
|
||||
this.runningGoal.set(nextPending.initial_goal || null);
|
||||
} else {
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
}
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
this.updateModelForCurrentView();
|
||||
@@ -650,12 +743,26 @@ export class AgentService {
|
||||
|
||||
if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(sessionId, parsedData);
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
this.activeTasks.update(list => list.filter(at => at.session_id !== sessionId));
|
||||
const remaining = this.activeTasks();
|
||||
if (remaining.length > 0) {
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(remaining[0].session_id);
|
||||
this.runningGoal.set(remaining[0].goal || null);
|
||||
} else if (this.pendingQueue().length > 0) {
|
||||
const nextPending = this.pendingQueue()[0];
|
||||
this.agentStatus.set('running');
|
||||
this.runningSessionId.set(nextPending.session_id);
|
||||
this.runningGoal.set(nextPending.initial_goal || null);
|
||||
} else {
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
}
|
||||
this.isRetrying.set(false);
|
||||
this.isPaused.set(false);
|
||||
this.fetchSessions();
|
||||
this.fetchStatus();
|
||||
this.fetchNotes(sessionId);
|
||||
this.updateModelForCurrentView();
|
||||
if (
|
||||
@@ -1072,7 +1179,9 @@ export class AgentService {
|
||||
: null;
|
||||
|
||||
if (status) {
|
||||
this.activeSessionTracking.delete(String(sessionId));
|
||||
this.setSessionStatus(String(sessionId), status);
|
||||
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,34 +1209,15 @@ export class AgentService {
|
||||
if (data && data.status) {
|
||||
const oldStatus = this.agentStatus();
|
||||
const oldRunningSessionId = this.runningSessionId();
|
||||
|
||||
this.agentStatus.set(data.status);
|
||||
const isActive = data.status === 'running' || data.status === 'paused';
|
||||
if (isActive) {
|
||||
this.runningSessionId.set(data.session_id || null);
|
||||
this.runningGoal.set(data.goal || null);
|
||||
if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
} else {
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
|
||||
// If viewing a selected history session, use its model_info
|
||||
const curId = this.currentSessionId();
|
||||
if (curId) {
|
||||
const selected = this.sessions().find(s => s.session_id === curId);
|
||||
if (selected && selected.model_info) {
|
||||
this.activeModel.set(selected.model_info);
|
||||
} else if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
} else if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
this.agentStatus.set(data.status);
|
||||
this.runningSessionId.set(data.session_id || null);
|
||||
this.runningGoal.set(data.goal || null);
|
||||
if (data.model_info) {
|
||||
this.activeModel.set(data.model_info);
|
||||
}
|
||||
|
||||
this.isPaused.set(data.status === 'paused');
|
||||
|
||||
if (data.status === 'paused') {
|
||||
this.isRetrying.set(false);
|
||||
const pauseError = data.paused_error
|
||||
@@ -1149,7 +1239,8 @@ export class AgentService {
|
||||
session_id: item.session_id || `pending-task-${index}`,
|
||||
initial_goal: item.goal || '',
|
||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||
status: item.status || 'pending'
|
||||
status: item.status || 'pending',
|
||||
device_serial: item.device_serial || item.device_id || null
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1160,6 +1251,7 @@ export class AgentService {
|
||||
};
|
||||
});
|
||||
this.pendingQueue.set(pending);
|
||||
this.activeTasks.set(data.active_tasks || []);
|
||||
|
||||
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
|
||||
this.fetchSessions();
|
||||
@@ -1176,16 +1268,11 @@ export class AgentService {
|
||||
this.beginRecordingFinalization(oldRunningSessionId);
|
||||
}
|
||||
|
||||
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
|
||||
// Auto-select the session ONLY if nothing is currently selected
|
||||
if (isActive && data.session_id) {
|
||||
const currentId = this.currentSessionId();
|
||||
const pinnedId = this.userPinnedSessionId();
|
||||
if (!currentId) {
|
||||
this.selectSession(data.session_id, false);
|
||||
} else if (currentId !== data.session_id) {
|
||||
if (!pinnedId) {
|
||||
this.selectSession(data.session_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1328,8 +1415,9 @@ export class AgentService {
|
||||
timestamp
|
||||
};
|
||||
|
||||
const rawKey = String(data.session_id || sessionId).trim();
|
||||
this.startupProgressBySession.update((allEvents) => {
|
||||
const current = [...(allEvents[sessionId] || [])];
|
||||
const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])];
|
||||
const existingIndex = current.findIndex((item) => item.stage === event.stage);
|
||||
if (existingIndex >= 0) {
|
||||
current[existingIndex] = event;
|
||||
@@ -1337,7 +1425,11 @@ export class AgentService {
|
||||
current.push(event);
|
||||
}
|
||||
current.sort((a, b) => a.timestamp - b.timestamp);
|
||||
return { ...allEvents, [sessionId]: current };
|
||||
const updated = { ...allEvents, [rawKey]: current };
|
||||
if (sessionId && sessionId !== rawKey) {
|
||||
updated[sessionId] = current;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user