From 5698e61e5a8d3c92b96d79cdf382d0ecbebcbdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 07:47:17 -0400 Subject: [PATCH] Q43: manual runs refuse done tasks and tasks with a session in flight Co-Authored-By: Claude Opus 5.5 --- src/main/services/task-execution-service.ts | 6 +++ src/renderer/src/i18n/zh.ts | 2 + src/renderer/src/pages/WorkPage.tsx | 11 ++--- src/renderer/src/pages/work/ListView.tsx | 8 ++-- .../src/pages/work/TaskDetailDialog.tsx | 4 +- src/renderer/src/pages/work/task-helpers.ts | 9 +++- tests/unit/run-blocker.test.ts | 17 ++++++++ tests/unit/task-execution-service.test.ts | 41 +++++++++++++++++++ 8 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 tests/unit/run-blocker.test.ts diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index fa023ef0..9f19204d 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -79,6 +79,12 @@ export class TaskExecutionService { async runTask(input: RunTaskInput): Promise { 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); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index aed4e01e..c5dce819 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -29,6 +29,8 @@ export const zh: Record = { "Integrations": "集成密钥", "Document": "文档", "Writing": "写作方式", + "Done tasks do not run": "已完成的任务不能再运行", + "A session is already queued or running": "已有会话在排队或运行中", "Conversational (house style)": "日常沟通(公司文风)", "Technical spec": "技术规格", "Structured output": "结构化输出", diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index 2248b607..5d4c992b 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -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({ )}
event.stopPropagation()}> {assignee && } - {assignee && } + {assignee && } {nextStatus(task.status) && }
diff --git a/src/renderer/src/pages/work/ListView.tsx b/src/renderer/src/pages/work/ListView.tsx index ebeebbaa..a53e85d1 100644 --- a/src/renderer/src/pages/work/ListView.tsx +++ b/src/renderer/src/pages/work/ListView.tsx @@ -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({ )}
event.stopPropagation()}> - {assignee && onRun(task)} disabled={!canRun(provider)} />} + {assignee && onRun(task)} disabled={runBlocker(task, provider, taskRuns) !== null} />} onOpen(task)} />
diff --git a/src/renderer/src/pages/work/TaskDetailDialog.tsx b/src/renderer/src/pages/work/TaskDetailDialog.tsx index c3f9cfe9..746e68f9 100644 --- a/src/renderer/src/pages/work/TaskDetailDialog.tsx +++ b/src/renderer/src/pages/work/TaskDetailDialog.tsx @@ -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({
- {assignee && } + {assignee && } {advanceTo && }
diff --git a/src/renderer/src/pages/work/task-helpers.ts b/src/renderer/src/pages/work/task-helpers.ts index 8c44ca54..bb06f1e7 100644 --- a/src/renderer/src/pages/work/task-helpers.ts +++ b/src/renderer/src/pages/work/task-helpers.ts @@ -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 { diff --git a/tests/unit/run-blocker.test.ts b/tests/unit/run-blocker.test.ts new file mode 100644 index 00000000..967f6bab --- /dev/null +++ b/tests/unit/run-blocker.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/task-execution-service.test.ts b/tests/unit/task-execution-service.test.ts index 968c4ad6..0187f202 100644 --- a/tests/unit/task-execution-service.test.ts +++ b/tests/unit/task-execution-service.test.ts @@ -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 => { + 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);