Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions src/components/ProjectsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import {
Title,
UnstyledButton,
} from "@mantine/core";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { IconChevronDown, IconChevronRight, IconRefresh } from "@tabler/icons-react";
import { fetchJson } from "@/lib/api";
import { AGENT_PROVIDER_OPTIONS, type AgentProvider } from "@/lib/agentProviders";
import { PRIORITY_LABELS, rowBorder, sectionHeader } from "@/lib/dashboard";
import { EmptyRow, StatusBadge } from "@/components/primitives";
import type { ContinueResult, LinearIssue, Project, Repo } from "@/types";
import type { ContinueResult, LinearIssue, Project, Repo, TodoExecutionResult } from "@/types";

export function ProjectsCard({ projects, repos }: { projects: Project[]; repos: Repo[] }) {
const queryClient = useQueryClient();
Expand Down Expand Up @@ -104,6 +104,19 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) {
},
});

const executeTodosMutation = useMutation({
mutationFn: () =>
fetchJson<TodoExecutionResult>(`/api/projects/${project.id}/execute-todos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agentProvider }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["overview"] });
void queryClient.invalidateQueries({ queryKey: ["issues", project.id] });
},
});

const panelId = `project-panel-${project.id}`;

return (
Expand Down Expand Up @@ -201,6 +214,15 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) {
Continue ▶
</Button>
)}
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={executeTodosMutation.isPending}
onClick={() => executeTodosMutation.mutate()}
>
Refresh To Dos
</Button>
</Group>

{continueMutation.error ? (
Expand All @@ -209,6 +231,18 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) {
</Text>
) : null}

{executeTodosMutation.error ? (
<Text size="xs" c="red" pb="xs" role="alert">
{(executeTodosMutation.error as Error).message}
</Text>
) : null}

{executeTodosMutation.data ? (
<Text size="xs" c="dimmed" pb="xs">
{todoExecutionSummary(executeTodosMutation.data)}
</Text>
) : null}

{continueMutation.data ? (
<Stack gap={4} pb="xs">
{continueMutation.data.summary ? (
Expand Down Expand Up @@ -298,3 +332,23 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) {
</Box>
);
}

function todoExecutionSummary(result: TodoExecutionResult): string {
const runs = result.runs ?? [];
const queued = runs.filter((run) => run.status === "queued").length;
const waiting = runs.filter((run) => run.status === "waiting_approval").length;
const other = runs.length - queued - waiting;
const parts = [
queued ? `${queued} queued` : null,
waiting ? `${waiting} waiting approval` : null,
other ? `${other} created` : null,
result.skippedActive ? `${result.skippedActive} already active` : null,
result.skippedCap ? `${result.skippedCap} deferred by cap` : null,
result.failedRuns?.length ? `${result.failedRuns.length} failed` : null,
].filter(Boolean);

if (parts.length === 0) {
return `No To Dos queued (${result.eligibleIssues} eligible of ${result.totalOpenIssues} open).`;
}
return `${parts.join(" · ")} (${result.eligibleIssues} eligible of ${result.totalOpenIssues} open).`;
}
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,13 @@ export type ContinueResult = {
queuedRuns: Array<{ id: string; issue: string }>;
skipped: number;
};

export type TodoExecutionResult = {
totalOpenIssues: number;
eligibleIssues: number;
runs: Array<{ id: string; issue: string; status: string }>;
skippedActive: number;
skippedState: number;
skippedCap: number;
failedRuns: Array<{ issue: string; error: string }>;
};
65 changes: 64 additions & 1 deletion tests/continue.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* AGPL-3.0-or-later */
import { describe, expect, it } from "vitest";
import { selectExecuteTargets, type CreatedWithMeta } from "../worker/src/platform/continue";
import {
selectExecuteTargets,
selectTodoExecutionTargets,
type CreatedWithMeta,
} from "../worker/src/platform/continue";

function made(planIndex: number, priority: number): CreatedWithMeta {
return {
Expand Down Expand Up @@ -36,3 +40,62 @@ describe("selectExecuteTargets", () => {
expect(picked.map((p) => p.planIndex)).toEqual([0, 2]);
});
});

function todo(
id: string,
stateType: string,
priority = 0,
updatedAt = "2026-06-01T12:00:00Z",
) {
return {
id,
identifier: id.toUpperCase(),
title: `Issue ${id}`,
description: null,
stateType,
priority,
updatedAt,
teamId: "team_1",
};
}

describe("selectTodoExecutionTargets", () => {
it("selects backlog and unstarted issues", () => {
const picked = selectTodoExecutionTargets(
[todo("a", "backlog"), todo("b", "unstarted"), todo("c", "started")],
new Set(),
10,
);

expect(picked.targets.map((issue) => issue.id)).toEqual(["a", "b"]);
expect(picked.eligibleIssues).toBe(2);
expect(picked.skippedState).toBe(1);
});

it("skips issues that already have an active run", () => {
const picked = selectTodoExecutionTargets(
[todo("a", "unstarted"), todo("b", "unstarted")],
new Set(["a"]),
10,
);

expect(picked.targets.map((issue) => issue.id)).toEqual(["b"]);
expect(picked.skippedActive).toBe(1);
});

it("prioritizes urgent issues and respects the execution cap", () => {
const picked = selectTodoExecutionTargets(
[
todo("low", "unstarted", 4),
todo("none", "unstarted", 0),
todo("urgent", "unstarted", 1),
todo("high", "backlog", 2),
],
new Set(),
2,
);

expect(picked.targets.map((issue) => issue.id)).toEqual(["urgent", "high"]);
expect(picked.skippedCap).toBe(2);
});
});
15 changes: 14 additions & 1 deletion worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import {
} from "./platform/orchestration";
import { writeBackToLinear } from "./platform/linear";
import { mergeWhenGreen, deleteRepository, renameRepository } from "./platform/github";
import { continueProject } from "./platform/continue";
import { continueProject, executeProjectTodos } from "./platform/continue";
import { runGoal, listGoals } from "./platform/goal";
import { getCore, getCoreResponse, saveCore, buildCorePreamble } from "./platform/core";
import { listWorkflows, saveWorkflow, deleteWorkflow } from "./platform/workflows";
Expand Down Expand Up @@ -537,6 +537,19 @@ app.post("/api/projects/:id/continue", async (c) => {
return continueProject(c.env, user, c.req.param("id"), payload);
});

// Manual To Do execution: fetch open Linear issues live and dispatch the backlog /
// unstarted ones immediately, without relying on the scheduled watcher table.
app.post("/api/projects/:id/execute-todos", async (c) => {
const user = await requireUser(c.req.raw, c.env);
if (user instanceof Response) return user;
const raw = await c.req.json().catch(() => ({}));
const payload =
raw && typeof raw === "object"
? (raw as { agentProvider?: "claude-code" | "codex" | "cloudflare" })
: {};
return executeProjectTodos(c.env, user, c.req.param("id"), payload);
});

// Auto-map all Linear projects to GitHub repos by name (confident matches become
// active; weaker ones are suggested; manual mappings are preserved).
app.post("/api/projects/auto-map", async (c) => {
Expand Down
144 changes: 143 additions & 1 deletion worker/src/platform/continue.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* AGPL-3.0-or-later */
import type { CurrentUser, Env } from "../env";
import { first } from "./data";
import { all, first } from "./data";
import { autoMapProjects, getLinearProjectIssues, getValidLinearToken } from "./integrations";
import type { LinearIssue } from "./integrations";
import { planNextSteps, type ProjectContext } from "./planner";
import { createLinearIssue, resolveProjectTeam, type CreatedLinearIssue } from "./linear";
import { createAutonomousRun, type AgentProvider } from "./orchestration";

const DEFAULT_EXECUTE_CAP = 3;
const MAX_EXECUTE_CAP = 10; // hard ceiling so a misconfigured env var can't start a flood of runs
const TODO_STATE_TYPES = new Set(["backlog", "unstarted"]);

export type CreatedWithMeta = {
planIndex: number;
Expand All @@ -23,6 +25,27 @@ export type ContinueResult = {
skipped: number;
};

export type TodoExecutionRun = {
id: string;
issue: string;
status: string;
};

export type TodoExecutionResult = {
totalOpenIssues: number;
eligibleIssues: number;
runs: TodoExecutionRun[];
skippedActive: number;
skippedState: number;
skippedCap: number;
failedRuns: Array<{ issue: string; error: string }>;
};

type TodoCandidate = Pick<
LinearIssue,
"id" | "identifier" | "title" | "description" | "stateType" | "priority" | "updatedAt" | "teamId"
>;

// Choose which created issues to execute: prefer the planner's `execute` indexes
// (those that actually got created), else fall back to highest priority. Capped.
export function selectExecuteTargets(
Expand All @@ -38,6 +61,116 @@ export function selectExecuteTargets(
return pool.slice(0, Math.max(0, cap));
}

export function selectTodoExecutionTargets(
issues: TodoCandidate[],
activeIssueIds: Set<string>,
cap: number,
): {
targets: TodoCandidate[];
eligibleIssues: number;
skippedActive: number;
skippedState: number;
skippedCap: number;
} {
const todoIssues = issues
.filter((issue) => TODO_STATE_TYPES.has(issue.stateType))
.sort((a, b) => {
const byPriority = priorityRank(a.priority) - priorityRank(b.priority);
if (byPriority !== 0) return byPriority;
const byUpdated = updatedAtMs(a.updatedAt) - updatedAtMs(b.updatedAt);
if (byUpdated !== 0) return byUpdated;
return a.identifier.localeCompare(b.identifier);
});
const available = todoIssues.filter((issue) => !activeIssueIds.has(issue.id));
const targetCap = Math.max(0, cap);
const targets = available.slice(0, targetCap);

return {
targets,
eligibleIssues: todoIssues.length,
skippedActive: todoIssues.length - available.length,
skippedState: issues.length - todoIssues.length,
skippedCap: Math.max(0, available.length - targets.length),
};
}

export async function executeProjectTodos(
env: Env,
user: CurrentUser,
projectId: string,
options: { agentProvider?: AgentProvider } = {},
): Promise<Response> {
const project = await first<{ id: string }>(
env,
"SELECT id FROM linear_projects WHERE id = ?",
[projectId],
);
if (!project) {
return Response.json({ error: "Project not found" }, { status: 404 });
}

let repo = await activeRepo(env, projectId);
if (!repo) {
await autoMapProjects(env, user.id).catch(() => undefined);
repo = await activeRepo(env, projectId);
}
if (!repo) {
return Response.json(
{ error: "No GitHub repo mapped to this project. Map a repo first.", needsRepo: true },
{ status: 409 },
);
}

const { issues, reason } = await getLinearProjectIssues(env, user.id, projectId);
if (reason) {
return Response.json({ error: reason, needsReconnect: reason.includes("Linear") }, { status: 400 });
}

const activeRows = await all<{ linear_issue_id: string }>(
env,
`SELECT linear_issue_id
FROM runs
WHERE project_id = ?
AND linear_issue_id IS NOT NULL
AND status IN ('queued', 'running', 'waiting_approval')`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include starting runs when skipping active issues

When a run has already been admitted by the queue, markRunStarting updates it to starting before it becomes running (worker/src/platform/orchestration.ts:317). This duplicate-suppression query omits that status, so clicking Refresh To Dos during container boot can select the same Linear issue again and create a second run for it; treat starting as active here as well.

Useful? React with 👍 / 👎.

[projectId],
);
const activeIssueIds = new Set(activeRows.map((row) => row.linear_issue_id));
const selection = selectTodoExecutionTargets(issues, activeIssueIds, executeCap(env));

const runs: TodoExecutionRun[] = [];
const failedRuns: TodoExecutionResult["failedRuns"] = [];
for (const issue of selection.targets) {
try {
const run = await createAutonomousRun(env, user, {
objective: `${issue.title}\n\n${issue.description ?? ""}`.trim(),
linearProjectId: projectId,
linearIssueId: issue.id,
linearTeamId: issue.teamId ?? undefined,
agentProvider: options.agentProvider ?? "claude-code",
autonomyMode: "auto_eligible",
source: "manual-todo-refresh",
});
runs.push({ id: run.id, issue: issue.identifier, status: run.status });
} catch (err) {
failedRuns.push({
issue: issue.identifier,
error: err instanceof Error ? err.message : String(err),
});
}
}

return Response.json({
totalOpenIssues: issues.length,
eligibleIssues: selection.eligibleIssues,
runs,
skippedActive: selection.skippedActive,
skippedState: selection.skippedState,
skippedCap: selection.skippedCap,
failedRuns,
} satisfies TodoExecutionResult);
}

export async function continueProject(
env: Env,
user: CurrentUser,
Expand Down Expand Up @@ -162,3 +295,12 @@ export function executeCap(env: Env): number {
const cap = Number.isInteger(n) && n > 0 ? n : DEFAULT_EXECUTE_CAP;
return Math.min(cap, MAX_EXECUTE_CAP);
}

function priorityRank(priority: number): number {
return priority > 0 ? priority : Number.MAX_SAFE_INTEGER;
}

function updatedAtMs(updatedAt: string | null): number {
const ms = updatedAt ? Date.parse(updatedAt) : Number.NaN;
return Number.isFinite(ms) ? ms : Number.MAX_SAFE_INTEGER;
}