feat: add wave-based parallel task execution to Task Loop

This commit is contained in:
2026-03-07 01:02:14 -08:00
committed by Naomi Carrigan
parent 7a07958b65
commit f60e45e486
4 changed files with 439 additions and 117 deletions
+200 -114
View File
@@ -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<LoopPhase | null>(null);
let taskEverStarted = $state(false);
let activePhases = $state<Record<number, LoopPhase>>({});
let taskEverStartedMap = $state<Record<number, boolean>>({});
let isLoading = $state(false);
let errorMessage = $state<string | null>(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<void> {
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<void> {
@@ -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<void> {
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<void> {
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<void> {
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"));
</script>
<div
@@ -288,9 +319,7 @@
<span
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 animate-pulse"
>
Running {completedCount +
failedCount +
($loopStatus === "running" ? 1 : 0)}/{totalCount}
{runningCount} running · {completedCount}/{totalCount} done
</span>
{:else if $loopStatus === "paused"}
<span
@@ -304,7 +333,7 @@
>
{completedCount}/{totalCount} completed{failedCount > 0
? `, ${failedCount} failed`
: ""}
: ""}{blockedCount > 0 ? `, ${blockedCount} blocked` : ""}
</span>
{/if}
</div>
@@ -373,48 +402,81 @@
{$sourceFile}
</div>
<!-- Task list -->
<div class="flex flex-col gap-2">
{#each $tasks as task, index (task.id)}
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {$currentTaskIndex ===
index && $loopStatus === 'running'
? 'border-blue-500/40 bg-blue-500/5'
: ''}"
>
<!-- Status icon -->
<span
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
task.status
)}"
>
{statusIcon(task.status)}
</span>
<!-- Task info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
{task.title}
</span>
<!-- Wave-grouped task list -->
<div class="flex flex-col gap-4">
{#each waves as waveIndices, waveIdx (waveIdx)}
<div>
{#if multiWave}
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs px-1.5 py-0.5 rounded-full border shrink-0 {priorityColour(
task.priority
)}"
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide"
>
{task.priority}
Wave {waveIdx + 1}
</span>
{#if $currentTaskIndex === index && $loopStatus === "running"}
<span class="text-xs text-blue-400 animate-pulse shrink-0">● running</span>
{#if waveIndices.length > 1}
<span class="text-xs text-[var(--text-tertiary)]">
({waveIndices.length} parallel)
</span>
{/if}
<div class="flex-1 border-t border-[var(--border-color)]"></div>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-0.5 line-clamp-2 font-mono">
{task.prompt}
</p>
{/if}
<div class="flex flex-col gap-2">
{#each waveIndices as taskIdx (taskIdx)}
{@const task = $tasks[taskIdx]}
{#if task}
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {task.status ===
'running'
? 'border-blue-500/40 bg-blue-500/5'
: task.status === 'blocked'
? 'opacity-50'
: ''}"
>
<!-- Status icon -->
<span
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
task.status
)} {task.status === 'running' ? 'animate-spin' : ''}"
>
{statusIcon(task.status)}
</span>
<!-- Task info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
{task.title}
</span>
<span
class="text-xs px-1.5 py-0.5 rounded-full border shrink-0 {priorityColour(
task.priority
)}"
>
{task.priority}
</span>
{#if task.status === "running"}
<span class="text-xs text-blue-400 animate-pulse shrink-0"
>● running</span
>
{:else if task.status === "blocked"}
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</span
>
{/if}
</div>
<p
class="text-xs text-[var(--text-tertiary)] mt-0.5 line-clamp-2 font-mono"
>
{task.prompt}
</p>
</div>
<!-- Task number -->
<span class="text-xs text-[var(--text-tertiary)] font-mono shrink-0"
>#{taskIdx + 1}</span
>
</div>
{/if}
{/each}
</div>
<!-- Task number -->
<span class="text-xs text-[var(--text-tertiary)] font-mono shrink-0"
>#{index + 1}</span
>
</div>
{/each}
</div>
@@ -447,13 +509,37 @@
Reset
</button>
{/if}
<!-- Concurrency limit control -->
{#if totalCount > 0}
<div class="flex items-center gap-1 ml-2">
<span class="text-xs text-[var(--text-tertiary)]">Parallel:</span>
<button
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit - 1)}
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
aria-label="Decrease concurrency limit"
>
</button>
<span class="text-xs font-mono text-[var(--text-primary)] w-4 text-center"
>{$concurrencyLimit}</span
>
<button
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit + 1)}
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
aria-label="Increase concurrency limit"
>
+
</button>
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if totalCount === 0}
<!-- no actions until tasks are loaded -->
{:else if $loopStatus === "idle" || $loopStatus === "stopped"}
{#if findNextPendingIndex($tasks) !== -1}
{#if hasPendingTasks}
<button
onclick={handleStart}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
+4 -1
View File
@@ -6,6 +6,7 @@ export interface PrdTask {
title: string;
prompt: string;
priority: "high" | "medium" | "low";
dependsOn?: string[];
}
export interface PrdFile {
@@ -31,7 +32,8 @@ Write the file to \`${workingDirectory}/hikari-tasks.json\` containing valid JSO
"id": "task-1",
"title": "<short descriptive title>",
"prompt": "<detailed prompt that Claude Code can execute to complete this task>",
"priority": "<high|medium|low>"
"priority": "<high|medium|low>",
"dependsOn": []
}
]
}
@@ -43,6 +45,7 @@ Guidelines:
- Prompts should be specific and actionable, not vague
- Order tasks logically (dependencies first)
- Assign priority: high for critical path, medium for features, low for polish/cleanup
- Fill in \`dependsOn\` with IDs of tasks that must complete before this one (use \`[]\` if none)
- Write only the JSON file — no explanations needed`;
}
+146 -1
View File
@@ -4,15 +4,23 @@ import {
countByStatus,
buildTaskPrompt,
normalizeToUnixPath,
isTaskBlocked,
getReadyTasks,
computeWaves,
type TaskLoopTask,
} from "./taskLoop";
const makeTask = (id: string, status: TaskLoopTask["status"] = "pending"): TaskLoopTask => ({
const makeTask = (
id: string,
status: TaskLoopTask["status"] = "pending",
dependsOn?: string[]
): TaskLoopTask => ({
id,
title: `Task ${id}`,
prompt: `Do the thing for ${id}`,
priority: "medium",
status,
dependsOn,
});
describe("findNextPendingIndex", () => {
@@ -125,3 +133,140 @@ describe("normalizeToUnixPath", () => {
expect(normalizeToUnixPath("")).toBe("");
});
});
describe("isTaskBlocked", () => {
it("returns false when dependsOn is empty", () => {
const task = makeTask("a", "pending", []);
expect(isTaskBlocked(task, [task])).toBe(false);
});
it("returns false when dependsOn is undefined", () => {
const task = makeTask("a", "pending");
expect(isTaskBlocked(task, [task])).toBe(false);
});
it("returns false when all dependencies are completed", () => {
const dep = makeTask("dep", "completed");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(false);
});
it("returns true when a dependency has failed", () => {
const dep = makeTask("dep", "failed");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(true);
});
it("returns true when a dependency is blocked", () => {
const dep = makeTask("dep", "blocked");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(true);
});
it("returns false when a dependency is still pending (not yet failed)", () => {
const dep = makeTask("dep", "pending");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(false);
});
it("returns false when dependency ID does not exist in task list", () => {
const task = makeTask("a", "pending", ["nonexistent"]);
expect(isTaskBlocked(task, [task])).toBe(false);
});
});
describe("getReadyTasks", () => {
it("returns empty array when task list is empty", () => {
expect(getReadyTasks([], 3)).toEqual([]);
});
it("returns all pending tasks with no deps when under limit", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
expect(getReadyTasks(tasks, 5)).toEqual([0, 1, 2]);
});
it("respects the concurrency limit", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
expect(getReadyTasks(tasks, 2)).toEqual([0, 1]);
});
it("skips tasks whose dependencies are not completed", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending", ["a"])];
// b depends on a which is pending, not completed — so only a is ready
expect(getReadyTasks(tasks, 5)).toEqual([0]);
});
it("includes task when all its dependencies are completed", () => {
const tasks = [makeTask("a", "completed"), makeTask("b", "pending", ["a"])];
expect(getReadyTasks(tasks, 5)).toEqual([1]);
});
it("skips running, completed, failed, and blocked tasks", () => {
const tasks = [
makeTask("a", "running"),
makeTask("b", "completed"),
makeTask("c", "failed"),
makeTask("d", "blocked"),
makeTask("e", "pending"),
];
expect(getReadyTasks(tasks, 5)).toEqual([4]);
});
it("returns empty when limit is 0", () => {
const tasks = [makeTask("a", "pending")];
expect(getReadyTasks(tasks, 0)).toEqual([]);
});
});
describe("computeWaves", () => {
it("returns empty array for empty task list", () => {
expect(computeWaves([])).toEqual([]);
});
it("puts all independent tasks in a single wave", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
expect(computeWaves(tasks)).toEqual([[0, 1, 2]]);
});
it("creates one wave per task for a linear chain", () => {
const tasks = [
makeTask("a", "pending"),
makeTask("b", "pending", ["a"]),
makeTask("c", "pending", ["b"]),
];
expect(computeWaves(tasks)).toEqual([[0], [1], [2]]);
});
it("handles diamond dependency: A → B,C → D", () => {
// A has no deps, B and C depend on A, D depends on B and C
const tasks = [
makeTask("a", "pending"),
makeTask("b", "pending", ["a"]),
makeTask("c", "pending", ["a"]),
makeTask("d", "pending", ["b", "c"]),
];
const waves = computeWaves(tasks);
expect(waves).toHaveLength(3);
expect(waves[0]).toEqual([0]);
expect(waves[1]).toEqual([1, 2]);
expect(waves[2]).toEqual([3]);
});
it("groups circular dependencies into a final overflow wave", () => {
// a→b, b→a — circular; c has no deps so goes in wave 0
const tasks = [
makeTask("a", "pending", ["b"]),
makeTask("b", "pending", ["a"]),
makeTask("c", "pending"),
];
const waves = computeWaves(tasks);
// c goes in wave 0, then a+b get dumped in overflow
expect(waves[0]).toEqual([2]);
expect(waves[1]).toEqual([0, 1]);
});
it("ignores unknown dependency IDs (treats them as satisfied)", () => {
const tasks = [makeTask("a", "pending", ["nonexistent"])];
expect(computeWaves(tasks)).toEqual([[0]]);
});
});
+89 -1
View File
@@ -2,7 +2,7 @@ import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import type { PrdTask, PrdFile } from "./prd";
export type TaskStatus = "pending" | "running" | "completed" | "failed";
export type TaskStatus = "pending" | "running" | "completed" | "failed" | "blocked";
export type LoopStatus = "idle" | "running" | "paused" | "stopped";
export interface TaskLoopTask extends PrdTask {
@@ -20,6 +20,87 @@ export function countByStatus(tasks: TaskLoopTask[], status: TaskStatus): number
return tasks.filter((t) => t.status === status).length;
}
/**
* Returns true if a task is blocked — i.e. any of its `dependsOn` IDs refer to a
* task that has failed or is already blocked.
*/
export function isTaskBlocked(task: TaskLoopTask, allTasks: TaskLoopTask[]): boolean {
if (!task.dependsOn || task.dependsOn.length === 0) return false;
return task.dependsOn.some((depId) => {
const dep = allTasks.find((t) => t.id === depId);
return dep !== undefined && (dep.status === "failed" || dep.status === "blocked");
});
}
/**
* Returns indices of tasks that are ready to start: status is `pending` and all
* `dependsOn` tasks are `completed`. Respects `limit` (concurrency cap).
*/
export function getReadyTasks(tasks: TaskLoopTask[], limit: number): number[] {
const ready: number[] = [];
for (let i = 0; i < tasks.length; i++) {
if (ready.length >= limit) break;
const task = tasks[i];
if (task.status !== "pending") continue;
const depsAllDone =
!task.dependsOn ||
task.dependsOn.length === 0 ||
task.dependsOn.every((depId) => {
const dep = tasks.find((t) => t.id === depId);
return dep === undefined || dep.status === "completed";
});
if (depsAllDone) {
ready.push(i);
}
}
return ready;
}
/**
* Groups task indices into waves for UI display. Tasks with no pending dependencies
* form wave 0; tasks whose deps are all in earlier waves form the next wave, etc.
* Circular dependencies are collected into a final "overflow" wave.
*/
export function computeWaves(tasks: TaskLoopTask[]): number[][] {
const waves: number[][] = [];
const assigned = new Set<number>();
// Build an id→index map
const idToIndex = new Map<string, number>();
tasks.forEach((t, i) => idToIndex.set(t.id, i));
let remaining = tasks.map((_, i) => i).filter((i) => !assigned.has(i));
while (remaining.length > 0) {
const wave: number[] = [];
for (const i of remaining) {
const task = tasks[i];
const depsAllAssigned =
!task.dependsOn ||
task.dependsOn.length === 0 ||
task.dependsOn.every((depId) => {
const depIdx = idToIndex.get(depId);
return depIdx === undefined || assigned.has(depIdx);
});
if (depsAllAssigned) {
wave.push(i);
}
}
if (wave.length === 0) {
// Circular dependency — dump all remaining into a single wave
waves.push(remaining);
break;
}
wave.forEach((i) => assigned.add(i));
waves.push(wave);
remaining = remaining.filter((i) => !assigned.has(i));
}
return waves;
}
/**
* Normalises a file-picker path to a Unix path.
*
@@ -51,6 +132,7 @@ function createTaskLoopStore() {
const loopStatus = writable<LoopStatus>("idle");
const currentTaskIndex = writable<number>(-1);
const sourceFile = writable<string>("");
const concurrencyLimit = writable<number>(3);
async function loadFile(path: string): Promise<void> {
const content = await invoke<string>("read_file_content", { path });
@@ -90,6 +172,10 @@ function createTaskLoopStore() {
currentTaskIndex.set(index);
}
function setConcurrencyLimit(limit: number): void {
concurrencyLimit.set(Math.max(1, limit));
}
function reset(): void {
tasks.set([]);
loopStatus.set("idle");
@@ -102,11 +188,13 @@ function createTaskLoopStore() {
loopStatus: { subscribe: loopStatus.subscribe },
currentTaskIndex: { subscribe: currentTaskIndex.subscribe },
sourceFile: { subscribe: sourceFile.subscribe },
concurrencyLimit: { subscribe: concurrencyLimit.subscribe },
loadFile,
setTaskStatus,
setTaskConversationId,
setLoopStatus,
setCurrentTaskIndex,
setConcurrencyLimit,
reset,
};
}