diff --git a/src/main/database/entity-crud.ts b/src/main/database/entity-crud.ts index 7451831d..9320a93b 100644 --- a/src/main/database/entity-crud.ts +++ b/src/main/database/entity-crud.ts @@ -1,7 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { AgentRecord, ConnectorId, TaskRecord } from "@shared/types"; import type { ReadAll, Transaction } from "./helpers"; -import { ensureId, normalizeOptionalId, nowIso } from "./helpers"; +import { activeRunIdSql, ensureId, normalizeOptionalId, nowIso } from "./helpers"; /** * Goal row shape returned by listGoals / getGoalForUpdate. Mirrors the @@ -53,7 +53,7 @@ export function buildEntityCrudMethods( getTask(id: string): TaskRecord { const row = getDb() .prepare( - "select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, (select l.run_id from task_leases l where l.task_id = tasks.id) as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, requires_filesystem as requiresFilesystem, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?", + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, requires_filesystem as requiresFilesystem, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?`, ) .get(id) as TaskRecord | undefined; if (!row) throw new Error(`Task not found: ${id}`); diff --git a/src/main/database/helpers.ts b/src/main/database/helpers.ts index 3a4ddfc4..80f4c21c 100644 --- a/src/main/database/helpers.ts +++ b/src/main/database/helpers.ts @@ -42,3 +42,12 @@ export function taskPriorityOrderSql(columnName: string): string { else 0 end`; } + +export function activeRunIdSql(taskTableAlias: string): string { + return `(select l.run_id + from task_leases l + join runs r on r.id = l.run_id + where l.task_id = ${taskTableAlias}.id + and r.status in ('queued', 'running') + limit 1)`; +} diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts index 7d2d3810..6dccf599 100644 --- a/src/main/database/snapshot.ts +++ b/src/main/database/snapshot.ts @@ -15,7 +15,7 @@ import type { TaskRecord, WorkspaceRecord, } from "@shared/types"; -import { BOARD_READER_ID, type ReadAll, type SqliteRow, toBoolean } from "./helpers"; +import { activeRunIdSql, BOARD_READER_ID, type ReadAll, type SqliteRow, toBoolean } from "./helpers"; export function buildSnapshotMethods( getDb: () => DatabaseSync, @@ -83,7 +83,7 @@ export function buildSnapshotMethods( ...companyArgs, ), tasks: readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, (select l.run_id from task_leases l where l.task_id = tasks.id) as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, requires_filesystem as requiresFilesystem, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc limit 2000`, + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, requires_filesystem as requiresFilesystem, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc limit 2000`, ...companyArgs, ).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), requiresFilesystem: toBoolean(record.requiresFilesystem), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[], approvals: readAll( diff --git a/src/main/database/task-queries.ts b/src/main/database/task-queries.ts index 52f6aeab..a1a6838c 100644 --- a/src/main/database/task-queries.ts +++ b/src/main/database/task-queries.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import type { TaskRecord } from "@shared/types"; -import { type ReadAll, type Transaction, taskPriorityOrderSql } from "./helpers"; +import { activeRunIdSql, type ReadAll, type Transaction, taskPriorityOrderSql } from "./helpers"; import type { TaskListByStatusesRow } from "./tasks-crud"; /** @@ -25,7 +25,7 @@ export function buildTaskQueryMethods( ): TaskRecord[] { const placeholders = statuses.map(() => "?").join(","); return readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, (select l.run_id from task_leases l where l.task_id = tasks.id) as activeRunId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and assignee_agent_id = ? and status in (${placeholders}) order by @@ -91,7 +91,7 @@ export function buildTaskQueryMethods( ): TaskListByStatusesRow[] { const placeholders = statuses.map(() => "?").join(", "); return readAll( - `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, (select l.run_id from task_leases l where l.task_id = tasks.id) as activeRunId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`, + `select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, ${activeRunIdSql("tasks")} as activeRunId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`, companyId, ...statuses, ).map((t) => ({ diff --git a/src/main/database/tasks-status.ts b/src/main/database/tasks-status.ts index 468c8ad8..66855d21 100644 --- a/src/main/database/tasks-status.ts +++ b/src/main/database/tasks-status.ts @@ -3,6 +3,7 @@ import type { TaskRecord } from "@shared/types"; import { type ReadAll, type Transaction, + activeRunIdSql, nowIso, taskPriorityOrderSql, } from "./helpers"; @@ -82,7 +83,7 @@ export function buildTaskStatusMethods( t.parent_id as parentId, t.title, t.description, t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId, t.status, t.priority, - (select l.run_id from task_leases l where l.task_id = t.id) as activeRunId, + ${activeRunIdSql("t")} as activeRunId, t.cost_usd as costUsd, t.created_at as createdAt, t.updated_at as updatedAt from tasks t diff --git a/tests/unit/active-run-projection.test.ts b/tests/unit/active-run-projection.test.ts new file mode 100644 index 00000000..b2ce7759 --- /dev/null +++ b/tests/unit/active-run-projection.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppDatabase } from "../../src/main/database"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +type RawDb = { + db: { + prepare: (sql: string) => { + run: (...args: unknown[]) => void; + }; + }; +}; + +function createCompany(db: AppDatabase): string { + return db.core.company.saveCompany({ + name: "Projection Co", + description: "Active run projection contract", + status: "active", + }); +} + +function createWorkspace(db: AppDatabase, companyId: string, localPath: string): string { + return db.core.workspaces.saveWorkspace({ + companyId, + name: "Primary", + localPath, + repoUrl: "", + repoRef: "", + isPrimary: true, + }); +} + +function createAgent(db: AppDatabase, companyId: string, workspaceId: string): string { + return db.core.agents.saveAgent({ + companyId, + name: "Projection Agent", + role: "Engineer", + title: "Engineer", + status: "idle", + reportsTo: null, + connectorId: "openai-codex", + workspaceId, + model: "gpt-5.4", + capabilities: "Tests active run projections", + budgetMonthlyUsd: 100, + }); +} + +describe("active run projection", () => { + let tempDir: string; + let db: AppDatabase; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcompany-active-run-projection-")); + db = new AppDatabase(tempDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("ignores terminal run leases when projecting activeRunId", () => { + const companyId = createCompany(db); + const workspaceId = createWorkspace(db, companyId, tempDir); + const agentId = createAgent(db, companyId, workspaceId); + const taskId = db.core.tasks.saveTask({ + companyId, + title: "Recoverable task", + description: "A stale terminal lease must not hide this task from recovery.", + assigneeAgentId: agentId, + workspaceId, + priority: "high", + status: "todo", + }); + const run = db.core.runs.createRun({ + companyId, + taskId, + agentId, + workspaceId: null, + connectorId: "openai-codex", + }); + db.core.runs.finishRun({ + runId: run.id, + status: "succeeded", + summary: "Done", + errorMessage: null, + exitCode: 0, + signal: null, + model: null, + unattributedCost: true, + }); + const now = new Date().toISOString(); + (db as unknown as RawDb).db + .prepare( + `insert into task_leases ( + task_id, company_id, run_id, agent_id, acquired_at, renewed_at, expires_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(taskId, companyId, run.id, agentId, now, now, now, now, now); + + expect(db.core.entity.getTask(taskId).activeRunId).toBeNull(); + expect(db.core.tasks.listAgentTasks(agentId, companyId, ["todo"])[0]?.activeRunId).toBeNull(); + expect(db.projection.snapshot.listSnapshot({ companyId }).tasks.find((task) => task.id === taskId)?.activeRunId).toBeNull(); + }); +});