Q150: work can be sent back, reopened, moved to backlog, or blocked with a reason
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ab46bb2de
commit
809c67a54a
21 files changed
+153
-7
No files matched your search
@@ -45,6 +45,7 @@ export const organizationContracts = {
|
||||
requiresFilesystem: z.boolean(), writingMode: z.enum(WRITING_MODE),
|
||||
}) satisfies z.ZodType<C.UpdateTaskInput>,
|
||||
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>,
|
||||
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>,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ 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.`);
|
||||
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"}).`);
|
||||
}
|
||||
|
||||
@@ -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<string, TaskHold> {
|
||||
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<string, TaskHold> {
|
||||
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<string, MoneyBlock | null>();
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface TaskExecutionTaskRow {
|
||||
writing_mode: WritingMode;
|
||||
handoff_json: string | null;
|
||||
origin: string | null;
|
||||
blocked_reason: string | null;
|
||||
}
|
||||
|
||||
export interface TaskExecutionAgentRow {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -31,6 +31,14 @@ export const zh: Record<string, string> = {
|
||||
"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} 自动恢复。",
|
||||
|
||||
@@ -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({
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 6, minWidth: 0 }}>
|
||||
{project ? <Pill>{project.name}</Pill> : <Pill>{capitalize(task.risk)}</Pill>}
|
||||
{task.stalledReason && task.status !== "done" && <Pill tone="red">{t("stalled")}</Pill>}
|
||||
{task.hold && !task.stalledReason && (
|
||||
{task.blockedReason && task.status !== "done" && (
|
||||
<span title={task.blockedReason} style={{ display: "inline-flex" }}><Pill tone="red">{t("blocked")}</Pill></span>
|
||||
)}
|
||||
{task.hold && !task.stalledReason && !task.blockedReason && (
|
||||
<span title={task.hold.reason} style={{ display: "inline-flex" }}>
|
||||
<Pill tone={task.hold.waitingOn === "operator" ? "red" : "amber"}>{task.hold.waitingOn === "operator" ? t("needs you") : t("waiting")}</Pill>
|
||||
</span>
|
||||
|
||||
@@ -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<void>;
|
||||
/** Blocks the task with a reason, or unblocks it with null. */
|
||||
onBlock: (task: TaskRecord, reason: string | null) => Promise<void>;
|
||||
onScope: (scope: Focus) => void;
|
||||
navigate: PageProps["navigate"];
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const act = (operation: () => Promise<void>, 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<string | null>(null);
|
||||
|
||||
@@ -148,8 +160,30 @@ export function TaskDetailDialog({
|
||||
<ActivityTab audit={taskAudit} approvals={taskApprovals} onOpenApproval={(approvalId) => navigate("approvals", { kind: "approval", id: approvalId })} />
|
||||
)}
|
||||
|
||||
{blocking && (
|
||||
<form
|
||||
className="row"
|
||||
style={{ gap: 8, marginTop: 12 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const reason = String(new FormData(event.currentTarget).get("reason") ?? "");
|
||||
act(() => onBlock(task, reason), () => setBlocking(false));
|
||||
}}
|
||||
>
|
||||
<input className="input" name="reason" aria-label={t("Why is it blocked?")} placeholder={t("Why is it blocked?")} required autoFocus style={{ flex: 1 }} />
|
||||
<Button tone="primary" size="small" type="submit">{t("Block task")}</Button>
|
||||
<Button tone="ghost" size="small" onClick={() => setBlocking(false)}>{t("Cancel")}</Button>
|
||||
</form>
|
||||
)}
|
||||
{actionError && <div className="form-error" role="alert" style={{ marginTop: 8 }}>{actionError}</div>}
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={deleteTask}><Icon name="x" size={16} /> {t("Delete")}</Button>
|
||||
{task.blockedReason
|
||||
? <Button onClick={() => act(() => onBlock(task, null))}>{t("Unblock")}</Button>
|
||||
: task.status !== TASK_STATUS.DONE && !blocking && <Button onClick={() => setBlocking(true)}>{t("Block")}</Button>}
|
||||
{task.status === TASK_STATUS.REVIEW && <Button onClick={() => act(() => onMove(task, TASK_STATUS.IN_PROGRESS))}>{t("Send back")}</Button>}
|
||||
{task.status === TASK_STATUS.DONE && <Button onClick={() => act(() => onMove(task, TASK_STATUS.IN_PROGRESS))}>{t("Reopen")}</Button>}
|
||||
{task.status === TASK_STATUS.IN_PROGRESS && <Button onClick={() => act(() => onMove(task, TASK_STATUS.BACKLOG))}>{t("Back to backlog")}</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DesktopResult<ThreadHistory>>;
|
||||
/** 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<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[]>>;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user