Sessions have been agent-scoped since v34, but the schema kept four
dead task-scoped columns that every session code path had to pretend
were meaningful:
- tasks.active_session_id (mirrored agents.session_id)
- tasks.session_json (superseded by task_sessions.session_params_json)
- tasks.session_display_id (superseded by task_sessions.session_display_id)
- task_sessions.task_id (always empty-string on new rows)
v41 drops all four via SQLite table rebuild — the only safe path when
multiple columns need to go in one step and indexes / FK directions
need to be preserved. `PRAGMA foreign_keys = OFF` spans the swap
window so cascade-delete does not fire mid-rebuild. The migration is
idempotent and also backfills the optional task columns
(task_type / requires_user_review / retry_count / last_failure_at /
started_at / completed_at / cancelled_at / checkout_run_id /
execution_locked_at) before rebuilding so it works whether or not
`ensureOptionalColumns` has run yet on this install.
Along with the schema change, every code path that read or wrote
those columns has been rewired to the agent-scoped equivalents:
- task-sessions-crud.ts: deleted updateTaskSession,
getTaskSession, getActiveTaskSession, updateTasksActiveSessionForAgent;
added updateSessionParams(sessionId, ...) for the worker's
finalizer. ensureAgentSession and createNewSession no longer touch
task_id.
- database-base.ts: facade drops the now-unused pass-throughs.
- snapshot.ts / task-queries.ts / tasks-status.ts / entity-crud.ts:
SELECT projections no longer pull session_json / session_display_id
/ active_session_id from tasks.
- tasks-crud.ts (TaskRow) and shared/types/records-core.ts
(TaskRecord): sessionJson / sessionDisplayId fields removed.
- TaskSessionRecord: taskId field removed.
- turn-context.ts::resolveSession: no more
updateTasksActiveSessionForAgent mirror; sessionParams read directly
from the current session via getSessionById.
- run-finished-helpers.ts::finalizeRun: stamps sessionParams via
updateSessionParams on the agent's current session, resolved through
agents.session_id.
- session-management-service.ts: rotateSession stops calling
updateTasksActiveSessionForAgent.
Test suite updated to use the new session-id API. Existing tests used
`getActiveTaskSession(taskId)` and `createNewSession({ taskId, ...})`
as probes; they now use `getSessionById(sessionId)` and the taskId-less
createNewSession input.
Tests: 603/603 green. Typecheck clean. Migration tested on fresh DB
(runs v1→v41 cleanly) and idempotent re-apply (detects absent dead
columns, skips).
249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
import type { DatabaseSync } from "node:sqlite";
|
|
import {
|
|
type ReadAll,
|
|
type Transaction,
|
|
ensureId,
|
|
normalizeOptionalId,
|
|
nowIso,
|
|
} from "./helpers";
|
|
|
|
/**
|
|
* Row shape returned by listTasksByStatuses — used by the
|
|
* GET /api/companies/:companyId/issues route. Mirrors the historical
|
|
* inline-SQL projection (no taskType column) so the HTTP response shape
|
|
* stays stable for agent CLI clients.
|
|
*/
|
|
export type TaskListByStatusesRow = {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
goalId: string | null;
|
|
parentId: string | null;
|
|
title: string;
|
|
description: string;
|
|
assigneeAgentId: string | null;
|
|
workspaceId: string | null;
|
|
status: string;
|
|
priority: string;
|
|
activeRunId: string | null;
|
|
costUsd: number | null;
|
|
requiresUserReview: boolean;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
cancelledAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export function buildTaskCrudMethods(
|
|
getDb: () => DatabaseSync,
|
|
readAll: ReadAll,
|
|
transaction: Transaction,
|
|
_deps: {
|
|
addComment: (input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) => void;
|
|
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
|
},
|
|
) {
|
|
return {
|
|
saveTask(input: {
|
|
id?: string | null;
|
|
companyId: string;
|
|
projectId?: string | null;
|
|
goalId?: string | null;
|
|
parentId?: string | null;
|
|
title: string;
|
|
description: string;
|
|
assigneeAgentId?: string | null;
|
|
workspaceId?: string | null;
|
|
priority: string;
|
|
status: string;
|
|
}): string {
|
|
const id = ensureId(input.id ?? null);
|
|
const ts = nowIso();
|
|
const assigneeAgentId = normalizeOptionalId(input.assigneeAgentId);
|
|
if (input.status === "in_progress" && !assigneeAgentId) {
|
|
throw new Error("Tasks in progress require an assignee.");
|
|
}
|
|
const existing = getDb()
|
|
.prepare("select id from tasks where id = ? and company_id = ?")
|
|
.get(id, input.companyId) as { id: string } | undefined;
|
|
if (existing) {
|
|
getDb()
|
|
.prepare(
|
|
"update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
|
)
|
|
.run(
|
|
normalizeOptionalId(input.projectId),
|
|
normalizeOptionalId(input.goalId),
|
|
normalizeOptionalId(input.parentId),
|
|
input.title,
|
|
input.description,
|
|
assigneeAgentId,
|
|
normalizeOptionalId(input.workspaceId),
|
|
input.priority,
|
|
input.status,
|
|
ts,
|
|
id,
|
|
input.companyId,
|
|
);
|
|
} else {
|
|
getDb()
|
|
.prepare(
|
|
"insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(
|
|
id,
|
|
input.companyId,
|
|
normalizeOptionalId(input.projectId),
|
|
normalizeOptionalId(input.goalId),
|
|
normalizeOptionalId(input.parentId),
|
|
input.title,
|
|
input.description,
|
|
assigneeAgentId,
|
|
normalizeOptionalId(input.workspaceId),
|
|
input.status,
|
|
input.priority,
|
|
ts,
|
|
ts,
|
|
);
|
|
}
|
|
return id;
|
|
},
|
|
|
|
deleteTask(id: string, companyId: string): void {
|
|
transaction(() => {
|
|
const activeTaskRuns = getDb()
|
|
.prepare(
|
|
"select id, task_id as taskId, workspace_id as workspaceId from runs where task_id = ? and company_id = ? and status in ('queued', 'running')",
|
|
)
|
|
.all(id, companyId) as Array<{
|
|
id: string;
|
|
taskId: string;
|
|
workspaceId: string | null;
|
|
}>;
|
|
for (const run of activeTaskRuns) {
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"update runs set status = 'cancelled', summary = ?, error_message = null, exit_code = null, signal = null, model = null, session_display_id = null, cost_usd = null, unattributed_cost = 1, finished_at = ?, updated_at = ? where id = ?",
|
|
)
|
|
.run("Task deleted.", ts, ts, run.id);
|
|
getDb()
|
|
.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?")
|
|
.run(ts, run.taskId);
|
|
getDb()
|
|
.prepare(
|
|
"UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?",
|
|
)
|
|
.run(ts, run.taskId, run.id);
|
|
if (run.workspaceId) {
|
|
getDb()
|
|
.prepare(
|
|
`update workspaces
|
|
set lock_holder_run_id = null, lock_acquired_at = null, state = 'ready', updated_at = ?
|
|
where id = ? and lock_holder_run_id = ?`,
|
|
)
|
|
.run(ts, run.workspaceId, run.id);
|
|
}
|
|
}
|
|
getDb()
|
|
.prepare(
|
|
"update tasks set parent_id = null where parent_id = ? and company_id = ?",
|
|
)
|
|
.run(id, companyId);
|
|
getDb()
|
|
.prepare("delete from approvals where related_task_id = ? and company_id = ?")
|
|
.run(id, companyId);
|
|
getDb()
|
|
.prepare("delete from task_comments where task_id = ? and company_id = ?")
|
|
.run(id, companyId);
|
|
getDb()
|
|
.prepare("delete from tasks where id = ? and company_id = ?")
|
|
.run(id, companyId);
|
|
});
|
|
},
|
|
|
|
getRecentRunsForTask(
|
|
taskId: string,
|
|
limit: number,
|
|
): Array<{
|
|
id: string;
|
|
status: string;
|
|
summary: string | null;
|
|
errorMessage: string | null;
|
|
finishedAt: string | null;
|
|
connectorId: string;
|
|
}> {
|
|
return readAll(
|
|
`SELECT id, status, summary, error_message as errorMessage,
|
|
finished_at as finishedAt, connector_id as connectorId
|
|
FROM runs WHERE task_id = ? ORDER BY finished_at DESC, rowid DESC LIMIT ?`,
|
|
taskId,
|
|
limit,
|
|
);
|
|
},
|
|
|
|
getRecentActivityForTask(
|
|
taskId: string,
|
|
limit: number,
|
|
): Array<{
|
|
action: string;
|
|
detail: string;
|
|
createdAt: string;
|
|
}> {
|
|
return readAll(
|
|
`SELECT action, detail, created_at as createdAt
|
|
FROM activity WHERE entity_type = 'task' AND entity_id = ?
|
|
ORDER BY created_at DESC LIMIT ?`,
|
|
taskId,
|
|
limit,
|
|
);
|
|
},
|
|
|
|
setTaskType(taskId: string, taskType: string): void {
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare("update tasks set task_type = ?, updated_at = ? where id = ?")
|
|
.run(taskType, ts, taskId);
|
|
},
|
|
|
|
getTaskRetryCount(taskId: string): number {
|
|
const row = getDb()
|
|
.prepare("select retry_count from tasks where id = ?")
|
|
.get(taskId) as { retry_count: number } | undefined;
|
|
return row?.retry_count ?? 0;
|
|
},
|
|
|
|
incrementTaskRetryCount(taskId: string): void {
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"update tasks set retry_count = retry_count + 1, last_failure_at = ?, updated_at = ? where id = ?",
|
|
)
|
|
.run(ts, ts, taskId);
|
|
},
|
|
|
|
resetTaskRetryCount(taskId: string): void {
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?",
|
|
)
|
|
.run(ts, taskId);
|
|
},
|
|
|
|
/**
|
|
* Returns `task_type` for a single task id, or null when the task is
|
|
* missing. Used by checkpoint-engine to branch on task type without
|
|
* loading the full row.
|
|
*/
|
|
getTaskType(taskId: string): string | null {
|
|
const row = getDb()
|
|
.prepare("SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1")
|
|
.get(taskId) as { taskType: string | null } | undefined;
|
|
return row?.taskType ?? null;
|
|
},
|
|
|
|
};
|
|
}
|