refactor(schema): make runs.task_id nullable, delete heartbeat workaround

The NOT NULL constraint forced heartbeat runs to synthesize a fake task
row (`[Heartbeat] X — Check for work`) so the FK could resolve. Three
layers of code — acquireHeartbeatTask, findReusableHeartbeatTask,
cleanupDuplicateHeartbeatTasks, plus the is_heartbeat flag column + UI
filter — existed to paper over one bad constraint. Remove the
constraint and the whole workaround vanishes.

SQLite table rebuild with data preservation. Live production DB, no
backup — migration is atomic, idempotent, and verified before DROP.

## Migration v35 (rebuild runs with nullable task_id)

Uses `fn:` form so it can:
- Snapshot `COUNT(*) FROM runs` before copy
- Walk `PRAGMA table_info(runs)` to detect drift columns (v34 declared
  a column set; any additive ALTER since v14 shows up here)
- Assert `COUNT(*) FROM runs_new == beforeCount` before `DROP TABLE runs`
- `PRAGMA defer_foreign_keys = ON` so run_events(run_id) FK defers to
  commit time, by which runs has the same id set under the new name
- Recreate all 6 indexes on the new table

If the assertion fails, the migration throws inside the existing
`transaction()` wrapper. SQLite rolls back the whole migration, old
`runs` table is untouched, `schema_migrations` never records v35. Next
app start retries cleanly.

## Migration v36 (drop tasks.is_heartbeat + synthetic rows)

Runs now carry `task_id = NULL` for heartbeat dispatches. The
`is_heartbeat` flag is dead data going forward.

1. `UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)`
2. `DELETE FROM tasks WHERE is_heartbeat = 1` (synthetic rows, not real work)
3. Rebuild `tasks` without the `is_heartbeat` column (same defer-FK +
   row-count-assert pattern as v35)

## Code cleanup

- `RunRecord.taskId: string | null`; `createRun` / `cancelRunForDeletion`
  / `finishRun` guard every `UPDATE tasks` path with a null-check.
- `TaskRecord.isHeartbeat` removed; every SELECT / filter / UI reference
  dropped.
- Delete `acquireHeartbeatTask` / `findReusableHeartbeatTask` /
  `cleanupDuplicateHeartbeatTasks` — dead after the constraint lifts.
- Delete `src/main/orchestration/heartbeat-task.ts` (pure workaround
  constant file).
- `heartbeat.ts` creates runs with `taskId: null` when the agent has no
  assigned work. Turn context already supports nullable task
  (heartbeatFallbackSection); prompt surface unchanged.
- `validateTaskCreation` drops the `isHeartbeat` parameter — user-
  authored + automation-authored tasks are never heartbeat.
- Renderer: `run.taskId ? taskMap.get(...) : undefined` in Dashboard /
  RunCenter / RunDetail / RunQueue.
- Delete `tests/unit/heartbeat-coalescing.test.ts` (12 tests that
  exercised the deleted DB methods).
- Drop 1 isHeartbeat-bypass test from `task-quality-gates.test.ts`.

## Grep verification

- `isHeartbeatTaskTitle src/` → 0
- `acquireHeartbeatTask src/` → migration comment only
- `is_heartbeat src/` → schema.ts migration history only
- `isHeartbeat src/` → 0

Tests: 578/578 (was 591 — delta is 13 tests that tested deleted code).
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 16:16:08 -04:00
1 parent 92795f7bfb
commit 7e1d4199ee
44 files changed
+385 -674

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
/**
* Heartbeat task title conventions.
*
* Heartbeat tasks are synthesized by the orchestrator when an agent wakes with
* no actionable work. The `tasks.is_heartbeat` column is the authoritative
* flag; the `[Heartbeat]` title prefix remains only for human-readable display
* so existing runs, logs, and UI affordances stay stable.
*/
export const HEARTBEAT_TASK_TITLE_PREFIX = "[Heartbeat]";
+7 -28
View File
@@ -3,11 +3,10 @@ import type { AppDatabase } from "../database";
import type { AgentRecord, ConnectorRecord } from "@shared/types";
import type { OrchestrationDependencies } from "./types";
import type { RunDispatchParams } from "./turn-context";
import { HEARTBEAT_TASK_TITLE_PREFIX } from "./heartbeat-task";
export function createHeartbeat(
deps: OrchestrationDependencies,
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null }, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null } | null, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
getRunEligibilityFailure: (agent: AgentRecord, connector: ConnectorRecord, projectId?: string | null) => string | null,
dispatchRunToWorker: (params: RunDispatchParams) => void,
hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean,
@@ -149,42 +148,22 @@ export function createHeartbeat(
return null;
}
const taskTitle = isCeo
? `${HEARTBEAT_TASK_TITLE_PREFIX} ${agent.name} — Strategic planning & task delegation`
: isManager
? `${HEARTBEAT_TASK_TITLE_PREFIX} ${agent.name} — Review team & delegate work`
: `${HEARTBEAT_TASK_TITLE_PREFIX} ${agent.name} — Check for work`;
const taskDescription = isCeo
? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners."
: isManager
? "Review your department's goals and your team's workload. Create tasks for your direct reports if they have capacity. Review any work submitted for your approval. Ensure your team is productive and unblocked."
: `Automated heartbeat run for ${agent.name}. Review assigned tasks, check goals, and take action as needed.`;
task = db.acquireHeartbeatTask({
agentId,
companyId,
runId,
workspaceId: agent.workspaceId ?? null,
title: taskTitle,
description: taskDescription,
priority: isCeo ? "high" : "medium",
});
if (!task) {
return null;
}
logger.info(`[heartbeat] Acquired heartbeat task for ${agent.name}: ${task.title}`);
// No task — this is a true heartbeat run. The run row carries
// task_id = NULL; workspace falls back to agent/project/company.
task = null;
logger.info(`[heartbeat] Starting taskless heartbeat run for ${agent.name}`);
}
const workspaceResolution = resolveWorkspace(task, agent, companyId);
const workspace = workspaceResolution.workspace;
logger.info(
`[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`,
`[heartbeat] Workspace resolution for ${agent.name}: task.workspaceId=${task?.workspaceId ?? "null"}, agent.workspaceId=${agent.workspaceId ?? "null"}, projectId=${task?.projectId ?? "null"}, source=${workspaceResolution.source}, mode=${workspaceResolution.lockMode}, resolved=${workspace?.name ?? "NONE"}, localPath=${workspace?.localPath ?? "NONE"}`,
);
const run = db.createRun({
id: runId,
companyId,
taskId: task.id,
taskId: task?.id ?? null,
agentId: agent.id,
workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null,
connectorId: connector.id,
+7 -5
View File
@@ -11,7 +11,7 @@ import type { WorkspaceLockMode } from "./types";
*/
export function resolveWorkspaceWithLockMode(
db: AppDatabase,
task: { workspaceId: string | null; projectId?: string | null },
task: { workspaceId: string | null; projectId?: string | null } | null,
agent: { workspaceId: string | null },
companyId: string,
): {
@@ -19,9 +19,11 @@ export function resolveWorkspaceWithLockMode(
lockMode: WorkspaceLockMode;
source: "task" | "agent" | "project" | "company_fallback" | "none";
} {
const taskWorkspace = db.getWorkspace(task.workspaceId);
if (taskWorkspace) {
return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" };
if (task) {
const taskWorkspace = db.getWorkspace(task.workspaceId);
if (taskWorkspace) {
return { workspace: taskWorkspace, lockMode: "exclusive", source: "task" };
}
}
const agentWorkspace = db.getWorkspace(agent.workspaceId);
@@ -29,7 +31,7 @@ export function resolveWorkspaceWithLockMode(
return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" };
}
const projectWorkspace = db.getProjectPrimaryWorkspace(task.projectId ?? null, companyId);
const projectWorkspace = db.getProjectPrimaryWorkspace(task?.projectId ?? null, companyId);
if (projectWorkspace) {
return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" };
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function createRunDispatch(deps: OrchestrationDependencies) {
} = deps;
function resolveWorkspace(
task: { workspaceId: string | null; projectId?: string | null },
task: { workspaceId: string | null; projectId?: string | null } | null,
agent: { workspaceId: string | null },
companyId: string,
) {
+5 -5
View File
@@ -7,7 +7,7 @@ import type { RunDispatchParams } from "./turn-context";
export function createTaskRunner(
deps: OrchestrationDependencies,
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null }, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null } | null, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
getRunEligibilityFailure: (agent: AgentRecord, connector: ConnectorRecord, projectId?: string | null) => string | null,
dispatchRunToWorker: (params: RunDispatchParams) => void,
hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean,
@@ -167,10 +167,10 @@ export function createTaskRunner(
}
try {
const task = db.getTask(run.taskId);
const task = run.taskId ? db.getTask(run.taskId) : null;
const agent = db.getAgent(run.agentId);
const connector = db.getConnector(run.connectorId);
const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null);
const eligibilityFailure = getRunEligibilityFailure(agent, connector, task?.projectId ?? null);
if (eligibilityFailure) {
if (eligibilityFailure === "CONNECTOR_MODEL_NOT_ALLOWED") {
db.finishRun({
@@ -190,13 +190,13 @@ export function createTaskRunner(
action: "run.recovery_blocked",
entityType: "run",
entityId: run.id,
detail: `Recovered queued run blocked by connector/model policy for task "${task.title}"`,
detail: `Recovered queued run blocked by connector/model policy for task "${task?.title ?? "(heartbeat)"}"`,
});
emitEvent({ type: "run-updated", runId: run.id, action: "completed" });
}
continue;
}
const workspaceResolution = resolveWorkspace(task, agent, run.companyId);
const workspaceResolution = resolveWorkspace(task ?? null, agent, run.companyId);
dispatchRunToWorker({
runId: run.id,
companyId: run.companyId,
+18 -6
View File
@@ -18,7 +18,10 @@ export interface RunDispatchParams {
runId: string;
companyId: string;
agent: ReturnType<AppDatabase["getAgent"]>;
task: ReturnType<AppDatabase["getTask"]>;
// `task` is null for heartbeat runs (agent wake-ups with no assigned task).
// Downstream code (prompt builder, worker payload, session pointer)
// branches on this — when null, skip task-scoped state.
task: ReturnType<AppDatabase["getTask"]> | null;
connector: ReturnType<AppDatabase["getConnector"]>;
workspace: ReturnType<AppDatabase["getWorkspace"]>;
workspaceMode: WorkspaceLockMode;
@@ -145,6 +148,15 @@ function resolveSession(
// that read per-task active_session_id keep working.
db.updateTasksActiveSessionForAgent(agent.id, agentSessionId);
// Heartbeat runs have no task; reuse the agent-scoped session with no
// task-specific active session lookup.
if (!task) {
return {
taskSession: { id: agentSessionId, sessionParams: null, sessionDisplayId: null },
sessionHandoff,
};
}
const activeSession = db.getActiveTaskSession(task.id);
let taskSession: {
id: string | null;
@@ -267,12 +279,12 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
const { db } = deps;
const { env, apiUrl, apiKey } = assembleApiEnv(db, connector, companyId, agent.id, runId, trigger, deps);
env.AGENT_COMPANY_TASK_ID = task.id;
env.AGENT_COMPANY_TASK_ID = task?.id ?? "";
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot);
const initialHandoff = resolveSessionHandoff(task.id, deps);
const initialHandoff = task ? resolveSessionHandoff(task.id, deps) : null;
const { taskSession, sessionHandoff } = resolveSession(db, agent, task, companyId, connector.id, initialHandoff, deps);
const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps);
@@ -331,9 +343,9 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
return {
runId,
companyId,
taskId: task.id,
taskTitle: task.title,
taskDescription: task.description,
taskId: task?.id ?? null,
taskTitle: task?.title ?? "",
taskDescription: task?.description ?? "",
agentId: agent.id,
agentName: agent.name,
connectorId: connector.id,
+2 -1
View File
@@ -27,7 +27,8 @@ export interface AutomationDispatch {
export interface EnqueueRunPayload {
runId: string;
companyId: string;
taskId: string;
/** Null for heartbeat runs (agent wake-ups with no assigned task). */
taskId: string | null;
taskTitle: string;
taskDescription: string;
agentId: string;