@@ -66,6 +66,7 @@ import {
|
||||
Globe,
|
||||
Code,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
OptionList,
|
||||
@@ -1021,14 +1022,18 @@ export default function ControlClient() {
|
||||
};
|
||||
|
||||
// Handle resuming an interrupted mission
|
||||
const handleResumeMission = async () => {
|
||||
const handleResumeMission = async (cleanWorkspace: boolean = false) => {
|
||||
if (!currentMission || !["interrupted", "blocked"].includes(currentMission.status)) return;
|
||||
try {
|
||||
setMissionLoading(true);
|
||||
const resumed = await resumeMission(currentMission.id);
|
||||
const resumed = await resumeMission(currentMission.id, cleanWorkspace);
|
||||
setCurrentMission(resumed);
|
||||
setShowStatusMenu(false);
|
||||
toast.success(currentMission.status === "blocked" ? "Continuing mission" : "Mission resumed");
|
||||
toast.success(
|
||||
cleanWorkspace
|
||||
? "Mission resumed with clean workspace"
|
||||
: (currentMission.status === "blocked" ? "Continuing mission" : "Mission resumed")
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to resume mission:", err);
|
||||
toast.error("Failed to resume mission");
|
||||
@@ -1413,14 +1418,25 @@ export default function ControlClient() {
|
||||
Mark Failed
|
||||
</button>
|
||||
{(currentMission.status === "interrupted" || currentMission.status === "blocked") && (
|
||||
<button
|
||||
onClick={handleResumeMission}
|
||||
disabled={missionLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-white/70 hover:bg-white/[0.04] disabled:opacity-50"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4 text-emerald-400" />
|
||||
{currentMission.status === "blocked" ? "Continue Mission" : "Resume Mission"}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleResumeMission(false)}
|
||||
disabled={missionLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-white/70 hover:bg-white/[0.04] disabled:opacity-50"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4 text-emerald-400" />
|
||||
{currentMission.status === "blocked" ? "Continue Mission" : "Resume Mission"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResumeMission(true)}
|
||||
disabled={missionLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-white/70 hover:bg-white/[0.04] disabled:opacity-50"
|
||||
title="Delete work folder and start fresh"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-orange-400" />
|
||||
Clean & {currentMission.status === "blocked" ? "Continue" : "Resume"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{currentMission.status !== "active" && currentMission.status !== "interrupted" && currentMission.status !== "blocked" && (
|
||||
<button
|
||||
@@ -1739,18 +1755,29 @@ export default function ControlClient() {
|
||||
)}
|
||||
</p>
|
||||
{currentMission.status === "blocked" && (
|
||||
<button
|
||||
onClick={handleResumeMission}
|
||||
disabled={missionLoading}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{missionLoading ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
Continue Mission
|
||||
</button>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleResumeMission(false)}
|
||||
disabled={missionLoading}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{missionLoading ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
Continue Mission
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResumeMission(true)}
|
||||
disabled={missionLoading}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white/10 border border-white/20 px-4 py-2 text-sm font-medium text-white/70 hover:bg-white/20 hover:text-white transition-colors disabled:opacity-50"
|
||||
title="Delete work folder and start fresh"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clean & Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -2039,7 +2066,7 @@ export default function ControlClient() {
|
||||
<span className="text-white/50 ml-1">— Agent used all {maxIterations} iterations</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResumeMission}
|
||||
onClick={() => handleResumeMission(false)}
|
||||
disabled={missionLoading}
|
||||
className="ml-2 inline-flex items-center gap-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-sm font-medium text-black hover:bg-amber-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -2050,6 +2077,15 @@ export default function ControlClient() {
|
||||
)}
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResumeMission(true)}
|
||||
disabled={missionLoading}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 border border-white/20 px-3 py-1.5 text-sm font-medium text-white/70 hover:bg-white/20 hover:text-white transition-colors disabled:opacity-50"
|
||||
title="Delete work folder and start fresh"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clean & Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -397,9 +397,11 @@ export async function setMissionStatus(
|
||||
}
|
||||
|
||||
// Resume an interrupted mission
|
||||
export async function resumeMission(id: string): Promise<Mission> {
|
||||
export async function resumeMission(id: string, cleanWorkspace: boolean = false): Promise<Mission> {
|
||||
const res = await apiFetch(`/api/control/missions/${id}/resume`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clean_workspace: cleanWorkspace }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
|
||||
@@ -216,7 +216,7 @@ struct ControlView: View {
|
||||
Task { await refreshRunningMissions() }
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.transition(AnyTransition.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
@@ -305,6 +305,8 @@ pub enum ControlCommand {
|
||||
/// Resume an interrupted mission
|
||||
ResumeMission {
|
||||
mission_id: Uuid,
|
||||
/// If true, clean the mission's work directory before resuming
|
||||
clean_workspace: bool,
|
||||
respond: oneshot::Sender<Result<Mission, String>>,
|
||||
},
|
||||
/// Graceful shutdown - mark running missions as interrupted
|
||||
@@ -956,12 +958,22 @@ pub async fn cancel_mission(
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e))
|
||||
}
|
||||
|
||||
/// Request body for resuming a mission
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ResumeMissionRequest {
|
||||
/// If true, clean the mission's work directory before resuming
|
||||
#[serde(default)]
|
||||
pub clean_workspace: bool,
|
||||
}
|
||||
|
||||
/// Resume an interrupted mission.
|
||||
/// This reconstructs context from history and work directory, then restarts execution.
|
||||
pub async fn resume_mission(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(mission_id): Path<Uuid>,
|
||||
body: Option<Json<ResumeMissionRequest>>,
|
||||
) -> Result<Json<Mission>, (StatusCode, String)> {
|
||||
let clean_workspace = body.map(|b| b.clean_workspace).unwrap_or(false);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
state
|
||||
@@ -969,6 +981,7 @@ pub async fn resume_mission(
|
||||
.cmd_tx
|
||||
.send(ControlCommand::ResumeMission {
|
||||
mission_id,
|
||||
clean_workspace,
|
||||
respond: tx,
|
||||
})
|
||||
.await
|
||||
@@ -1335,6 +1348,7 @@ async fn control_actor_loop(
|
||||
memory: &Option<MemorySystem>,
|
||||
config: &Config,
|
||||
mission_id: Uuid,
|
||||
clean_workspace: bool,
|
||||
) -> Result<(Mission, String), String> {
|
||||
let mission = load_mission_from_db(memory, mission_id).await?;
|
||||
|
||||
@@ -1346,6 +1360,19 @@ async fn control_actor_loop(
|
||||
));
|
||||
}
|
||||
|
||||
// Clean workspace if requested
|
||||
let short_id = &mission_id.to_string()[..8];
|
||||
let mission_dir = config.working_dir.join("work").join(format!("mission-{}", short_id));
|
||||
|
||||
if clean_workspace && mission_dir.exists() {
|
||||
tracing::info!("Cleaning workspace for mission {} at {:?}", mission_id, mission_dir);
|
||||
if let Err(e) = std::fs::remove_dir_all(&mission_dir) {
|
||||
tracing::warn!("Failed to clean workspace: {}", e);
|
||||
}
|
||||
// Recreate the directory
|
||||
let _ = std::fs::create_dir_all(&mission_dir);
|
||||
}
|
||||
|
||||
// Build resume context
|
||||
let mut resume_parts = Vec::new();
|
||||
|
||||
@@ -1355,13 +1382,19 @@ async fn control_actor_loop(
|
||||
_ => "was interrupted",
|
||||
};
|
||||
|
||||
let workspace_note = if clean_workspace {
|
||||
" (workspace cleaned)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if let Some(interrupted_at) = &mission.interrupted_at {
|
||||
resume_parts.push(format!(
|
||||
"**MISSION RESUMED**\nThis mission {} at {} and is now being continued.",
|
||||
resume_reason, interrupted_at
|
||||
"**MISSION RESUMED**{}\nThis mission {} at {} and is now being continued.",
|
||||
workspace_note, resume_reason, interrupted_at
|
||||
));
|
||||
} else {
|
||||
resume_parts.push(format!("**MISSION RESUMED**\nThis mission {} and is now being continued.", resume_reason));
|
||||
resume_parts.push(format!("**MISSION RESUMED**{}\nThis mission {} and is now being continued.", workspace_note, resume_reason));
|
||||
}
|
||||
|
||||
// Add history summary
|
||||
@@ -1384,10 +1417,7 @@ async fn control_actor_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// Scan work directory for artifacts
|
||||
let short_id = &mission_id.to_string()[..8];
|
||||
let mission_dir = config.working_dir.join("work").join(format!("mission-{}", short_id));
|
||||
|
||||
// Scan work directory for artifacts (use mission_dir defined earlier)
|
||||
if mission_dir.exists() {
|
||||
resume_parts.push("\n## Work Directory Contents".to_string());
|
||||
|
||||
@@ -1735,9 +1765,9 @@ async fn control_actor_loop(
|
||||
|
||||
let _ = respond.send(running_list);
|
||||
}
|
||||
ControlCommand::ResumeMission { mission_id, respond } => {
|
||||
ControlCommand::ResumeMission { mission_id, clean_workspace, respond } => {
|
||||
// Resume an interrupted mission by building resume context
|
||||
match resume_mission_impl(&memory, &config, mission_id).await {
|
||||
match resume_mission_impl(&memory, &config, mission_id, clean_workspace).await {
|
||||
Ok((mission, resume_prompt)) => {
|
||||
// First persist current mission history (if any)
|
||||
persist_mission_history(&memory, ¤t_mission, &history).await;
|
||||
|
||||
@@ -804,7 +804,7 @@ impl Tool for BrowserClick {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Click on an element in the browser. Use CSS selectors like '#id', '.class', 'button', 'a[href*=login]', etc."
|
||||
"Click on an element in the browser. Use standard CSS selectors like '#id', '.class', 'button', 'a[href*=login]', etc. NOTE: jQuery pseudo-selectors like :contains() are NOT supported - use XPath or JavaScript for text matching instead."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -813,7 +813,7 @@ impl Tool for BrowserClick {
|
||||
"properties": {
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector for the element to click"
|
||||
"description": "Standard CSS selector (e.g., '#id', '.class', 'button', 'a[href*=download]'). Do NOT use jQuery :contains() - it's not valid CSS."
|
||||
}
|
||||
},
|
||||
"required": ["selector"]
|
||||
@@ -924,7 +924,7 @@ impl Tool for BrowserEvaluate {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute JavaScript code in the browser and return the result. Useful for complex interactions, extracting data, or debugging."
|
||||
"Execute JavaScript code in the browser and return the result. Useful for complex interactions, extracting data, or debugging. NOTE: DevTools-only APIs like getEventListeners() are NOT available - use standard DOM APIs only."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -933,7 +933,7 @@ impl Tool for BrowserEvaluate {
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "JavaScript code to execute. The result of the last expression is returned."
|
||||
"description": "JavaScript code to execute. Use standard DOM APIs only - DevTools-only functions like getEventListeners() are not available. The result of the last expression is returned."
|
||||
}
|
||||
},
|
||||
"required": ["script"]
|
||||
@@ -973,7 +973,7 @@ impl Tool for BrowserWait {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Wait for an element to appear or a condition to be met. Use after clicking or navigating when content loads dynamically."
|
||||
"Wait for an element to appear or a condition to be met. Use after clicking or navigating when content loads dynamically. NOTE: Use standard CSS selectors only - jQuery pseudo-selectors like :contains() are NOT supported."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -982,7 +982,7 @@ impl Tool for BrowserWait {
|
||||
"properties": {
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector to wait for"
|
||||
"description": "Standard CSS selector to wait for (e.g., '#id', '.class', 'div.loaded'). Do NOT use jQuery :contains()."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
@@ -1080,7 +1080,7 @@ impl Tool for BrowserListElements {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List interactive elements on the current page (links, buttons, inputs, etc.). Useful for understanding page structure before interacting."
|
||||
"List interactive elements on the current page (links, buttons, inputs, etc.). Useful for understanding page structure before interacting. NOTE: Use standard CSS selectors only - jQuery pseudo-selectors like :contains() are NOT supported."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -1089,7 +1089,7 @@ impl Tool for BrowserListElements {
|
||||
"properties": {
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "Optional CSS selector to filter elements (default: 'a, button, input, select, textarea, [onclick]')"
|
||||
"description": "Standard CSS selector to filter elements (default: 'a, button, input, select, textarea, [onclick]'). Do NOT use jQuery :contains() - it's not valid CSS."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
|
||||
Reference in New Issue
Block a user