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:
5
.changeset/fn-3350-agent-pause-task-state.md
Normal file
5
.changeset/fn-3350-agent-pause-task-state.md
Normal 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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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} />,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user