Q43: manual runs refuse done tasks and tasks with a session in flight

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 07:47:17 -04:00
1 parent 9be5ad06c1
commit 5698e61e5a
8 files changed
+86 -12

No files matched your search

@@ -79,6 +79,12 @@ export class TaskExecutionService {
async runTask(input: RunTaskInput): Promise<string> {
const task = requireTask(this.db, input.orgId, input.taskId);
if (task.status === TASK_STATUS.DONE) throw new Error(`"${task.title}" is done. Reopen it before running it again.`);
const inFlight = this.db.prepare("select state from task_runs where task_id = ? and state in ('queued', 'running') limit 1")
.get(task.id) as { state: string } | undefined;
if (inFlight) {
throw new Error(inFlight.state === "queued" ? `"${task.title}" already has a queued session; it will run shortly.` : `"${task.title}" already has a running session.`);
}
assertExecutionApproval(this.db, input.orgId, task);
const agent = requireAssignedAgent(this.db, input.orgId, task);
const provider = requireAgentProvider(this.db, input.orgId, agent);
+2
View File
@@ -29,6 +29,8 @@ export const zh: Record<string, string> = {
"Integrations": "集成密钥",
"Document": "文档",
"Writing": "写作方式",
"Done tasks do not run": "已完成的任务不能再运行",
"A session is already queued or running": "已有会话在排队或运行中",
"Conversational (house style)": "日常沟通(公司文风)",
"Technical spec": "技术规格",
"Structured output": "结构化输出",
+6 -5
View File
@@ -20,7 +20,7 @@ import { TaskDetailDialog } from "./work/TaskDetailDialog";
import { ProjectOverview } from "./work/ProjectOverview";
import { ListView } from "./work/ListView";
import { TimelineView } from "./work/TimelineView";
import { canRun, latestTaskRun, nextStatus, prettyRunState, providerFor, runTone } from "./work/task-helpers";
import { latestTaskRun, nextStatus, prettyRunState, providerFor, runBlocker, runTone } from "./work/task-helpers";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { t } from "../i18n/i18n";
@@ -125,6 +125,7 @@ export function WorkPage({ snapshot, actions }: PageProps) {
agents={agents}
projects={projects}
providers={providers}
taskRuns={taskRuns}
onRun={runTask}
onOpen={(task) => setDetailTaskId(task.id)}
/>
@@ -256,7 +257,7 @@ function BoardView({
task={task}
assignee={agents.find((agent) => agent.id === task.assigneeAgentId) ?? null}
project={projects.find((project) => project.id === task.projectId) ?? null}
provider={providerFor(agents.find((agent) => agent.id === task.assigneeAgentId) ?? null, providers)}
blocker={runBlocker(task, providerFor(agents.find((agent) => agent.id === task.assigneeAgentId) ?? null, providers), taskRuns)}
latestRun={latestTaskRun(task.id, taskRuns)}
onAdvance={() => onAdvance(task)}
onRun={() => onRun(task)}
@@ -275,7 +276,7 @@ function TaskCard({
task,
assignee,
project,
provider,
blocker,
latestRun,
onAdvance,
onRun,
@@ -284,7 +285,7 @@ function TaskCard({
task: TaskRecord;
assignee: AgentRecord | null;
project: ProjectRecord | null;
provider: ProviderRecord | null;
blocker: string | null;
latestRun: TaskRunRecord | null;
onAdvance: () => void;
onRun: () => void;
@@ -337,7 +338,7 @@ function TaskCard({
)}
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center", flexShrink: 0 }} onClick={(event) => event.stopPropagation()}>
{assignee && <Avatar name={assignee.name} size="sm" bot />}
{assignee && <IconButton icon="send" label={canRun(provider) ? t("Run {title}", { title: task.title }) : t("Connect a provider to run")} onClick={onRun} disabled={!canRun(provider)} />}
{assignee && <IconButton icon="send" label={blocker ?? t("Run {title}", { title: task.title })} onClick={onRun} disabled={blocker !== null} />}
{nextStatus(task.status) && <IconButton icon="check" label={t("Advance")} onClick={onAdvance} />}
</div>
</div>
+5 -3
View File
@@ -1,6 +1,6 @@
import { Avatar, IconButton, Pill } from "../../shared/ui";
import type { AgentRecord, ProjectRecord, ProviderRecord, TaskRecord } from "../../../../shared/domain";
import { canRun, prettyStatus, providerFor } from "./task-helpers";
import type { AgentRecord, ProjectRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
import { prettyStatus, providerFor, runBlocker } from "./task-helpers";
import { t } from "../../i18n/i18n";
const GRID = "minmax(0, 3fr) 110px 96px minmax(0, 1.2fr) 88px";
@@ -10,6 +10,7 @@ export function ListView({
agents,
projects,
providers,
taskRuns,
onRun,
onOpen,
}: {
@@ -17,6 +18,7 @@ export function ListView({
agents: AgentRecord[];
projects: ProjectRecord[];
providers: ProviderRecord[];
taskRuns: TaskRunRecord[];
onRun: (task: TaskRecord) => void;
onOpen: (task: TaskRecord) => void;
}) {
@@ -62,7 +64,7 @@ export function ListView({
)}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }} onClick={(event) => event.stopPropagation()}>
{assignee && <IconButton icon="send" label={t("Run {title}", { title: task.title })} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
{assignee && <IconButton icon="send" label={t("Run {title}", { title: task.title })} onClick={() => onRun(task)} disabled={runBlocker(task, provider, taskRuns) !== null} />}
<IconButton icon="more" label={t("Open {title}", { title: task.title })} onClick={() => onOpen(task)} />
</div>
</div>
@@ -13,7 +13,7 @@ import type {
} from "../../../../shared/domain";
import { TASK_STATUS } from "../../../../shared/domain";
import { auditEventLabel, formatRelative } from "../types";
import { canRun, nextStatus, prettyRunState, prettyStatus, providerFor, runsForTask, runTone } from "./task-helpers";
import { nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers";
import type { SnapshotActions } from "../../app/useSnapshot";
import { t } from "../../i18n/i18n";
@@ -123,7 +123,7 @@ export function TaskDetailDialog({
<div className="modal-actions">
<Button tone="ghost" onClick={deleteTask}><Icon name="x" size={16} /> {t("Delete")}</Button>
{assignee && <Button onClick={() => onRun(task)} disabled={!canRun(provider)}><Icon name="send" size={16} /> {t("Run")}</Button>}
{assignee && <Button onClick={() => onRun(task)} disabled={runBlocker(task, provider, taskRuns) !== null}><Icon name="send" size={16} /> {t("Run")}</Button>}
{advanceTo && <Button tone="primary" onClick={() => onAdvance(task)}><Icon name="check" size={16} /> {prettyStatus(advanceTo)}</Button>}
</div>
+7 -2
View File
@@ -2,6 +2,7 @@ import {
TASK_STATUS,
type AgentRecord,
type ProviderRecord,
type TaskRecord,
type TaskRunRecord,
type TaskStatus,
} from "../../../../shared/domain";
@@ -41,8 +42,12 @@ export function providerFor(assignee: AgentRecord | null, providers: ProviderRec
return providers.find((provider) => provider.id === assignee.modelProviderId) ?? null;
}
export function canRun(provider: ProviderRecord | null): boolean {
return provider?.enabled === true && provider.authState === "configured";
/** Why a manual run is not possible right now, or null when it is. */
export function runBlocker(task: TaskRecord, provider: ProviderRecord | null, taskRuns: TaskRunRecord[]): string | null {
if (provider?.enabled !== true || provider.authState !== "configured") return t("Connect a provider to run");
if (task.status === "done") return t("Done tasks do not run");
if (taskRuns.some((run) => run.taskId === task.id && (run.state === "queued" || run.state === "running"))) return t("A session is already queued or running");
return null;
}
export function latestTaskRun(taskId: string, taskRuns: TaskRunRecord[]): TaskRunRecord | null {
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { runBlocker } from "../../src/renderer/src/pages/work/task-helpers";
import type { ProviderRecord, TaskRecord, TaskRunRecord } from "../../src/shared/domain";
const provider = { enabled: true, authState: "configured" } as ProviderRecord;
const task = (status: TaskRecord["status"]) => ({ id: "tsk_1", status }) as TaskRecord;
const run = (state: string, taskId = "tsk_1") => ({ taskId, state }) as TaskRunRecord;
describe("run blocker", () => {
test("a manual run needs a provider, an open task and no session in flight", () => {
expect(runBlocker(task("in_progress"), provider, [run("completed"), run("running", "tsk_other")])).toBeNull();
expect(runBlocker(task("in_progress"), null, [])).toBe("Connect a provider to run");
expect(runBlocker(task("done"), provider, [])).toBe("Done tasks do not run");
expect(runBlocker(task("backlog"), provider, [run("queued")])).toBe("A session is already queued or running");
expect(runBlocker(task("review"), provider, [run("running")])).toBe("A session is already queued or running");
});
});
+41
View File
@@ -341,6 +341,47 @@ describe("task execution service", () => {
connection.close();
});
test("a manual run refuses a task that is done or already has a session queued or running", async () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
let turns = 0;
const fakeToolTurn = async (): Promise<AgentTurnResult> => {
turns += 1;
return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: `resp-${turns}` }) };
};
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, fakeToolTurn);
const execution = new TaskExecutionService(connection.db, providers);
let orgId = "";
let taskId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana Lee", mission: "m", goal: "g" });
const providerId = openAiProviderId(connection, orgId);
providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" });
const agentId = organization.createAgent({
orgId, name: "Ada", role: "Execution lead", department: "Engineering",
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: null,
});
taskId = organization.createTask({
orgId, title: "Draft launch checklist", description: "d", status: TASK_STATUS.IN_PROGRESS,
assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, projectId: null, goalId: null,
workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
});
});
const firstRun = await execution.runTask({ orgId, taskId });
connection.db.prepare("update task_runs set state = 'queued' where id = ?").run(firstRun);
await expect(execution.runTask({ orgId, taskId })).rejects.toThrow('"Draft launch checklist" already has a queued session; it will run shortly.');
connection.db.prepare("update task_runs set state = 'running' where id = ?").run(firstRun);
await expect(execution.runTask({ orgId, taskId })).rejects.toThrow('"Draft launch checklist" already has a running session.');
connection.db.prepare("update task_runs set state = 'completed' where id = ?").run(firstRun);
connection.db.prepare("update tasks set status = 'done' where id = ?").run(taskId);
await expect(execution.runTask({ orgId, taskId })).rejects.toThrow('"Draft launch checklist" is done. Reopen it before running it again.');
expect(connection.db.prepare("select count(*) as n from task_runs where task_id = ?").get(taskId)).toEqual({ n: 1 });
expect(turns).toBe(1);
connection.close();
});
test("requires task assignees to have a model provider", async () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);