feat: make max iterations resumable with Continue button

- Mark missions as "blocked" instead of "failed" when max iterations reached
- Allow resuming blocked missions (same as interrupted)
- Add "Continue Mission" button in UI for blocked missions
- Show iteration limit banner in conversation view
This commit is contained in:
Thomas Marchand
2025-12-23 08:50:38 +01:00
parent f20b84f172
commit 486db50be3
2 changed files with 75 additions and 16 deletions

View File

@@ -753,13 +753,13 @@ export default function ControlClient() {
// Handle resuming an interrupted mission
const handleResumeMission = async () => {
if (!currentMission || currentMission.status !== "interrupted") return;
if (!currentMission || !["interrupted", "blocked"].includes(currentMission.status)) return;
try {
setMissionLoading(true);
const resumed = await resumeMission(currentMission.id);
setCurrentMission(resumed);
setShowStatusMenu(false);
toast.success("Mission resumed");
toast.success(currentMission.status === "blocked" ? "Continuing mission" : "Mission resumed");
} catch (err) {
console.error("Failed to resume mission:", err);
toast.error("Failed to resume mission");
@@ -1439,16 +1439,36 @@ export default function ControlClient() {
) : currentMission && currentMission.status !== "active" ? (
<>
<h2 className="text-lg font-medium text-white">
{currentMission.status === "interrupted" ? "Mission Interrupted" : "No conversation history"}
{currentMission.status === "interrupted"
? "Mission Interrupted"
: currentMission.status === "blocked"
? "Iteration Limit Reached"
: "No conversation history"}
</h2>
<p className="mt-2 text-sm text-white/40 max-w-sm">
{currentMission.status === "interrupted" ? (
<>This mission was interrupted (server shutdown or cancellation). Click the <strong className="text-amber-400">Resume</strong> button in the mission menu to continue where you left off.</>
) : currentMission.status === "blocked" ? (
<>The agent reached its iteration limit (50). You can continue the mission to give it more iterations.</>
) : (
<>This mission was {currentMission.status} without any messages.
{currentMission.status === "completed" && " You can reactivate it to continue."}</>
)}
</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>
)}
</>
) : (
<>
@@ -1732,6 +1752,32 @@ export default function ControlClient() {
</div>
);
})}
{/* Continue banner for blocked missions */}
{currentMission?.status === "blocked" && items.length > 0 && (
<div className="flex justify-center py-4">
<div className="flex items-center gap-3 rounded-xl bg-amber-500/10 border border-amber-500/20 px-5 py-3">
<Clock className="h-5 w-5 text-amber-400" />
<div className="text-sm">
<span className="text-amber-400 font-medium">Iteration limit reached</span>
<span className="text-white/50 ml-1"> Agent used all 50 iterations</span>
</div>
<button
onClick={handleResumeMission}
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"
>
{missionLoading ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
Continue
</button>
</div>
</div>
)}
<div ref={endRef} />
</div>
)}

View File

@@ -1328,7 +1328,7 @@ async fn control_actor_loop(
})
}
// Helper to build resume context for an interrupted mission
// Helper to build resume context for an interrupted or blocked mission
async fn resume_mission_impl(
memory: &Option<MemorySystem>,
config: &Config,
@@ -1336,8 +1336,8 @@ async fn control_actor_loop(
) -> Result<(Mission, String), String> {
let mission = load_mission_from_db(memory, mission_id).await?;
// Check if mission can be resumed
if mission.status != MissionStatus::Interrupted {
// Check if mission can be resumed (interrupted or blocked)
if !matches!(mission.status, MissionStatus::Interrupted | MissionStatus::Blocked) {
return Err(format!(
"Mission {} cannot be resumed (status: {})",
mission_id, mission.status
@@ -1347,14 +1347,19 @@ async fn control_actor_loop(
// Build resume context
let mut resume_parts = Vec::new();
// Add interruption notice
// Add resumption notice based on status
let resume_reason = match mission.status {
MissionStatus::Blocked => "reached its iteration limit",
_ => "was interrupted",
};
if let Some(interrupted_at) = &mission.interrupted_at {
resume_parts.push(format!(
"**MISSION RESUMED**\nThis mission was interrupted at {} and is now being continued.",
interrupted_at
"**MISSION RESUMED**\nThis mission {} at {} and is now being continued.",
resume_reason, interrupted_at
));
} else {
resume_parts.push("**MISSION RESUMED**\nThis mission was interrupted and is now being continued.".to_string());
resume_parts.push(format!("**MISSION RESUMED**\nThis mission {} and is now being continued.", resume_reason));
}
// Add history summary
@@ -1966,7 +1971,20 @@ async fn control_actor_loop(
.map(|m| m.status);
if current_status.as_deref() == Some("active") {
let status = if agent_result.success { "completed" } else { "failed" };
// Determine status based on terminal reason
// MaxIterations -> blocked (resumable) instead of failed
let (status, new_status) = match agent_result.terminal_reason {
Some(TerminalReason::MaxIterations) => {
("blocked", MissionStatus::Blocked)
}
_ if agent_result.success => {
("completed", MissionStatus::Completed)
}
_ => {
("failed", MissionStatus::Failed)
}
};
tracing::info!(
"Auto-completing mission {} with status '{}' (terminal_reason: {:?})",
mission_id, status, agent_result.terminal_reason
@@ -1975,11 +1993,6 @@ async fn control_actor_loop(
tracing::warn!("Failed to auto-complete mission: {}", e);
} else {
// Emit status change event
let new_status = if agent_result.success {
MissionStatus::Completed
} else {
MissionStatus::Failed
};
let _ = events_tx.send(AgentEvent::MissionStatusChanged {
mission_id,
status: new_status,