feat(FN-3350): tokenize task detail danger/error styling

Fixes task detail modal danger/error styling by switching to token-based CSS variables for consistent theming across dark and light modes.

Fusion-Task-Id: FN-3350
This commit is contained in:
Fusion
2026-05-04 04:04:13 -07:00
committed by gsxdsm
parent 5be5da1938
commit 6ecfb3c4ec
24 changed files with 487 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Agent pause now automatically pauses all assigned tasks; manual pause controls are blocked/hidden for agent-assigned tasks; tasks now show a "paused by agent" indicator.

View File

@@ -155,7 +155,7 @@ describe("Database", () => {
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
}); });
it("seeds lastModified", () => { it("seeds lastModified", () => {
@@ -178,7 +178,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
@@ -899,7 +899,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29) // Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -924,11 +924,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir); const db = new Database(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
db.close(); db.close();
}); });
@@ -963,7 +963,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority"); expect(cols.map((col) => col.name)).toContain("priority");
@@ -1004,7 +1004,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1073,7 +1073,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1176,7 +1176,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments"); expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1250,7 +1250,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]); expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1274,7 +1274,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]); expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1378,7 +1378,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 // Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1847,7 +1847,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir); const db = createDatabase(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();

View File

@@ -869,7 +869,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33) // Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir); const db1 = createDatabase(legacyDir);
db1.init(); db1.init();
expect(db1.getSchemaVersion()).toBe(59); expect(db1.getSchemaVersion()).toBe(60);
db1.close(); db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables // Step 2: Manually downgrade to version 32 and drop insight tables
@@ -904,7 +904,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs"); expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration // Now run init — this triggers the v32→v33 migration
db3.init(); db3.init();
expect(db3.getSchemaVersion()).toBe(59); expect(db3.getSchemaVersion()).toBe(60);
// Step 4: Verify insight tables exist after migration // Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare( const tablesAfter = db3.prepare(
@@ -935,12 +935,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try { try {
const db1 = createDatabase(testDir); const db1 = createDatabase(testDir);
db1.init(); db1.init();
expect(db1.getSchemaVersion()).toBe(59); expect(db1.getSchemaVersion()).toBe(60);
db1.close(); db1.close();
const db2 = createDatabase(testDir); const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow(); expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(59); expect(db2.getSchemaVersion()).toBe(60);
db2.close(); db2.close();
} finally { } finally {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => { describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => { it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
}); });
it("mission_features table has loop state columns", () => { it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => { describe("schema version", () => {
it("schema version is 40 after init", () => { it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
}); });
}); });

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
}); });
it("schema version is bumped to 40", () => { it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
}); });
}); });
}); });

View File

@@ -480,6 +480,33 @@ describe("TaskStore", () => {
}); });
}); });
describe("pausedByAgentId persistence", () => {
it("creates and lists a task with pausedByAgentId", async () => {
const task = await store.createTask({ description: "Agent paused task" });
const updated = await store.updateTask(task.id, { pausedByAgentId: "agent-1" });
expect(updated.pausedByAgentId).toBe("agent-1");
const detail = await store.getTask(task.id);
expect(detail.pausedByAgentId).toBe("agent-1");
const tasks = await store.listTasks();
const listed = tasks.find((t) => t.id === task.id);
expect(listed?.pausedByAgentId).toBe("agent-1");
});
it("clears pausedByAgentId with null via updateTask", async () => {
const task = await store.createTask({ description: "Clear agent pause marker" });
await store.updateTask(task.id, { pausedByAgentId: "agent-2" });
const cleared = await store.updateTask(task.id, { pausedByAgentId: null });
expect(cleared.pausedByAgentId).toBeUndefined();
const detail = await store.getTask(task.id);
expect(detail.pausedByAgentId).toBeUndefined();
});
});
describe("nodeId persistence", () => { describe("nodeId persistence", () => {
it("creates a task with nodeId when provided", async () => { it("creates a task with nodeId when provided", async () => {
const task = await store.createTask({ const task = await store.createTask({
@@ -592,6 +619,36 @@ describe("TaskStore", () => {
}); });
}); });
describe("getTasksByAssignedAgent", () => {
it("returns only tasks assigned to the requested agent", async () => {
const mine = await store.createTask({ description: "mine", assignedAgentId: "agent-1" });
await store.createTask({ description: "other", assignedAgentId: "agent-2" });
await store.createTask({ description: "unassigned" });
const tasks = await store.getTasksByAssignedAgent("agent-1");
expect(tasks.map((task) => task.id)).toEqual([mine.id]);
});
it("supports pausedOnly filter", async () => {
const paused = await store.createTask({ description: "paused", assignedAgentId: "agent-1" });
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
await store.updateTask(paused.id, { paused: true });
const tasks = await store.getTasksByAssignedAgent("agent-1", { pausedOnly: true });
expect(tasks.map((task) => task.id)).toEqual([paused.id]);
expect(tasks.some((task) => task.id === active.id)).toBe(false);
});
it("supports excludeArchived filter", async () => {
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
const archived = await store.createTask({ description: "archived", assignedAgentId: "agent-1", column: "done" });
await store.archiveTask(archived.id, false);
const tasks = await store.getTasksByAssignedAgent("agent-1", { excludeArchived: true });
expect(tasks.map((task) => task.id)).toEqual([active.id]);
});
});
describe("selectNextTaskForAgent", () => { describe("selectNextTaskForAgent", () => {
it("returns null when no tasks exist", async () => { it("returns null when no tasks exist", async () => {
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull(); await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
@@ -3546,6 +3603,39 @@ describe("TaskStore", () => {
fetched = await store.getTask(task.id); fetched = await store.getTask(task.id);
expect(fetched.paused).toBe(true); expect(fetched.paused).toBe(true);
}); });
it("sets pausedByAgentId and logs agent pause reason", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-1" });
expect(paused.pausedByAgentId).toBe("agent-1");
expect(paused.log.at(-1)?.action).toBe("Task paused (agent agent-1 paused)");
});
it("clears pausedByAgentId and logs agent resume reason", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-2" });
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.pausedByAgentId).toBeUndefined();
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused (agent agent-2 resumed)");
});
it("uses standard unpause log when task was not paused by an agent", async () => {
const task = await createTestTask();
await store.pauseTask(task.id, true);
const unpaused = await store.pauseTask(task.id, false);
expect(unpaused.pausedByAgentId).toBeUndefined();
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused");
});
it("keeps pausedByAgentId undefined when pausing without agent options", async () => {
const task = await createTestTask();
const paused = await store.pauseTask(task.id, true);
expect(paused.pausedByAgentId).toBeUndefined();
});
}); });
describe("updateTask — paused", () => { describe("updateTask — paused", () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(59); expect(db.getSchemaVersion()).toBe(60);
const index = db const index = db
.prepare( .prepare(

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ──────────────────────────────────────────────── // ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 59; const SCHEMA_VERSION = 60;
function normalizeTaskComments( function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined, steeringComments: SteeringComment[] | undefined,
@@ -215,6 +215,7 @@ CREATE TABLE IF NOT EXISTS tasks (
missionId TEXT, missionId TEXT,
sliceId TEXT, sliceId TEXT,
assignedAgentId TEXT, assignedAgentId TEXT,
pausedByAgentId TEXT,
assigneeUserId TEXT, assigneeUserId TEXT,
sourceType TEXT, sourceType TEXT,
sourceAgentId TEXT, sourceAgentId TEXT,
@@ -2350,6 +2351,13 @@ export class Database {
}); });
} }
if (version < 60) {
this.applyMigration(60, () => {
this.addColumnIfMissing("tasks", "pausedByAgentId", "TEXT");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksPausedByAgentId ON tasks(pausedByAgentId)`);
});
}
} }
/** /**

View File

@@ -95,6 +95,7 @@ interface TaskRow {
missionId: string | null; missionId: string | null;
sliceId: string | null; sliceId: string | null;
assignedAgentId: string | null; assignedAgentId: string | null;
pausedByAgentId: string | null;
assigneeUserId: string | null; assigneeUserId: string | null;
nodeId: string | null; nodeId: string | null;
effectiveNodeId: string | null; effectiveNodeId: string | null;
@@ -728,6 +729,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
missionId: row.missionId || undefined, missionId: row.missionId || undefined,
sliceId: row.sliceId || undefined, sliceId: row.sliceId || undefined,
assignedAgentId: row.assignedAgentId || undefined, assignedAgentId: row.assignedAgentId || undefined,
pausedByAgentId: row.pausedByAgentId || undefined,
assigneeUserId: row.assigneeUserId || undefined, assigneeUserId: row.assigneeUserId || undefined,
nodeId: row.nodeId || undefined, nodeId: row.nodeId || undefined,
effectiveNodeId: row.effectiveNodeId || undefined, effectiveNodeId: row.effectiveNodeId || undefined,
@@ -965,7 +967,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments", "dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles", "breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt", "checkedOutBy", "checkedOutAt",
// `log` is fetched in slim mode so the server can aggregate // `log` is fetched in slim mode so the server can aggregate
@@ -1014,7 +1016,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "attachments", "steeringComments", "dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles", "breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt", "checkedOutBy", "checkedOutAt",
]; ];
@@ -1057,9 +1059,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments, dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
title = excluded.title, title = excluded.title,
@@ -1128,6 +1130,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
missionId = excluded.missionId, missionId = excluded.missionId,
sliceId = excluded.sliceId, sliceId = excluded.sliceId,
assignedAgentId = excluded.assignedAgentId, assignedAgentId = excluded.assignedAgentId,
pausedByAgentId = excluded.pausedByAgentId,
assigneeUserId = excluded.assigneeUserId, assigneeUserId = excluded.assigneeUserId,
nodeId = excluded.nodeId, nodeId = excluded.nodeId,
effectiveNodeId = excluded.effectiveNodeId, effectiveNodeId = excluded.effectiveNodeId,
@@ -1209,6 +1212,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.missionId ?? null, task.missionId ?? null,
task.sliceId ?? null, task.sliceId ?? null,
task.assignedAgentId ?? null, task.assignedAgentId ?? null,
task.pausedByAgentId ?? null,
task.assigneeUserId ?? null, task.assigneeUserId ?? null,
task.nodeId ?? null, task.nodeId ?? null,
task.effectiveNodeId ?? null, task.effectiveNodeId ?? null,
@@ -2652,6 +2656,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return limit >= 0 ? matches.slice(0, limit) : matches; return limit >= 0 ? matches.slice(0, limit) : matches;
} }
async getTasksByAssignedAgent(
agentId: string,
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
): Promise<Task[]> {
const whereClauses = ["assignedAgentId = ?"];
const params: Array<string | number> = [agentId];
if (options?.pausedOnly) {
whereClauses.push("paused = 1");
}
if (options?.excludeArchived) {
whereClauses.push('"column" != \'archived\'');
}
const selectClause = this.getTaskSelectClause(false);
const rows = this.db.prepare(`
SELECT ${selectClause} FROM tasks
WHERE ${whereClauses.join(" AND ")}
ORDER BY createdAt ASC
`).all(...params) as TaskRow[];
return rows.map((row) => this.rowToTask(row));
}
async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> { async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> {
const tasks = await this.listTasks({ slim: true }); const tasks = await this.listTasks({ slim: true });
if (tasks.length === 0) { if (tasks.length === 0) {
@@ -2917,7 +2946,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask( async updateTask(
id: string, id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext, runContext?: RunMutationContext,
): Promise<Task> { ): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
@@ -3001,6 +3030,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.assignedAgentId !== undefined) { } else if (updates.assignedAgentId !== undefined) {
task.assignedAgentId = updates.assignedAgentId; task.assignedAgentId = updates.assignedAgentId;
} }
if (updates.pausedByAgentId === null) {
task.pausedByAgentId = undefined;
} else if (updates.pausedByAgentId !== undefined) {
task.pausedByAgentId = updates.pausedByAgentId;
}
if (updates.assigneeUserId === null) { if (updates.assigneeUserId === null) {
task.assigneeUserId = undefined; task.assigneeUserId = undefined;
} else if (updates.assigneeUserId !== undefined) { } else if (updates.assigneeUserId !== undefined) {
@@ -3278,7 +3312,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Pause or unpause a task. Paused tasks are excluded from all automated * Pause or unpause a task. Paused tasks are excluded from all automated
* agent and scheduler interaction. Logs the action and emits `task:updated`. * agent and scheduler interaction. Logs the action and emits `task:updated`.
*/ */
async pauseTask(id: string, paused: boolean, runContext?: RunMutationContext): Promise<Task> { async pauseTask(
id: string,
paused: boolean,
runContext?: RunMutationContext,
agentOptions?: { pausedByAgentId?: string },
): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
const dir = this.taskDir(id); const dir = this.taskDir(id);
const task = await this.readTaskJson(dir); const task = await this.readTaskJson(dir);
@@ -3288,7 +3327,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.log = []; task.log = [];
} }
const previousPausedByAgentId = task.pausedByAgentId;
task.paused = paused || undefined; task.paused = paused || undefined;
if (paused && agentOptions?.pausedByAgentId) {
task.pausedByAgentId = agentOptions.pausedByAgentId;
}
if (!paused) {
task.pausedByAgentId = undefined;
}
// When pausing an in-progress/in-review task, set status so the UI can show the state. // When pausing an in-progress/in-review task, set status so the UI can show the state.
// When unpausing, clear the "paused" status. // When unpausing, clear the "paused" status.
if (task.column === "in-progress" || task.column === "in-review") { if (task.column === "in-progress" || task.column === "in-review") {
@@ -3298,7 +3344,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.updatedAt = now; task.updatedAt = now;
const logEntry: TaskLogEntry = { const logEntry: TaskLogEntry = {
timestamp: now, timestamp: now,
action: paused ? "Task paused" : "Task unpaused", action: paused
? (agentOptions?.pausedByAgentId
? `Task paused (agent ${agentOptions.pausedByAgentId} paused)`
: "Task paused")
: (previousPausedByAgentId
? `Task unpaused (agent ${previousPausedByAgentId} resumed)`
: "Task unpaused"),
}; };
if (runContext) { if (runContext) {
logEntry.runContext = runContext; logEntry.runContext = runContext;

View File

@@ -879,6 +879,8 @@ export interface Task {
blockedBy?: string; blockedBy?: string;
/** When true, all automated agent and scheduler interaction is suspended. */ /** When true, all automated agent and scheduler interaction is suspended. */
paused?: boolean; paused?: boolean;
/** When set, this task was paused because the agent with this ID was paused. Cleared when the agent resumes. Distinct from user-initiated pause. */
pausedByAgentId?: string;
/** Git branch name (or task ID) to use as the starting point when /** Git branch name (or task ID) to use as the starting point when
* creating this task's worktree. Set by the scheduler when a task's * creating this task's worktree. Set by the scheduler when a task's
* explicit dependency or `blockedBy` task is in-review with an * explicit dependency or `blockedBy` task is in-review with an

View File

@@ -221,7 +221,10 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
} }
}, [tasks, onMoveTask, addToast, confirm]); }, [tasks, onMoveTask, addToast, confirm]);
const pauseEligibleTasks = useMemo(() => tasks.filter((task) => !task.paused), [tasks]); const pauseEligibleTasks = useMemo(
() => tasks.filter((task) => !task.paused && !task.assignedAgentId),
[tasks],
);
const pauseEligibleCount = pauseEligibleTasks.length; const pauseEligibleCount = pauseEligibleTasks.length;
const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review"; const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review";
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo; const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
@@ -422,8 +425,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
{tasks.length === 0 {tasks.length === 0
? "No tasks in this column" ? "No tasks in this column"
: pauseEligibleCount === 0 : pauseEligibleCount === 0
? "All tasks are already paused" ? "No manually pausable tasks"
: `Pause ${pauseEligibleCount} active task${pauseEligibleCount === 1 ? "" : "s"}`} : `Pause ${pauseEligibleCount} active unassigned task${pauseEligibleCount === 1 ? "" : "s"}`}
</span> </span>
</button> </button>
<button <button

View File

@@ -1257,7 +1257,9 @@ export function ListView({
</span> </span>
)} )}
<span className="list-card-spacer" /> <span className="list-card-spacer" />
{isStuckState ? ( {isPaused && task.pausedByAgentId ? (
<span className="list-status-badge paused">paused by agent</span>
) : isStuckState ? (
<span className="list-status-badge stuck">Stuck</span> <span className="list-status-badge stuck">Stuck</span>
) : hasStatus ? ( ) : hasStatus ? (
<span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}> <span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}>
@@ -1451,7 +1453,9 @@ export function ListView({
)} )}
{visibleColumns.has("status") && ( {visibleColumns.has("status") && (
<td className="list-cell"> <td className="list-cell">
{isStuckState ? ( {isPaused && task.pausedByAgentId ? (
<span className="list-status-badge paused">paused by agent</span>
) : isStuckState ? (
<span className="list-status-badge stuck"> <span className="list-status-badge stuck">
Stuck Stuck
</span> </span>

View File

@@ -709,6 +709,7 @@ function TaskCardComponent({
const isFailed = task.status === "failed"; const isFailed = task.status === "failed";
const isPaused = task.paused === true; const isPaused = task.paused === true;
const pausedByAgent = Boolean(task.paused && task.pausedByAgentId);
const normalizedPriority = normalizeTaskPriorityValue(task.priority); const normalizedPriority = normalizeTaskPriorityValue(task.priority);
const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY; const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY;
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
@@ -1315,7 +1316,7 @@ function TaskCardComponent({
<span <span
className="card-status-badge paused" className="card-status-badge paused"
> >
paused {pausedByAgent ? "paused by agent" : "paused"}
</span> </span>
)} )}
{!isPaused && task.status && task.status !== "queued" && ( {!isPaused && task.status && task.status !== "queued" && (

View File

@@ -210,9 +210,9 @@
gap: var(--space-md); gap: var(--space-md);
margin: var(--space-md) 0 var(--space-lg); margin: var(--space-md) 0 var(--space-lg);
padding: 12px 14px; padding: 12px 14px;
background: rgba(218, 54, 51, 0.1); background: color-mix(in srgb, var(--color-error-dark) 10%, transparent);
border: 1px solid rgba(218, 54, 51, 0.3); border: 1px solid color-mix(in srgb, var(--color-error-dark) 30%, transparent);
border-radius: var(--radius); border-radius: var(--radius-md);
} }
.detail-error-icon { .detail-error-icon {
@@ -570,6 +570,11 @@
background: var(--surface-hover); background: var(--surface-hover);
} }
.detail-actions-menu-note {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.detail-move-btn__arrow { .detail-move-btn__arrow {
padding: var(--space-xs); padding: var(--space-xs);
@@ -599,11 +604,11 @@
} }
.detail-actions-menu-item-danger { .detail-actions-menu-item-danger {
color: var(--color-error, #dc3545); color: var(--color-error);
} }
.detail-actions-menu-item-danger:hover { .detail-actions-menu-item-danger:hover {
background: rgba(220, 53, 69, 0.08); background: color-mix(in srgb, var(--color-error) 8%, transparent);
} }
.detail-refine-overlay { .detail-refine-overlay {

View File

@@ -2521,7 +2521,7 @@ export function TaskDetailContent({
)} )}
{/* Pause/Unpause */} {/* Pause/Unpause */}
{task.column !== "done" && ( {task.column !== "done" && !task.assignedAgentId && (
<button <button
className="detail-actions-menu-item" className="detail-actions-menu-item"
role="menuitem" role="menuitem"
@@ -2530,6 +2530,14 @@ export function TaskDetailContent({
{task.paused ? "Unpause" : "Pause"} {task.paused ? "Unpause" : "Pause"}
</button> </button>
)} )}
{task.column !== "done" && task.paused && task.pausedByAgentId && (
<span
className="detail-actions-menu-item detail-actions-menu-note"
role="note"
>
Paused by agent
</span>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -324,7 +324,7 @@ describe("Column in-progress/in-review bulk actions", () => {
expect(screen.getByRole("menuitem", { name: /Move All to Todo/i })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: /Move All to Todo/i })).toBeTruthy();
}); });
it.each(["in-progress", "in-review"] as const)("Stop All pauses only non-paused tasks in %s", async (column) => { it.each(["in-progress", "in-review"] as const)("Stop All pauses only manually-pausable tasks in %s", async (column) => {
const user = userEvent.setup(); const user = userEvent.setup();
const onPauseTask = vi.fn().mockResolvedValue({} as Task); const onPauseTask = vi.fn().mockResolvedValue({} as Task);
@@ -335,7 +335,8 @@ describe("Column in-progress/in-review bulk actions", () => {
tasks={[ tasks={[
{ ...makeTask("FN-001"), column, paused: false }, { ...makeTask("FN-001"), column, paused: false },
{ ...makeTask("FN-002"), column, paused: true }, { ...makeTask("FN-002"), column, paused: true },
{ ...makeTask("FN-003"), column, paused: false }, { ...makeTask("FN-003"), column, paused: false, assignedAgentId: "agent-1" },
{ ...makeTask("FN-004"), column, paused: false },
]} ]}
onPauseTask={onPauseTask} onPauseTask={onPauseTask}
/>, />,
@@ -348,7 +349,8 @@ describe("Column in-progress/in-review bulk actions", () => {
expect(onPauseTask).toHaveBeenCalledTimes(2); expect(onPauseTask).toHaveBeenCalledTimes(2);
}); });
expect(onPauseTask).toHaveBeenCalledWith("FN-001"); expect(onPauseTask).toHaveBeenCalledWith("FN-001");
expect(onPauseTask).toHaveBeenCalledWith("FN-003"); expect(onPauseTask).toHaveBeenCalledWith("FN-004");
expect(onPauseTask).not.toHaveBeenCalledWith("FN-003");
expect(screen.queryByRole("menu")).toBeNull(); expect(screen.queryByRole("menu")).toBeNull();
expect(mockConfirm).toHaveBeenCalledWith({ expect(mockConfirm).toHaveBeenCalledWith({
title: "Stop All Tasks", title: "Stop All Tasks",
@@ -374,7 +376,7 @@ describe("Column in-progress/in-review bulk actions", () => {
expect(screen.getByText("No tasks in this column")).toBeTruthy(); expect(screen.getByText("No tasks in this column")).toBeTruthy();
}); });
it.each(["in-progress", "in-review"] as const)("disables Stop All when all %s tasks are already paused", async (column) => { it.each(["in-progress", "in-review"] as const)("disables Stop All when no %s tasks are manually pausable", async (column) => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -383,7 +385,7 @@ describe("Column in-progress/in-review bulk actions", () => {
column={column} column={column}
tasks={[ tasks={[
{ ...makeTask("FN-010"), column, paused: true }, { ...makeTask("FN-010"), column, paused: true },
{ ...makeTask("FN-011"), column, paused: true }, { ...makeTask("FN-011"), column, paused: false, assignedAgentId: "agent-1" },
]} ]}
onPauseTask={vi.fn().mockResolvedValue({} as Task)} onPauseTask={vi.fn().mockResolvedValue({} as Task)}
/>, />,
@@ -391,7 +393,7 @@ describe("Column in-progress/in-review bulk actions", () => {
await user.click(screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` })); await user.click(screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` }));
expect(screen.getByRole("menuitem", { name: /Stop All/i })).toBeDisabled(); expect(screen.getByRole("menuitem", { name: /Stop All/i })).toBeDisabled();
expect(screen.getByText("All tasks are already paused")).toBeTruthy(); expect(screen.getByText("No manually pausable tasks")).toBeTruthy();
}); });
it.each(["in-progress", "in-review"] as const)("Move All to Todo moves every task in %s", async (column) => { it.each(["in-progress", "in-review"] as const)("Move All to Todo moves every task in %s", async (column) => {

View File

@@ -210,6 +210,26 @@ describe("ListView", () => {
expect(standardRow.querySelector(".list-execution-mode-badge")).toBeNull(); expect(standardRow.querySelector(".list-execution-mode-badge")).toBeNull();
}); });
it("shows paused by agent status in table view", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "in-progress", paused: true, pausedByAgentId: "agent-1" }),
];
renderListView({ tasks });
expect(screen.getByText("paused by agent")).toBeDefined();
});
it("shows paused by agent status in mobile card view", () => {
const matchMediaSpy = mockMobileViewport();
const tasks = [
createMockTask({ id: "FN-001", column: "in-progress", paused: true, pausedByAgentId: "agent-1" }),
];
renderListView({ tasks });
expect(screen.getByText("paused by agent")).toBeDefined();
matchMediaSpy.mockRestore();
});
it("shows empty state when no tasks", () => { it("shows empty state when no tasks", () => {
renderListView({ tasks: [] }); renderListView({ tasks: [] });
expect(screen.getByText("No tasks yet")).toBeDefined(); expect(screen.getByText("No tasks yet")).toBeDefined();

View File

@@ -93,6 +93,23 @@ describe("TaskCard", () => {
expect(container.querySelector(".card-status-badge")).toBeNull(); expect(container.querySelector(".card-status-badge")).toBeNull();
}); });
it("shows paused by agent label when pausedByAgentId is set", () => {
render(
<TaskCard task={makeTask({ paused: true, pausedByAgentId: "agent-1" })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused by agent")).toBeDefined();
});
it("shows plain paused label when pausedByAgentId is not set", () => {
render(
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused")).toBeDefined();
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("renders fast-mode indicator only when executionMode is fast", () => { it("renders fast-mode indicator only when executionMode is fast", () => {
const { container, rerender } = render( const { container, rerender } = render(
<TaskCard task={makeTask({ executionMode: "fast" })} onOpenDetail={noop} addToast={noop} />, <TaskCard task={makeTask({ executionMode: "fast" })} onOpenDetail={noop} addToast={noop} />,

View File

@@ -4090,6 +4090,49 @@ describe("TaskDetailModal", () => {
expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy();
}); });
it("hides Pause/Unpause button for agent-assigned tasks", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "active" } as any);
render(
<TaskDetailModal
task={makeTask({ column: "triage", paused: true, assignedAgentId: "agent-1" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
expect(screen.queryByRole("menuitem", { name: "Pause" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Unpause" })).toBeNull();
});
it("shows paused-by-agent indicator for agent-paused tasks", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-1", name: "Agent 1", role: "executor", state: "paused" } as any);
render(
<TaskDetailModal
task={makeTask({ column: "triage", paused: true, assignedAgentId: "agent-1", pausedByAgentId: "agent-1" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
expect(screen.getByText("Paused by agent")).toBeTruthy();
});
it("does NOT render Actions dropdown for a non-paused, non-awaiting-approval, non-retryable triage task", () => { it("does NOT render Actions dropdown for a non-paused, non-awaiting-approval, non-retryable triage task", () => {
render( render(
<TaskDetailModal <TaskDetailModal

View File

@@ -61,6 +61,9 @@ class MockStore extends EventEmitter {
getRunAuditEvents = mockGetRunAuditEvents; getRunAuditEvents = mockGetRunAuditEvents;
getMutationsForRun = vi.fn().mockResolvedValue([]); getMutationsForRun = vi.fn().mockResolvedValue([]);
getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]); getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]);
getTasksByAssignedAgent = vi.fn().mockResolvedValue([]);
getTask = vi.fn().mockResolvedValue({ id: "FN-1" });
pauseTask = vi.fn().mockImplementation(async (id: string, paused: boolean) => ({ id, paused }));
getRootDir(): string { getRootDir(): string {
return "/tmp/fn-1059-test"; return "/tmp/fn-1059-test";
@@ -138,6 +141,70 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
}); });
}); });
describe("POST /api/tasks/:id/pause and /unpause", () => {
it("returns 409 for pause on agent-assigned task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ id: "FN-1", assignedAgentId: "agent-1" });
const response = await request(
app,
"POST",
"/api/tasks/FN-1/pause",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(409);
expect((response.body as any).error).toContain("Cannot manually pause/unpause task assigned to agent agent-1");
expect(store.pauseTask).not.toHaveBeenCalled();
});
it("allows pause for unassigned task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ id: "FN-2" });
const response = await request(
app,
"POST",
"/api/tasks/FN-2/pause",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(store.pauseTask).toHaveBeenCalledWith("FN-2", true);
});
it("returns 409 for unpause on agent-assigned task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ id: "FN-3", assignedAgentId: "agent-2" });
const response = await request(
app,
"POST",
"/api/tasks/FN-3/unpause",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(409);
expect((response.body as any).error).toContain("Cannot manually pause/unpause task assigned to agent agent-2");
expect(store.pauseTask).not.toHaveBeenCalled();
});
it("allows unpause for unassigned task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ id: "FN-4" });
const response = await request(
app,
"POST",
"/api/tasks/FN-4/unpause",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(store.pauseTask).toHaveBeenCalledWith("FN-4", false);
});
});
describe("POST /api/agents/:id/runs", () => { describe("POST /api/agents/:id/runs", () => {
it("returns 201 with run record (fallback behavior without HeartbeatMonitor)", async () => { it("returns 201 with run record (fallback behavior without HeartbeatMonitor)", async () => {
const mockRun = createMockRun(); const mockRun = createMockRun();
@@ -445,6 +512,55 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
}); });
}); });
it("pausing agent auto-pauses only non-paused assigned tasks", async () => {
(store.getTasksByAssignedAgent as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ id: "FN-1", paused: false },
{ id: "FN-2", paused: true },
{ id: "FN-3" },
]);
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "paused" });
const response = await request(
app,
"POST",
"/api/agents/agent-001/state",
JSON.stringify({ state: "paused" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
await vi.waitFor(() => {
expect(store.pauseTask).toHaveBeenCalledTimes(2);
});
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", true, undefined, { pausedByAgentId: "agent-001" });
expect(store.pauseTask).toHaveBeenCalledWith("FN-3", true, undefined, { pausedByAgentId: "agent-001" });
});
it("resuming agent only unpauses tasks paused by that same agent", async () => {
(store.getTasksByAssignedAgent as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ id: "FN-1", paused: true, pausedByAgentId: "agent-001" },
{ id: "FN-2", paused: true, pausedByAgentId: "agent-002" },
{ id: "FN-3", paused: true },
]);
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "paused" });
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" });
mockExecuteHeartbeat.mockResolvedValue(createMockRun({ id: "run-resume-1", status: "completed" }));
const response = await request(
app,
"POST",
"/api/agents/agent-001/state",
JSON.stringify({ state: "active" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
await vi.waitFor(() => {
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", false);
});
expect(store.pauseTask).toHaveBeenCalledTimes(1);
});
it("resuming to active triggers on-demand heartbeat exactly once", async () => { it("resuming to active triggers on-demand heartbeat exactly once", async () => {
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "paused" }); mockGetAgent.mockResolvedValue({ id: "agent-001", state: "paused" });
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" }); mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" });
@@ -475,6 +591,26 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
}); });
}); });
it("terminated agent also unpauses tasks paused by that agent", async () => {
(store.getTasksByAssignedAgent as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ id: "FN-9", paused: true, pausedByAgentId: "agent-001" },
]);
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "terminated" });
const response = await request(
app,
"POST",
"/api/agents/agent-001/state",
JSON.stringify({ state: "terminated" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
await vi.waitFor(() => {
expect(store.pauseTask).toHaveBeenCalledWith("FN-9", false);
});
});
it("resuming to active does not auto-trigger heartbeat when disabled", async () => { it("resuming to active does not auto-trigger heartbeat when disabled", async () => {
mockGetAgent.mockResolvedValue({ mockGetAgent.mockResolvedValue({
id: "agent-001", id: "agent-001",

View File

@@ -1623,6 +1623,7 @@ describe("Pause/Unpause endpoints", () => {
beforeEach(() => { beforeEach(() => {
store = createMockStore({ store = createMockStore({
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
pauseTask: vi.fn().mockResolvedValue({ id: "FN-001", paused: true }), pauseTask: vi.fn().mockResolvedValue({ id: "FN-001", paused: true }),
}); });
}); });
@@ -1642,7 +1643,7 @@ describe("Pause/Unpause endpoints", () => {
}); });
it("POST /tasks/:id/pause — returns 500 on error", async () => { it("POST /tasks/:id/pause — returns 500 on error", async () => {
(store.pauseTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found")); (store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause"); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
expect(res.status).toBe(500); expect(res.status).toBe(500);
expect(res.body.error).toBe("not found"); expect(res.body.error).toBe("not found");

View File

@@ -432,6 +432,35 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
} }
} }
if (nextState === "paused") {
const assignedTasks = await scopedStore.getTasksByAssignedAgent(agentId, { excludeArchived: true });
const toPause = assignedTasks.filter((task) => task.paused !== true);
const results = await Promise.allSettled(
toPause.map((task) => scopedStore.pauseTask(task.id, true, undefined, { pausedByAgentId: agentId })),
);
results.forEach((result, index) => {
if (result.status === "rejected") {
console.error(`[agent-state] failed to pause assigned task ${toPause[index]?.id} for ${agentId}:`, result.reason);
}
});
}
if (nextState === "active" || nextState === "terminated") {
const pausedTasks = await scopedStore.getTasksByAssignedAgent(agentId, {
pausedOnly: true,
excludeArchived: true,
});
const toUnpause = pausedTasks.filter((task) => task.pausedByAgentId === agentId);
const results = await Promise.allSettled(
toUnpause.map((task) => scopedStore.pauseTask(task.id, false)),
);
results.forEach((result, index) => {
if (result.status === "rejected") {
console.error(`[agent-state] failed to unpause assigned task ${toUnpause[index]?.id} for ${agentId}:`, result.reason);
}
});
}
const isHeartbeatEnabled = currentAgent.runtimeConfig?.enabled !== false; const isHeartbeatEnabled = currentAgent.runtimeConfig?.enabled !== false;
if (nextState === "active" && isHeartbeatEnabled && projectHeartbeatMonitor) { if (nextState === "active" && isHeartbeatEnabled && projectHeartbeatMonitor) {
await projectHeartbeatMonitor.executeHeartbeat({ await projectHeartbeatMonitor.executeHeartbeat({

View File

@@ -8,7 +8,7 @@ import {
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
validateNodeOverrideChange, validateNodeOverrideChange,
} from "@fusion/core"; } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
interface TaskWorkflowRouteDeps { interface TaskWorkflowRouteDeps {
@@ -824,8 +824,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.post("/tasks/:id/pause", async (req, res) => { router.post("/tasks/:id/pause", async (req, res) => {
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.pauseTask(req.params.id, true); const task = await scopedStore.getTask(req.params.id);
res.json(task); if (task.assignedAgentId) {
throw conflict(`Cannot manually pause/unpause task assigned to agent ${task.assignedAgentId}. Use agent pause controls instead.`);
}
const updated = await scopedStore.pauseTask(req.params.id, true);
res.json(updated);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;
@@ -838,8 +842,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.post("/tasks/:id/unpause", async (req, res) => { router.post("/tasks/:id/unpause", async (req, res) => {
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.pauseTask(req.params.id, false); const task = await scopedStore.getTask(req.params.id);
res.json(task); if (task.assignedAgentId) {
throw conflict(`Cannot manually pause/unpause task assigned to agent ${task.assignedAgentId}. Use agent pause controls instead.`);
}
const updated = await scopedStore.pauseTask(req.params.id, false);
res.json(updated);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;