diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index ed1001d1..a5c5bf16 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -45,6 +45,7 @@ export const organizationContracts = { requiresFilesystem: z.boolean(), writingMode: z.enum(WRITING_MODE), }) satisfies z.ZodType, deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, + setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType, updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType, runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 0ea62a46..5e5ee2c4 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -164,6 +164,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("clearIntegrationKey", (input) => mutate(() => integrationKeys.clear(input.name))); handle("createTask", (input) => mutate(() => organization.createTask(input))); handle("updateTask", (input) => mutate(() => organization.updateTask(input))); + handle("setTaskBlocked", (input) => mutate(() => organization.setTaskBlocked(input))); handle("deleteTask", (input) => mutate(() => organization.deleteTask(input))); handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); handle("runTask", async (input) => { diff --git a/src/main/database/migrations/101-task-blocked.ts b/src/main/database/migrations/101-task-blocked.ts new file mode 100644 index 00000000..b3d6be1e --- /dev/null +++ b/src/main/database/migrations/101-task-blocked.ts @@ -0,0 +1,7 @@ +// Why the operator blocked a task; while set, no session starts on it. +export const taskBlockedSchema = ` + alter table tasks add column blocked_reason text; + + insert into schema_migrations(version, name, applied_at) + values (101, 'task_blocked', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 2e126d74..00a70e3d 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -99,6 +99,7 @@ import { dropDrillOnlySloSchema } from "./migrations/097-drop-drill-only-slo"; import { dropAuditReplaysSchema } from "./migrations/098-drop-audit-replays"; import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-records"; import { chatReplyRequestsSchema } from "./migrations/100-chat-reply-requests"; +import { taskBlockedSchema } from "./migrations/101-task-blocked"; interface Migration { version: number; @@ -207,6 +208,7 @@ const MIGRATIONS: Migration[] = [ { version: 98, name: "drop_audit_replays", sql: dropAuditReplaysSchema }, { version: 99, name: "drop_rollback_records", sql: dropRollbackRecordsSchema }, { version: 100, name: "chat_reply_requests", sql: chatReplyRequestsSchema }, + { version: 101, name: "task_blocked", sql: taskBlockedSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/heartbeat-scheduler.ts b/src/main/services/heartbeat-scheduler.ts index 90fd45bf..4e8b33b9 100644 --- a/src/main/services/heartbeat-scheduler.ts +++ b/src/main/services/heartbeat-scheduler.ts @@ -38,6 +38,7 @@ export class HeartbeatScheduler { left join provider_usage_windows u on u.org_id = p.org_id and u.provider_id = p.id and u.window_start = ? where t.status in ('backlog', 'in_progress') + and t.blocked_reason is null and a.heartbeat_enabled = 1 and a.lifecycle = 'active' and p.enabled = 1 diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 369f4d2d..a3cecb5f 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,7 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain"; import { createId } from "../../shared/ids"; -import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; +import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff"; import { PolicyService } from "./policy-service"; @@ -472,6 +472,10 @@ export class OrganizationService { this.tasks.update(input); } + setTaskBlocked(input: SetTaskBlockedInput): void { + this.tasks.setBlocked(input); + } + deleteTask(input: DeleteTaskInput): void { this.tasks.delete(input); } diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 379d5bbb..2817f09b 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -90,6 +90,7 @@ 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.`); + if (task.blocked_reason !== null) throw new Error(`"${task.title}" is blocked: ${task.blocked_reason} Unblock it to run it.`); 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) { @@ -115,6 +116,7 @@ export class TaskExecutionService { try { const task = requireTask(this.db, orgId, run.task_id); if (task.status === TASK_STATUS.DONE) return this.supersedeRun(orgId, runId, run, "the task was closed after this run was queued."); + if (task.blocked_reason !== null) return this.supersedeRun(orgId, runId, run, `the task was blocked after this run was queued (${task.blocked_reason}).`); if (task.assignee_agent_id !== run.agent_id) { return this.supersedeRun(orgId, runId, run, `the task was reassigned after this run was queued (run agent ${run.agent_id}, assignee ${task.assignee_agent_id ?? "none"}).`); } diff --git a/src/main/services/task-holds.ts b/src/main/services/task-holds.ts index 07662c19..e8fa011f 100644 --- a/src/main/services/task-holds.ts +++ b/src/main/services/task-holds.ts @@ -5,7 +5,9 @@ import { budgetBlock, isPriced, providerPrices } from "./spend"; interface OpenTaskRow { id: string; + status: string; stalled_reason: string | null; + blocked_reason: string | null; enqueue_error: string | null; agent_id: string | null; provider_id: string | null; @@ -20,7 +22,7 @@ interface OpenTaskRow { export function taskHolds(db: DatabaseSync, now: Date): Map { const window = currentProviderUsageWindow(now); const rows = db.prepare(` - select t.id, t.stalled_reason, t.enqueue_error, a.id as agent_id, p.id as provider_id, p.label as provider_label, + select t.id, t.status, t.stalled_reason, t.blocked_reason, t.enqueue_error, a.id as agent_id, p.id as provider_id, p.label as provider_label, p.monthly_request_limit as request_limit, coalesce(u.request_count, 0) as request_count, c.until_ms as cooldown_until_ms, c.label as cooldown_label from tasks t @@ -28,7 +30,7 @@ export function taskHolds(db: DatabaseSync, now: Date): Map { left join providers p on p.id = a.model_provider_id left join provider_usage_windows u on u.org_id = p.org_id and u.provider_id = p.id and u.window_start = ? left join provider_cooldowns c on c.provider_id = p.id - where t.status in ('backlog', 'in_progress') + where (t.status in ('backlog', 'in_progress') or (t.status = 'review' and t.blocked_reason is not null)) and not exists (select 1 from task_runs r where r.task_id = t.id and r.state = 'running') `).all(window.start) as unknown as OpenTaskRow[]; const budgets = new Map(); @@ -60,6 +62,9 @@ interface MoneyBlock { } function holdOf(row: OpenTaskRow, now: Date, windowEnd: string, budget: (agentId: string, providerId: string) => MoneyBlock | null): TaskHold | null { + if (row.blocked_reason) return { reason: `Blocked: ${row.blocked_reason}`, resumesAt: null, waitingOn: "operator" }; + // Work in review waits on its reviewer, so only a block holds it here. + if (row.status === "review") return null; if (row.stalled_reason) return { reason: row.stalled_reason, resumesAt: null, waitingOn: "operator" }; if (row.enqueue_error) return { reason: `Could not start: ${row.enqueue_error}`, resumesAt: null, waitingOn: "operator" }; if (!row.agent_id || !row.provider_id) return null; diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index 4b28268b..07160bba 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain"; -import type { CreateTaskInput, DeleteTaskInput, UpdateTaskInput } from "../../shared/commands"; +import type { CreateTaskInput, DeleteTaskInput, SetTaskBlockedInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; import { withSavepoint } from "../database/connection"; import { heldWorkTitles } from "./held-deliverables"; @@ -70,6 +70,26 @@ export class TaskMutations { }); } + /** Blocks the task with a reason (no session starts, queued ones are superseded) or unblocks it with null. */ + setBlocked(input: SetTaskBlockedInput): void { + const reason = input.reason === null ? null : input.reason.trim(); + if (reason === "") throw new Error("Say why the task is blocked."); + withSavepoint(this.db, "task_block", () => { + const result = this.db.prepare("update tasks set blocked_reason = ?, updated_at = ? where id = ? and org_id = ?").run(reason, new Date().toISOString(), input.taskId, input.orgId); + if (result.changes === 0) throw new Error("Task was not found."); + if (reason !== null) { + this.db.prepare(` + update task_runs set state = 'superseded', completed_at = ?, error_message = ? + where task_id = ? and org_id = ? and state = 'queued' + `).run(new Date().toISOString(), `Superseded: the task was blocked (${reason}).`, input.taskId, input.orgId); + } + this.audit.record({ + orgId: input.orgId, entityType: "task", entityId: input.taskId, actorType: "employee", actorId: null, + action: reason === null ? "unblocked" : "blocked", payload: { reason }, + }); + }); + } + delete(input: DeleteTaskInput): void { const running = this.db.prepare("select 1 from task_runs where task_id = ? and org_id = ? and state = 'running' limit 1") .get(input.taskId, input.orgId); diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index eef54414..d2d1505a 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -21,6 +21,7 @@ export interface TaskExecutionTaskRow { writing_mode: WritingMode; handoff_json: string | null; origin: string | null; + blocked_reason: string | null; } export interface TaskExecutionAgentRow { diff --git a/src/preload/index.ts b/src/preload/index.ts index fe325585..3713cff9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -90,6 +90,7 @@ const api: DesktopApi = { runRequests: (input) => invoke(IPC_CHANNELS.runRequests, input), chatThreadHistory: (input) => invoke(IPC_CHANNELS.chatThreadHistory, input), chatSpaceHistory: (input) => invoke(IPC_CHANNELS.chatSpaceHistory, input), + setTaskBlocked: (input) => invoke(IPC_CHANNELS.setTaskBlocked, input), revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index a6cc3fe4..d508ccda 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -31,6 +31,14 @@ export const zh: Record = { "Writing": "写作方式", "superseded": "已被取代", "stalled": "已停滞", + "blocked": "已阻塞", + "Block": "阻塞", + "Unblock": "解除阻塞", + "Block task": "阻塞任务", + "Why is it blocked?": "为什么阻塞?", + "Send back": "退回", + "Reopen": "重新打开", + "Back to backlog": "退回待办", "needs you": "待你处理", "waiting": "等待中", "Resumes on its own at {time}.": "将于 {time} 自动恢复。", diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index d4477261..fa930994 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -177,6 +177,12 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) { onAdvance={advance} onRun={runTask} onOpenTask={setDetailTaskId} + onMove={async (task, status) => { + await actions.run(() => window.agentCompany.updateTaskStatus({ orgId, taskId: task.id, status })); + }} + onBlock={async (task, reason) => { + await actions.run(() => window.agentCompany.setTaskBlocked({ orgId, taskId: task.id, reason })); + }} onScope={(next) => { setScope(next); setDetailTaskId(null); @@ -357,7 +363,10 @@ function TaskCard({
{project ? {project.name} : {capitalize(task.risk)}} {task.stalledReason && task.status !== "done" && {t("stalled")}} - {task.hold && !task.stalledReason && ( + {task.blockedReason && task.status !== "done" && ( + {t("blocked")} + )} + {task.hold && !task.stalledReason && !task.blockedReason && ( {task.hold.waitingOn === "operator" ? t("needs you") : t("waiting")} diff --git a/src/renderer/src/pages/work/TaskDetailDialog.tsx b/src/renderer/src/pages/work/TaskDetailDialog.tsx index 8251054c..a0b395c9 100644 --- a/src/renderer/src/pages/work/TaskDetailDialog.tsx +++ b/src/renderer/src/pages/work/TaskDetailDialog.tsx @@ -12,7 +12,7 @@ import type { TaskRunRecord, WorkspaceRecord, } from "../../../../shared/domain"; -import { TASK_STATUS, type WritingMode } from "../../../../shared/domain"; +import { TASK_STATUS, type TaskStatus, type WritingMode } from "../../../../shared/domain"; import { auditEventLabel, formatRelative } from "../types"; import { WRITING_OPTIONS, descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers"; import type { SnapshotActions } from "../../app/useSnapshot"; @@ -43,6 +43,8 @@ export function TaskDetailDialog({ onAdvance, onRun, onOpenTask, + onMove, + onBlock, onScope, navigate, }: { @@ -62,10 +64,20 @@ export function TaskDetailDialog({ onAdvance: (task: TaskRecord) => void; onRun: (task: TaskRecord) => void; onOpenTask: (taskId: string) => void; + /** Moves the task to any status, backwards included: send back, reopen, back to backlog. */ + onMove: (task: TaskRecord, status: TaskStatus) => Promise; + /** Blocks the task with a reason, or unblocks it with null. */ + onBlock: (task: TaskRecord, reason: string | null) => Promise; onScope: (scope: Focus) => void; navigate: PageProps["navigate"]; }) { const [tab, setTab] = useState("overview"); + const [blocking, setBlocking] = useState(false); + const [actionError, setActionError] = useState(null); + const act = (operation: () => Promise, after?: () => void) => { + setActionError(null); + operation().then(after, (error: unknown) => setActionError(error instanceof Error ? error.message : String(error))); + }; const [editing, setEditing] = useState(false); const [editError, setEditError] = useState(null); @@ -148,8 +160,30 @@ export function TaskDetailDialog({ navigate("approvals", { kind: "approval", id: approvalId })} /> )} + {blocking && ( +
{ + event.preventDefault(); + const reason = String(new FormData(event.currentTarget).get("reason") ?? ""); + act(() => onBlock(task, reason), () => setBlocking(false)); + }} + > + + + +
+ )} + {actionError &&
{actionError}
}
+ {task.blockedReason + ? + : task.status !== TASK_STATUS.DONE && !blocking && } + {task.status === TASK_STATUS.REVIEW && } + {task.status === TASK_STATUS.DONE && } + {task.status === TASK_STATUS.IN_PROGRESS && } {assignee && } {advanceTo && }
diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index ea74129e..3a5a70a5 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -123,6 +123,13 @@ export interface UpdateTaskInput { writingMode: WritingMode; } +export interface SetTaskBlockedInput { + orgId: string; + taskId: string; + /** Why the task is blocked; null unblocks it. */ + reason: string | null; +} + export interface DeleteTaskInput { orgId: string; taskId: string; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 5801c509..f11562ff 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -34,6 +34,7 @@ import type { DeleteAutomationRuleInput, DeleteDocumentInput, DeleteTaskInput, + SetTaskBlockedInput, DeleteKnowledgeEntryInput, ResumeGoalInput, UpdateAutomationRuleInput, @@ -199,6 +200,8 @@ export interface DesktopApi { /** One thread in full, beyond the snapshot's recent window. */ chatThreadHistory(input: { orgId: string; threadId: string }): Promise>; /** The page of a conversation before a loaded message, for history older than the snapshot holds. */ + /** Blocks a task with a reason, or unblocks it with null. */ + setTaskBlocked(input: SetTaskBlockedInput): Promise>; chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise>; revokeConnector(input: RevokeConnectorInput): Promise>; search(input: SearchInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 8be36b57..774bb5da 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -108,6 +108,8 @@ export interface TaskRecord { stalledReason: string | null; /** Why the heartbeat last failed to queue the task; cleared once it queues. */ enqueueError: string | null; + /** Why the operator blocked the task; no session starts while it is set. */ + blockedReason: string | null; /** Why the open task is not moving now, or null when nothing holds it. */ hold: TaskHold | null; /** Runs at or below this task_runs rowid no longer count toward the run budget. */ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 147d0fb4..9d0d51a5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -83,6 +83,7 @@ export const IPC_CHANNELS = { runRequests: "agentcompany:run-requests", chatThreadHistory: "agentcompany:chat-thread-history", chatSpaceHistory: "agentcompany:chat-space-history", + setTaskBlocked: "agentcompany:set-task-blocked", revokeConnector: "agentcompany:revoke-connector", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index cd6e440f..398a01d6 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -95,6 +95,16 @@ test("the pinned CEO conversation is the Messages front door", async () => { return [task.writingMode, task.requiresFilesystem]; }); expect(edited).toEqual(["technical", true]); + // Blocking says why on the task and stops it; work can also move backwards. + await taskDialog.getByRole("button", { name: "Block", exact: true }).click(); + await taskDialog.getByRole("textbox", { name: "Why is it blocked?" }).fill("Waiting for the legal review."); + await taskDialog.getByRole("button", { name: "Block task" }).click(); + await expect(taskDialog.getByRole("status")).toContainText("Blocked: Waiting for the legal review."); + await taskDialog.getByRole("button", { name: "Unblock" }).click(); + await expect(taskDialog.getByText("Blocked: Waiting for the legal review.")).toHaveCount(0); + await taskDialog.getByRole("button", { name: "In progress" }).click(); + await taskDialog.getByRole("button", { name: "Back to backlog" }).click(); + await expect(taskDialog.getByRole("button", { name: "In progress" })).toBeVisible(); await taskDialog.getByRole("button", { name: "Close" }).click(); await page.getByRole("button", { name: "Messages" }).click(); diff --git a/tests/integration/task-holds.test.ts b/tests/integration/task-holds.test.ts index 86338bc3..45683332 100644 --- a/tests/integration/task-holds.test.ts +++ b/tests/integration/task-holds.test.ts @@ -8,6 +8,7 @@ import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters } from "../../src/main/services/provider-adapters"; import { TaskExecutionService } from "../../src/main/services/task-execution-service"; import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; import { currentProviderUsageWindow } from "../../src/shared/provider-runtime-domain"; import type { AgentTurnResult } from "../../src/main/services/provider-tool-turn"; @@ -106,3 +107,29 @@ describe("why an open task is not moving", () => { h.connection.close(); }); }); + +describe("blocking a task", () => { + test("stops it until unblocked: no new session, queued ones superseded, manual runs refused, and the board says why", async () => { + const h = company(); + const organization = new OrganizationService(h.connection.db); + expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toHaveLength(1); + h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: "Waiting for the legal review." })); + expect(h.connection.db.prepare("select state from task_runs where task_id = ? order by rowid desc limit 1").get(h.taskId)).toEqual({ state: "superseded" }); + expect(h.hold()).toEqual({ reason: "Blocked: Waiting for the legal review.", resumesAt: null, waitingOn: "operator" }); + expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toEqual([]); + await expect(h.execution.runTask({ orgId: h.orgId, taskId: h.taskId })).rejects.toThrow("is blocked: Waiting for the legal review. Unblock it to run it."); + expect(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: " " })).toThrow("Say why the task is blocked."); + + h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: null })); + expect(h.hold()).toBeNull(); + // Work in review can be blocked too, and says so. + h.connection.db.prepare("update tasks set status = 'review' where id = ?").run(h.taskId); + h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: "Spec changed." })); + expect(h.hold()).toEqual({ reason: "Blocked: Spec changed.", resumesAt: null, waitingOn: "operator" }); + h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: null })); + expect(h.hold()).toBeNull(); + h.connection.db.prepare("update tasks set status = 'backlog' where id = ?").run(h.taskId); + expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toHaveLength(1); + h.connection.close(); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 226cc7ac..6d0b30bf 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -35,7 +35,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]); connection.close(); });