diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index 232ba85..6a131e7 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -4,7 +4,9 @@ import { invoke } from "@tauri-apps/api/core"; import { taskLoopStore, - findNextPendingIndex, + getReadyTasks, + computeWaves, + isTaskBlocked, buildTaskPrompt, normalizeToUnixPath, type TaskLoopTask, @@ -23,53 +25,60 @@ const tasks = $derived(taskLoopStore.tasks); const loopStatus = $derived(taskLoopStore.loopStatus); - const currentTaskIndex = $derived(taskLoopStore.currentTaskIndex); const sourceFile = $derived(taskLoopStore.sourceFile); const conversations = $derived(claudeStore.conversations); + const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit); - // Orchestration phase (panel-local, not persisted) + // Per-task orchestration phases (panel-local, not persisted) type LoopPhase = "waiting_for_connection" | "waiting_for_completion"; - let loopPhase = $state(null); - let taskEverStarted = $state(false); + let activePhases = $state>({}); + let taskEverStartedMap = $state>({}); let isLoading = $state(false); let errorMessage = $state(null); const completedCount = $derived($tasks.filter((t) => t.status === "completed").length); const failedCount = $derived($tasks.filter((t) => t.status === "failed").length); + const blockedCount = $derived($tasks.filter((t) => t.status === "blocked").length); + const runningCount = $derived($tasks.filter((t) => t.status === "running").length); const totalCount = $derived($tasks.length); + const waves = $derived(computeWaves($tasks)); + const multiWave = $derived(waves.length > 1); const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"]; - // Watch the current task's conversation for state transitions + // Watch all active tasks' conversations for state transitions $effect(() => { - const phase = loopPhase; - if (!phase) return; + for (const [idxStr, phase] of Object.entries(activePhases)) { + const taskIdx = Number(idxStr); + const taskList = $tasks; + if (taskIdx < 0 || taskIdx >= taskList.length) continue; - const taskIdx = $currentTaskIndex; - const taskList = $tasks; - if (taskIdx < 0 || taskIdx >= taskList.length) return; + const currentTask = taskList[taskIdx]; + if (!currentTask.conversationId) continue; - const currentTask = taskList[taskIdx]; - if (!currentTask.conversationId) return; + const conv = $conversations.get(currentTask.conversationId); + if (!conv) continue; - const conv = $conversations.get(currentTask.conversationId); - if (!conv) return; - - if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") { - loopPhase = "waiting_for_completion"; - taskEverStarted = false; - void sendTaskPrompt(currentTask, taskIdx, taskList.length); - return; - } - - if (phase === "waiting_for_completion") { - if (workingStates.includes(conv.characterState)) { - taskEverStarted = true; + if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") { + activePhases = { ...activePhases, [taskIdx]: "waiting_for_completion" }; + taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false }; + void sendTaskPrompt(currentTask, taskIdx, taskList.length); + continue; } - if (taskEverStarted && conv.characterState === "idle") { - taskEverStarted = false; - loopPhase = null; - void onTaskCompleted(taskIdx, "completed"); + + if (phase === "waiting_for_completion") { + if (workingStates.includes(conv.characterState)) { + taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true }; + } + if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") { + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); + taskEverStartedMap = Object.fromEntries( + Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx) + ); + void onTaskCompleted(taskIdx, "completed"); + } } } }); @@ -83,7 +92,9 @@ }); } catch (error) { console.error("Failed to send task prompt:", error); - loopPhase = null; + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); void onTaskCompleted(taskIdx, "failed"); } } @@ -94,16 +105,34 @@ const currentLoopStatus = get(taskLoopStore.loopStatus); if (currentLoopStatus !== "running") return; - const taskList = get(taskLoopStore.tasks); - const nextIdx = findNextPendingIndex(taskList); + // If any tasks are still active, wait for them + if (Object.keys(activePhases).length > 0) return; - if (nextIdx === -1) { + await advanceToNextWave(); + } + + async function advanceToNextWave(): Promise { + const currentLoopStatus = get(taskLoopStore.loopStatus); + if (currentLoopStatus !== "running") return; + + // Mark any newly-blocked tasks + const taskList = get(taskLoopStore.tasks); + taskList.forEach((task, i) => { + if (task.status === "pending" && isTaskBlocked(task, taskList)) { + taskLoopStore.setTaskStatus(i, "blocked"); + } + }); + + const updatedTaskList = get(taskLoopStore.tasks); + const limit = get(taskLoopStore.concurrencyLimit); + const readyIndices = getReadyTasks(updatedTaskList, limit); + + if (readyIndices.length === 0) { taskLoopStore.setLoopStatus("stopped"); - taskLoopStore.setCurrentTaskIndex(-1); return; } - await startTask(nextIdx, taskList); + await Promise.all(readyIndices.map((i) => startTask(i, updatedTaskList))); } async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise { @@ -113,19 +142,17 @@ ...new Set([...get(claudeStore.grantedTools), ...(config.auto_granted_tools ?? [])]), ]; - // sourceFile is already normalised to a Unix path at import time. const filePath = get(taskLoopStore.sourceFile); const workingDir = filePath.split("/").slice(0, -1).join("/"); - // Create a new conversation for this task const conversationId = claudeStore.createConversation(task.title); void claudeStore.switchConversation(conversationId); taskLoopStore.setTaskConversationId(taskIdx, conversationId); taskLoopStore.setTaskStatus(taskIdx, "running"); - taskLoopStore.setCurrentTaskIndex(taskIdx); - loopPhase = "waiting_for_connection"; - taskEverStarted = false; + + activePhases = { ...activePhases, [taskIdx]: "waiting_for_connection" }; + taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false }; try { await invoke("start_claude", { @@ -144,7 +171,9 @@ }); } catch (error) { console.error("Failed to start Claude for task:", error); - loopPhase = null; + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); void onTaskCompleted(taskIdx, "failed"); } } @@ -170,58 +199,54 @@ async function handleStart(): Promise { const taskList = get(taskLoopStore.tasks); - const nextIdx = findNextPendingIndex(taskList); - if (nextIdx === -1) return; + const limit = get(taskLoopStore.concurrencyLimit); + const readyIndices = getReadyTasks(taskList, limit); + if (readyIndices.length === 0) return; taskLoopStore.setLoopStatus("running"); - await startTask(nextIdx, taskList); + await Promise.all(readyIndices.map((i) => startTask(i, taskList))); } function handlePause(): void { taskLoopStore.setLoopStatus("paused"); } - function handleResume(): void { + async function handleResume(): Promise { taskLoopStore.setLoopStatus("running"); - // If we're between tasks (no active phase), advance immediately - if (!loopPhase) { - const taskList = get(taskLoopStore.tasks); - const nextIdx = findNextPendingIndex(taskList); - if (nextIdx !== -1) { - void startTask(nextIdx, taskList); - } else { - taskLoopStore.setLoopStatus("stopped"); - } + if (Object.keys(activePhases).length === 0) { + await advanceToNextWave(); } } async function handleStop(): Promise { - const taskIdx = get(taskLoopStore.currentTaskIndex); - const taskList = get(taskLoopStore.tasks); - const currentTask = taskIdx >= 0 ? taskList[taskIdx] : null; - taskLoopStore.setLoopStatus("stopped"); - loopPhase = null; - // Stop Claude process for the current task if running - if (currentTask?.conversationId) { - try { - await invoke("stop_claude", { conversationId: currentTask.conversationId }); - } catch (error) { - console.error("Failed to stop Claude for current task:", error); + // Stop all active Claude processes + const taskList = get(taskLoopStore.tasks); + const stopPromises = Object.keys(activePhases).map(async (idxStr) => { + const taskIdx = Number(idxStr); + const task = taskList[taskIdx]; + if (task?.conversationId) { + try { + await invoke("stop_claude", { conversationId: task.conversationId }); + } catch (error) { + console.error("Failed to stop Claude for task:", error); + } + if (task.status === "running") { + taskLoopStore.setTaskStatus(taskIdx, "failed"); + } } - if (currentTask.status === "running") { - taskLoopStore.setTaskStatus(taskIdx, "failed"); - } - } + }); + await Promise.all(stopPromises); - taskLoopStore.setCurrentTaskIndex(-1); + activePhases = {}; + taskEverStartedMap = {}; } function handleReset(): void { taskLoopStore.reset(); - loopPhase = null; - taskEverStarted = false; + activePhases = {}; + taskEverStartedMap = {}; errorMessage = null; } @@ -235,6 +260,8 @@ return "text-green-400"; case "failed": return "text-red-400"; + case "blocked": + return "text-[var(--text-tertiary)] opacity-50"; } } @@ -248,6 +275,8 @@ return "✓"; case "failed": return "✗"; + case "blocked": + return "⊘"; } } @@ -261,6 +290,8 @@ return "bg-green-500/20 text-green-400 border-green-500/30"; } } + + const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending"));
- Running {completedCount + - failedCount + - ($loopStatus === "running" ? 1 : 0)}/{totalCount} + {runningCount} running · {completedCount}/{totalCount} done {:else if $loopStatus === "paused"} {completedCount}/{totalCount} completed{failedCount > 0 ? `, ${failedCount} failed` - : ""} + : ""}{blockedCount > 0 ? `, ${blockedCount} blocked` : ""} {/if}
@@ -373,48 +402,81 @@ {$sourceFile} - -
- {#each $tasks as task, index (task.id)} -
- - - {statusIcon(task.status)} - - -
-
- - {task.title} - + +
+ {#each waves as waveIndices, waveIdx (waveIdx)} +
+ {#if multiWave} +
- {task.priority} + Wave {waveIdx + 1} - {#if $currentTaskIndex === index && $loopStatus === "running"} - ● running + {#if waveIndices.length > 1} + + ({waveIndices.length} parallel) + {/if} +
-

- {task.prompt} -

+ {/if} +
+ {#each waveIndices as taskIdx (taskIdx)} + {@const task = $tasks[taskIdx]} + {#if task} +
+ + + {statusIcon(task.status)} + + +
+
+ + {task.title} + + + {task.priority} + + {#if task.status === "running"} + ● running + {:else if task.status === "blocked"} + blocked + {/if} +
+

+ {task.prompt} +

+
+ + #{taskIdx + 1} +
+ {/if} + {/each}
- - #{index + 1}
{/each}
@@ -447,13 +509,37 @@ Reset {/if} + + + {#if totalCount > 0} +
+ Parallel: + + {$concurrencyLimit} + +
+ {/if}
{#if totalCount === 0} {:else if $loopStatus === "idle" || $loopStatus === "stopped"} - {#if findNextPendingIndex($tasks) !== -1} + {#if hasPendingTasks}