Q157: a run can be cancelled before it starts, and a failed or stopped one retried or resumed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 02:11:11 -04:00
1 parent 71ac5085cd
commit a075f931d5
12 files changed
+114 -22

No files matched your search

@@ -47,6 +47,7 @@ export const organizationContracts = {
deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.DeleteTaskInput>,
setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType<C.SetTaskBlockedInput>,
taskDeleteImpact: z.strictObject({ orgId: id, taskId: id }),
cancelQueuedRun: z.strictObject({ orgId: id, runId: id }),
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
+2
View File
@@ -37,6 +37,7 @@ import { coalesce } from "./coalesce";
import { normalizeExternalBrowserUrl } from "./external-url";
import { resumeGoal } from "../services/goal-review-service";
import { stopAllRuns, stopRun } from "../services/run-control";
import { cancelQueuedRun } from "../services/run-cancel";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -166,6 +167,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
handle("updateTask", (input) => mutate(() => organization.updateTask(input)));
handle("setTaskBlocked", (input) => mutate(() => organization.setTaskBlocked(input)));
handle("taskDeleteImpact", (input) => ok(organization.taskDeleteImpact(input.orgId, input.taskId)));
handle("cancelQueuedRun", (input) => mutate(() => cancelQueuedRun(deps.connection.db, input.orgId, input.runId)));
handle("deleteTask", (input) => mutate(() => organization.deleteTask(input)));
handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
handle("runTask", async (input) => {
+14
View File
@@ -0,0 +1,14 @@
import type { DatabaseSync } from "node:sqlite";
import { TASK_STATUS } from "../../shared/domain";
import { OrganizationService } from "./organization-service";
/** A chat-wake task only carries its reply run, so the run ending closes it; left open it would strand forever. */
export function closeChatWakeTask(db: DatabaseSync, orgId: string, runId: string): void {
const run = db.prepare("select r.task_id, t.origin from task_runs r join tasks t on t.id = r.task_id where r.id = ? and r.org_id = ?")
.get(runId, orgId) as { task_id: string; origin: string | null } | undefined;
if (!run || run.origin !== "chat_wake") return;
const task = db.prepare("select status from tasks where id = ? and org_id = ?")
.get(run.task_id, orgId) as { status: string } | undefined;
if (!task || (task.status !== TASK_STATUS.BACKLOG && task.status !== TASK_STATUS.IN_PROGRESS)) return;
new OrganizationService(db).updateTaskStatus(orgId, run.task_id, TASK_STATUS.DONE);
}
+15
View File
@@ -0,0 +1,15 @@
import type { DatabaseSync } from "node:sqlite";
import { AuditService } from "./audit-service";
import { closeChatWakeTask } from "./chat-wake-close";
/** Cancels a run that has not started: not a failure, no retry budget spent; a chat reply it carried closes. */
export function cancelQueuedRun(db: DatabaseSync, orgId: string, runId: string): void {
const result = db.prepare(`
update task_runs set state = 'stopped', completed_at = ?, error_message = 'Cancelled before it started.'
where id = ? and org_id = ? and state = 'queued'
`).run(new Date().toISOString(), runId, orgId);
if (result.changes === 0) throw new Error("That run is no longer queued.");
console.info(`[task-run] run ${runId} cancelled before it started`);
closeChatWakeTask(db, orgId, runId);
new AuditService(db).record({ orgId, entityType: "task_run", entityId: runId, actorType: "employee", actorId: null, action: "cancelled", payload: {} });
}
+4 -14
View File
@@ -1,3 +1,4 @@
import { closeChatWakeTask } from "./chat-wake-close";
import type { DatabaseSync } from "node:sqlite";
import type { RunTaskInput } from "../../shared/commands";
import { TASK_STATUS, WRITING_MODE } from "../../shared/domain";
@@ -436,18 +437,7 @@ export class TaskExecutionService {
set state = ?, provider_request_id = coalesce(?, provider_request_id), completed_at = ?, error_message = null
where id = ? and org_id = ?
`).run(TASK_RUN_STATE.COMPLETED, providerRequestId, new Date().toISOString(), runId, orgId);
if (!checkpoint) this.closeChatWakeTask(orgId, runId);
}
/** A chat-wake task only carries its reply run, so the run completing closes it; left open it would strand forever. */
private closeChatWakeTask(orgId: string, runId: string): void {
const run = this.db.prepare("select r.task_id, t.origin from task_runs r join tasks t on t.id = r.task_id where r.id = ? and r.org_id = ?")
.get(runId, orgId) as { task_id: string; origin: string | null } | undefined;
if (!run || run.origin !== "chat_wake") return;
const task = this.db.prepare("select status from tasks where id = ? and org_id = ?")
.get(run.task_id, orgId) as { status: string } | undefined;
if (!task || (task.status !== TASK_STATUS.BACKLOG && task.status !== TASK_STATUS.IN_PROGRESS)) return;
this.organization.updateTaskStatus(orgId, run.task_id, TASK_STATUS.DONE);
if (!checkpoint) closeChatWakeTask(this.db, orgId, runId);
}
/** The operator stopped the run: not a failure, no retry budget spent, no memory recorded. */
@@ -456,7 +446,7 @@ export class TaskExecutionService {
update task_runs set state = ?, provider_request_id = coalesce(?, provider_request_id), completed_at = ?, error_message = ?
where id = ? and org_id = ?
`).run(TASK_RUN_STATE.STOPPED, providerRequestId, new Date().toISOString(), reason, runId, orgId);
this.closeChatWakeTask(orgId, runId);
closeChatWakeTask(this.db, orgId, runId);
this.audit.record({ orgId, entityType: "task_run", entityId: runId, actorType: "system", actorId: null, action: "stopped", payload: { ...payload, reason } });
}
@@ -466,7 +456,7 @@ export class TaskExecutionService {
console.info(`[task-run] run ${runId} ${message}`);
this.db.prepare("update task_runs set state = ?, completed_at = ?, error_message = ? where id = ? and org_id = ?")
.run(TASK_RUN_STATE.SUPERSEDED, new Date().toISOString(), message, runId, orgId);
this.closeChatWakeTask(orgId, runId);
closeChatWakeTask(this.db, orgId, runId);
this.audit.record({
orgId,
entityType: "task_run",
+1
View File
@@ -92,6 +92,7 @@ const api: DesktopApi = {
chatSpaceHistory: (input) => invoke(IPC_CHANNELS.chatSpaceHistory, input),
setTaskBlocked: (input) => invoke(IPC_CHANNELS.setTaskBlocked, input),
taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input),
cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input),
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
+4
View File
@@ -51,6 +51,10 @@ export const zh: Record<string, string> = {
"No notes": "无备注",
"Showing {shown} of {total}": "显示 {total} 项中的 {shown} 项",
"Show earlier decisions": "显示更早的决定",
"Cancel run": "取消运行",
"Running…": "运行中…",
"A cancelled session can start again on the next heartbeat; block the task to keep it stopped.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。",
"A new session on the task picks up from what earlier sessions did and where they stopped.": "任务的新会话会接着之前会话做过的工作和停下的位置继续。",
"Edit the task": "编辑任务",
"Changing the task replaces this request with a fresh one.": "修改任务会用新的申请替换这一项。",
"Delete task": "删除任务",
+22 -8
View File
@@ -90,7 +90,7 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) {
</aside>
<div style={{ flex: 1, minWidth: 0, overflow: "auto", padding: 24 }}>
{selected ? (
<RunDetail run={selected} tasks={tasks} agents={agents} providers={providers} providerRequests={providerRequests} actions={actions} onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })} />
<RunDetail key={selected.id} run={selected} tasks={tasks} agents={agents} providers={providers} providerRequests={providerRequests} actions={actions} onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })} />
) : (
<div style={{ display: "grid", placeItems: "center", height: "100%", color: "var(--text-3)", fontSize: 14 }}>
{t("Select a run")}
@@ -118,12 +118,19 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions, o
const started = run.startedAt ? new Date(run.startedAt) : null;
const completed = run.completedAt ? new Date(run.completedAt) : null;
const durationMs = started && completed ? completed.getTime() - started.getTime() : null;
const [stopError, setStopError] = useState<string | null>(null);
const stop = (runId: string) => {
setStopError(null);
actions.run(() => window.agentCompany.stopRun({ runId }))
.catch((error: unknown) => setStopError(error instanceof Error ? error.message : String(error)));
// Stop a running session, cancel one not yet started, or start a new session that picks up from the journal.
const [controlError, setControlError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const control = (operation: () => Promise<unknown>) => {
setControlError(null);
setBusy(true);
operation()
.catch((error: unknown) => setControlError(error instanceof Error ? error.message : String(error)))
.finally(() => setBusy(false));
};
const stop = () => control(() => actions.run(() => window.agentCompany.stopRun({ runId: run.id })));
const cancel = () => control(() => actions.run(() => window.agentCompany.cancelQueuedRun({ orgId: run.orgId, runId: run.id })));
const again = () => control(() => actions.run(() => window.agentCompany.runTask({ orgId: run.orgId, taskId: run.taskId })));
return (
<div>
@@ -136,9 +143,16 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions, o
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{run.id}</div>
</div>
<Pill tone={runTone(run.state)}>{runLabel(run.state)}</Pill>
{run.state === "running" && <Button size="small" onClick={() => stop(run.id)}>{t("Stop")}</Button>}
{run.state === "running" && <Button size="small" onClick={stop} disabled={busy}>{t("Stop")}</Button>}
{run.state === "queued" && <Button size="small" onClick={cancel} disabled={busy}>{t("Cancel run")}</Button>}
{run.state === "failed" && <Button size="small" onClick={again} disabled={busy}>{busy ? t("Running…") : t("Retry")}</Button>}
{(run.state === "stopped" || run.state === "superseded") && <Button size="small" onClick={again} disabled={busy}>{busy ? t("Running…") : t("Resume")}</Button>}
</div>
{stopError && <div className="form-error" role="alert">{stopError}</div>}
{run.state === "queued" && <div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 8 }}>{t("A cancelled session can start again on the next heartbeat; block the task to keep it stopped.")}</div>}
{(run.state === "failed" || run.state === "stopped" || run.state === "superseded") && (
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 8 }}>{t("A new session on the task picks up from what earlier sessions did and where they stopped.")}</div>
)}
{controlError && <div className="form-error" role="alert">{controlError}</div>}
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "8px 16px", fontSize: 14, marginBottom: 20 }}>
<MetaLabel>{t("Agent")}</MetaLabel><div>{agent?.name ?? "—"} ({agent?.role ?? "—"})</div>
+2
View File
@@ -205,6 +205,8 @@ export interface DesktopApi {
setTaskBlocked(input: SetTaskBlockedInput): Promise<DesktopResult<AppSnapshot>>;
/** What deleting a task would remove, keep and close. */
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
/** Cancels a run that has not started yet. */
cancelQueuedRun(input: { orgId: string; runId: string }): Promise<DesktopResult<AppSnapshot>>;
chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise<DesktopResult<SpaceHistoryPage>>;
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
+1
View File
@@ -85,6 +85,7 @@ export const IPC_CHANNELS = {
chatSpaceHistory: "agentcompany:chat-space-history",
setTaskBlocked: "agentcompany:set-task-blocked",
taskDeleteImpact: "agentcompany:task-delete-impact",
cancelQueuedRun: "agentcompany:cancel-queued-run",
revokeConnector: "agentcompany:revoke-connector",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
+3
View File
@@ -82,6 +82,9 @@ test("the pinned CEO conversation is the Messages front door", async () => {
await expect(page.getByText("Goal: Ship the FlowPilot MVP.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click();
await taskDialog.getByRole("button", { name: /^Open run/ }).first().click();
// A queued run can be cancelled before it starts; then it offers to resume.
await page.getByRole("button", { name: "Cancel run" }).click();
await expect(page.getByRole("button", { name: "Resume" })).toBeVisible();
await expect(page.getByRole("button", { name: "Open task Operationalize goal: Ship the FlowPilot MVP." })).toBeVisible();
await page.getByRole("button", { name: "Open task Operationalize goal: Ship the FlowPilot MVP." }).click();
await expect(taskDialog).toBeVisible();
+45
View File
@@ -0,0 +1,45 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OnboardingService } from "../../src/main/services/onboarding-service";
import { ProviderService } from "../../src/main/services/provider-service";
import { providerAdapters } from "../../src/main/services/provider-adapters";
import { ChatService } from "../../src/main/services/chat-service";
import { cancelQueuedRun } from "../../src/main/services/run-cancel";
const testVault = {
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"),
};
describe("cancelling a queued run", () => {
test("stops it before it starts without spending the retry budget, and closes a chat reply it was for", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-run-cancel-${randomUUID()}.db`));
const providers = new ProviderService(connection.db, providerAdapters, testVault);
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
let orgId = "";
connection.transaction(() => {
orgId = onboarding.bootstrap({
companyName: "FlowPilot", companyDescription: "CSM analytics.", employeeName: "Yang", workspacePath: "",
executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false,
goalTitle: "Ship the MVP.", goalDescription: "A working product.",
providerKey: "openai", providerSecret: "sk-test-key", providerModel: "gpt-5.4",
}).orgId;
});
const kickoff = connection.db.prepare("select id from task_runs where state = 'queued'").get() as { id: string };
connection.transaction(() => cancelQueuedRun(connection.db, orgId, kickoff.id));
expect(connection.db.prepare("select state, error_message from task_runs where id = ?").get(kickoff.id))
.toEqual({ state: "stopped", error_message: "Cancelled before it started." });
expect(() => cancelQueuedRun(connection.db, orgId, kickoff.id)).toThrow("That run is no longer queued.");
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
const dm = (connection.db.prepare("select id from chat_spaces where org_id = ? and space_type = 'direct'").get(orgId) as { id: string }).id;
connection.transaction(() => new ChatService(connection.db).sendMessage({ orgId, spaceId: dm, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Status?", attachmentFileIds: [] }));
const wake = connection.db.prepare("select id, task_id from task_runs where trigger = 'chat_mention'").get() as { id: string; task_id: string };
connection.transaction(() => cancelQueuedRun(connection.db, orgId, wake.id));
expect(connection.db.prepare("select status from tasks where id = ?").get(wake.task_id)).toEqual({ status: "done" });
connection.close();
});
});