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

+1 -1
View File
@@ -108,7 +108,7 @@ export function registerFileRoutes(server: AgentApiServer) {
try {
const run = db.getRun(token.runId);
if (run.companyId === token.companyId) {
taskId = run.taskId;
taskId = run.taskId ?? null;
}
} catch (error) {
log.warn(
-1
View File
@@ -126,7 +126,6 @@ export function registerMeetingRoutes(server: AgentApiServer) {
if (item.title && typeof item.title === "string") {
const meetingTaskValidation = validateTaskCreation(
{ title: item.title, description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}` },
false,
);
if (!meetingTaskValidation.valid) {
log.warn(
+1 -3
View File
@@ -310,9 +310,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
// Agent-authored tasks are never heartbeat tasks; the is_heartbeat flag
// is set only by acquireHeartbeatTask in the orchestrator.
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, false);
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" });
if (!taskValidation.valid) {
server.json(res, 422, { error: taskValidation.errors.join("; ") });
return;
+1 -17
View File
@@ -115,9 +115,7 @@ export function executeAutomationAction(
const description = cfg.description ?? "";
const priority = cfg.priority ?? "medium";
const assigneeAgentId = cfg.assigneeAgentId ?? null;
// Automation rules create user-facing tasks; heartbeat tasks are
// synthesized only by acquireHeartbeatTask, never via create_task.
const taskValidation = validateTaskCreation({ title, description }, false);
const taskValidation = validateTaskCreation({ title, description });
if (!taskValidation.valid) {
logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`);
break;
@@ -231,20 +229,6 @@ export function executeAutomationAction(
} catch (err) {
logger.warn(`[automation] getTask failed for escalation task ${ctx.taskId}; using id as title:`, err instanceof Error ? err.message : String(err));
}
// Heartbeat tasks are internal system tasks — skip escalation messages to avoid flooding channels.
let isHeartbeatTask = false;
if (ctx.taskId) {
try {
isHeartbeatTask = db.getTask(ctx.taskId).isHeartbeat === true;
} catch (err) {
logger.warn(`[automation] getTask failed for heartbeat check on ${ctx.taskId}; treating as non-heartbeat:`, err instanceof Error ? err.message : String(err));
isHeartbeatTask = false;
}
}
if (isHeartbeatTask) {
logger.info(`[automation] Skipping escalation for heartbeat task "${taskTitle}"`);
break;
}
const manager = db.getManagerForAgent(targetAgentId);
const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`;
const wakeTrigger = deriveEscalationWakeTrigger(ctx.newStatus ?? null, rule.trigger ?? null);
@@ -209,6 +209,7 @@ export function processRunCompletion(
const { db, logger: _logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
const { dispatchAutomation, handleTaskStatusChange } = callbacks;
if (!run.taskId) return;
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(run.taskId);
@@ -260,14 +261,9 @@ export function processRunCompletion(
}
}
if (isSuccess && task.isHeartbeat && effectiveStatus !== "done") {
db.advanceTaskStatus(task.id, "done", "Heartbeat check completed.");
handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done");
}
const automationContext: AutomationEventContext = {
companyId: run.companyId,
taskId: run.taskId,
taskId: run.taskId ?? undefined,
agentId: run.agentId,
runId: run.id,
};
@@ -48,10 +48,12 @@ export function recoverShutdownInterruptedTasks(deps: RecoverInterruptedTasksDep
const resumedAgents = new Set<string>();
for (const run of shutdownRuns) {
try {
const task = deps.db.getTask(run.taskId);
if (task.status === "in_progress" || task.status === "blocked") {
deps.db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
deps.db.releaseTaskLock(run.taskId, run.id);
if (run.taskId) {
const task = deps.db.getTask(run.taskId);
if (task.status === "in_progress" || task.status === "blocked") {
deps.db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
deps.db.releaseTaskLock(run.taskId, run.id);
}
}
if (!resumedAgents.has(run.agentId)) {
resumedAgents.add(run.agentId);
+3 -6
View File
@@ -268,14 +268,11 @@ export class AppDatabaseBase {
checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); }
releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); }
renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); }
listAgentTasks(agentId: string, companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listAgentTasks(agentId, companyId, statuses, options); }
listTasksByStatuses(companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listTasksByStatuses(companyId, statuses, options); }
listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); }
listTasksByStatuses(companyId: string, statuses: string[]) { return this.tasks.listTasksByStatuses(companyId, statuses); }
listTasksForStandup(companyId: string) { return this.tasks.listTasksForStandup(companyId); }
listAssigneeTasksForStandup(companyId: string, agentId: string) { return this.tasks.listAssigneeTasksForStandup(companyId, agentId); }
listTasksForAssignees(companyId: string, assigneeIds: string[]) { return this.tasks.listTasksForAssignees(companyId, assigneeIds); }
findReusableHeartbeatTask(agentId: string, companyId: string) { return this.tasks.findReusableHeartbeatTask(agentId, companyId); }
acquireHeartbeatTask(input: { agentId: string; companyId: string; runId: string; workspaceId: string | null; title: string; description: string; priority: string }) { return this.tasks.acquireHeartbeatTask(input); }
cleanupDuplicateHeartbeatTasks(companyId: string) { return this.tasks.cleanupDuplicateHeartbeatTasks(companyId); }
listReviewTasksForManager(managerId: string, companyId: string) { return this.tasks.listReviewTasksForManager(managerId, companyId); }
countActiveTasksForCompany(companyId: string) { return this.tasks.countActiveTasksForCompany(companyId); }
listChildTasks(parentId: string, companyId: string) { return this.tasks.listChildTasks(parentId, companyId); }
@@ -300,7 +297,7 @@ export class AppDatabaseBase {
getRun(id: string) { return this.runs.getRun(id); }
getRunAuthState(runId: string) { return this.runs.getRunAuthState(runId); }
createRun(input: { id?: string; companyId: string; taskId: string; agentId: string; workspaceId: string | null; connectorId: ConnectorId }) { return this.runs.createRun(input); }
createRun(input: { id?: string; companyId: string; taskId: string | null; agentId: string; workspaceId: string | null; connectorId: ConnectorId }) { return this.runs.createRun(input); }
appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) { return this.runs.appendRunLog(runId, stream, chunk); }
readRunLog(runId: string) { return this.runs.readRunLog(runId); }
readRunLogChunk(runId: string, offset = 0, limit = 65_536) { return this.runs.readRunLogChunk(runId, offset, limit); }
+2 -2
View File
@@ -26,7 +26,7 @@ export function buildAgentCrudMethods(
createdByAgentId?: string | null;
createdByUserId?: string | null;
}) => string;
cancelRunForDeletion: (runId: string, taskId: string, workspaceId: string | null, summary: string) => void;
cancelRunForDeletion: (runId: string, taskId: string | null, workspaceId: string | null, summary: string) => void;
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
},
) {
@@ -159,7 +159,7 @@ export function buildAgentCrudMethods(
},
deleteAgent(id: string, companyId: string): void {
const activeAgentRuns = readAll<{ id: string; taskId: string; workspaceId: string | null }>(
const activeAgentRuns = readAll<{ id: string; taskId: string | null; workspaceId: string | null }>(
"select id, task_id as taskId, workspace_id as workspaceId from runs where agent_id = ? and company_id = ? and status in ('queued', 'running')",
id,
companyId,
+1 -1
View File
@@ -22,7 +22,7 @@ export function buildAgentMethods(
createdByAgentId?: string | null;
createdByUserId?: string | null;
}) => string;
cancelRunForDeletion: (runId: string, taskId: string, workspaceId: string | null, summary: string) => void;
cancelRunForDeletion: (runId: string, taskId: string | null, workspaceId: string | null, summary: string) => void;
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
},
) {
+1 -2
View File
@@ -49,12 +49,11 @@ 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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, 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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, 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 id = ?",
)
.get(id) as TaskRecord | undefined;
if (!row) throw new Error(`Task not found: ${id}`);
row.requiresUserReview = Boolean(row.requiresUserReview);
row.isHeartbeat = Boolean(row.isHeartbeat);
row.startedAt = row.startedAt ?? null;
row.completedAt = row.completedAt ?? null;
row.cancelledAt = row.cancelledAt ?? null;
+23 -15
View File
@@ -60,7 +60,7 @@ export function buildRunMethods(
createRun(input: {
id?: string;
companyId: string;
taskId: string;
taskId: string | null;
agentId: string;
workspaceId: string | null;
connectorId: ConnectorId;
@@ -74,7 +74,9 @@ export function buildRunMethods(
"insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, log_path, created_at, updated_at) values (?, ?, ?, ?, ?, ?, 'queued', '', 0, ?, ?, ?)",
)
.run(id, input.companyId, input.taskId, input.agentId, input.workspaceId, input.connectorId, logPath, ts, ts);
getDb().prepare("update tasks set active_run_id = ?, updated_at = ? where id = ?").run(id, ts, input.taskId);
if (input.taskId !== null) {
getDb().prepare("update tasks set active_run_id = ?, updated_at = ? where id = ?").run(id, ts, input.taskId);
}
});
writeFileSync(logPath, "", "utf8");
return { id, logPath };
@@ -163,7 +165,7 @@ export function buildRunMethods(
unattributedCost: boolean;
}) {
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as
| { taskId: string; agentId: string; companyId: string; connectorId: ConnectorId }
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId }
| undefined;
if (!run) return { budgetHardStopped: false };
const ts = nowIso();
@@ -198,14 +200,18 @@ export function buildRunMethods(
input.signal, input.model, input.sessionDisplayId, runCostUsd,
input.unattributedCost ? 1 : 0, ts, ts, input.runId,
);
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, input.runId);
if (runCostUsd > 0) {
if (run.taskId !== null) {
getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, run.taskId);
getDb()
.prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?")
.run(runCostUsd, ts, run.taskId);
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
.run(ts, run.taskId, input.runId);
}
if (runCostUsd > 0) {
if (run.taskId !== null) {
getDb()
.prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?")
.run(runCostUsd, ts, run.taskId);
}
getDb()
.prepare("update agents set spent_monthly_usd = spent_monthly_usd + ?, updated_at = ? where id = ?")
.run(runCostUsd, ts, run.agentId);
@@ -223,17 +229,19 @@ export function buildRunMethods(
return { budgetHardStopped };
},
cancelRunForDeletion(runId: string, taskId: string, workspaceId: string | null, summary: string) {
cancelRunForDeletion(runId: string, taskId: string | null, workspaceId: string | null, summary: string) {
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(summary, ts, ts, runId);
getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId);
getDb()
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
.run(ts, taskId, runId);
if (taskId !== null) {
getDb().prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId);
getDb()
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
.run(ts, taskId, runId);
}
if (workspaceId) {
getDb()
.prepare(
+227 -1
View File
@@ -42,7 +42,6 @@ export function ensureOptionalColumns(db: DatabaseSync) {
addColumnIfMissing("tasks", "checkout_run_id", "alter table tasks add column checkout_run_id text");
addColumnIfMissing("tasks", "execution_locked_at", "alter table tasks add column execution_locked_at text");
addColumnIfMissing("tasks", "active_session_id", "alter table tasks add column active_session_id text");
addColumnIfMissing("tasks", "is_heartbeat", "alter table tasks add column is_heartbeat integer not null default 0");
addColumnIfMissing("cost_entries", "project_id", "alter table cost_entries add column project_id text");
db.exec(`
create table if not exists task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null);
@@ -513,6 +512,233 @@ export function runMigrations(
sql: `ALTER TABLE agents ADD COLUMN session_id TEXT;
CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id)`,
},
{
// Rebuild runs table with task_id NULLABLE. Heartbeat runs (agent
// wake-ups with no real task) no longer need to synthesize a fake
// task row; they simply insert a run with task_id = NULL. The old
// workaround (is_heartbeat flag + acquireHeartbeatTask + cleanup
// sweeps) becomes dead code in v36.
//
// SQLite can't ALTER a column's NOT NULL constraint, so we rebuild:
// 1. Snapshot row count
// 2. Discover actual column set via PRAGMA (drift safe)
// 3. CREATE runs_new with the same columns + task_id nullable
// 4. Copy every row
// 5. Assert copied count == snapshot count (abort before DROP
// if any row went missing)
// 6. DROP old, RENAME new
// 7. Recreate every index on runs
//
// FK from run_events(run_id) references runs(id); PRAGMA foreign_keys
// OFF during the rebuild (we're inside a transaction already, so we
// toggle the defer_foreign_keys flag instead — SQLite forbids
// changing foreign_keys mid-transaction).
version: 35, name: "make_runs_task_id_nullable",
fn: () => {
const beforeCount = (db
.prepare("SELECT COUNT(*) as cnt FROM runs")
.get() as { cnt: number }).cnt;
const existingCols = (db
.prepare("PRAGMA table_info(runs)")
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
if (existingCols.length === 0) {
throw new Error("runs table is missing; refusing to migrate");
}
const colNames = existingCols.map((c) => c.name);
// Declare the known v34 column set. If the live DB has drift (extra
// columns we did not declare), preserve those verbatim using the
// column metadata from PRAGMA.
const knownCols = new Set([
"id", "company_id", "task_id", "agent_id", "workspace_id",
"connector_id", "status", "summary", "error_message", "exit_code",
"signal", "model", "session_display_id", "cost_usd",
"unattributed_cost", "started_at", "finished_at", "log_path",
"created_at", "updated_at", "context_snapshot_json",
"actor_type", "actor_id",
]);
const driftCols = existingCols.filter((c) => !knownCols.has(c.name));
// Defer FK checks within this transaction.
db.exec("PRAGMA defer_foreign_keys = ON");
// Build CREATE TABLE runs_new. task_id is nullable. Other columns
// reuse the declared types / constraints. For drift columns we
// emit a best-effort column definition (type, NULL, default).
const declaredDdl = [
"id TEXT PRIMARY KEY",
"company_id TEXT NOT NULL",
"task_id TEXT", // NULLABLE (was NOT NULL)
"agent_id TEXT NOT NULL",
"workspace_id TEXT",
"connector_id TEXT NOT NULL",
"status TEXT NOT NULL",
"summary TEXT NOT NULL",
"error_message TEXT",
"exit_code INTEGER",
"signal TEXT",
"model TEXT",
"session_display_id TEXT",
"cost_usd REAL",
"unattributed_cost INTEGER NOT NULL DEFAULT 0",
"started_at TEXT",
"finished_at TEXT",
"log_path TEXT",
"created_at TEXT NOT NULL",
"updated_at TEXT NOT NULL",
"context_snapshot_json TEXT",
"actor_type TEXT",
"actor_id TEXT",
];
for (const drift of driftCols) {
const type = drift.type || "TEXT";
declaredDdl.push(`${drift.name} ${type}`);
}
db.exec(`CREATE TABLE runs_new (${declaredDdl.join(", ")})`);
// Copy preserving every column present in the old table.
const copyCols = colNames.join(", ");
db.exec(`INSERT INTO runs_new (${copyCols}) SELECT ${copyCols} FROM runs`);
const afterCount = (db
.prepare("SELECT COUNT(*) as cnt FROM runs_new")
.get() as { cnt: number }).cnt;
if (afterCount !== beforeCount) {
throw new Error(
`runs copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`,
);
}
db.exec("DROP TABLE runs");
db.exec("ALTER TABLE runs_new RENAME TO runs");
// Recreate every index on runs.
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)");
log.info(`[migration v35] runs table rebuilt with nullable task_id (${afterCount} rows preserved)`);
},
},
{
// Drop the `tasks.is_heartbeat` column and all synthetic heartbeat
// task rows. Prerequisite: v35 must have made runs.task_id nullable
// so we can null-out runs that pointed at heartbeat tasks before
// deleting those task rows.
version: 36, name: "drop_tasks_is_heartbeat_and_synthetic_rows",
fn: () => {
const hasColumn = columnExists(db, "tasks", "is_heartbeat");
if (!hasColumn) {
log.info("[migration v36] tasks.is_heartbeat already absent; skipping");
return;
}
// 1. NULL-out runs that pointed at heartbeat tasks.
const updated = db
.prepare(
"UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)",
)
.run();
const runsNulledOut = Number(updated.changes);
// 2. Delete synthetic heartbeat tasks. Cost entries referring to
// these tasks remain with their task_id pointing at now-deleted
// ids — acceptable, cost_entries.task_id has no FK constraint.
// task_comments and approvals attached to these tasks are
// already cascaded by the task row delete or would remain
// orphaned; heartbeat tasks carry neither in practice.
const beforeHeartbeatCount = (db
.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE is_heartbeat = 1")
.get() as { cnt: number }).cnt;
db.exec("DELETE FROM tasks WHERE is_heartbeat = 1");
// 3. Rebuild `tasks` without the is_heartbeat column.
const beforeTasksCount = (db
.prepare("SELECT COUNT(*) as cnt FROM tasks")
.get() as { cnt: number }).cnt;
const existingCols = (db
.prepare("PRAGMA table_info(tasks)")
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
const keepCols = existingCols.filter((c) => c.name !== "is_heartbeat");
db.exec("PRAGMA defer_foreign_keys = ON");
// Declared v20 + v13 + v38+v39/v20 column set for `tasks`. Drift
// columns (any column we haven't declared here) are preserved
// verbatim using PRAGMA metadata — same drift-safe shape as v35.
const declaredCols = new Map<string, string>([
["id", "TEXT PRIMARY KEY"],
["company_id", "TEXT NOT NULL"],
["project_id", "TEXT"],
["goal_id", "TEXT"],
["parent_id", "TEXT"],
["title", "TEXT NOT NULL"],
["description", "TEXT NOT NULL"],
["assignee_agent_id", "TEXT"],
["workspace_id", "TEXT"],
["status", "TEXT NOT NULL"],
["priority", "TEXT NOT NULL"],
["active_run_id", "TEXT"],
["session_json", "TEXT"],
["session_display_id", "TEXT"],
["cost_usd", "REAL NOT NULL DEFAULT 0"],
["created_at", "TEXT NOT NULL"],
["updated_at", "TEXT NOT NULL"],
["task_type", "TEXT NOT NULL DEFAULT 'general'"],
["requires_user_review", "INTEGER NOT NULL DEFAULT 0"],
["retry_count", "INTEGER NOT NULL DEFAULT 0"],
["last_failure_at", "TEXT"],
["checkout_run_id", "TEXT"],
["execution_locked_at", "TEXT"],
["active_session_id", "TEXT"],
["started_at", "TEXT"],
["completed_at", "TEXT"],
["cancelled_at", "TEXT"],
]);
const ddl = keepCols.map((c) => {
const declared = declaredCols.get(c.name);
if (declared) return `${c.name} ${declared}`;
return `${c.name} ${c.type || "TEXT"}`;
});
db.exec(`CREATE TABLE tasks_new (${ddl.join(", ")})`);
const copyCols = keepCols.map((c) => c.name).join(", ");
db.exec(`INSERT INTO tasks_new (${copyCols}) SELECT ${copyCols} FROM tasks`);
const afterTasksCount = (db
.prepare("SELECT COUNT(*) as cnt FROM tasks_new")
.get() as { cnt: number }).cnt;
if (afterTasksCount !== beforeTasksCount) {
throw new Error(
`tasks copy row count mismatch: before=${beforeTasksCount} after=${afterTasksCount}; aborting before DROP`,
);
}
db.exec("DROP TABLE tasks");
db.exec("ALTER TABLE tasks_new RENAME TO tasks");
// Recreate every index on tasks.
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)");
log.info(
`[migration v36] removed tasks.is_heartbeat; deleted ${beforeHeartbeatCount} synthetic heartbeat task row(s); nulled task_id on ${runsNulledOut} run(s); preserved ${afterTasksCount} task row(s)`,
);
},
},
];
for (const migration of migrations) {
+2 -2
View File
@@ -75,9 +75,9 @@ export function buildSnapshotMethods(
...companyArgs,
),
tasks: readAll<TaskRecord>(
`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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere ? `${companyWhere} and is_heartbeat = 0` : " where is_heartbeat = 0"} 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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, 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${companyWhere} order by created_at desc limit 2000`,
...companyArgs,
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), isHeartbeat: toBoolean(record.isHeartbeat), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
approvals: readAll<ApprovalRecord>(
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc limit 1000`,
...companyArgs,
+14 -30
View File
@@ -33,7 +33,6 @@ export type TaskListByStatusesRow = {
sessionDisplayId: string | null;
costUsd: number | null;
requiresUserReview: boolean;
isHeartbeat: boolean;
startedAt: string | null;
completedAt: string | null;
cancelledAt: string | null;
@@ -170,23 +169,18 @@ export function buildTaskCrudMethods(
},
/**
* Returns actionable real work for an agent. Heartbeat tasks are
* excluded by default since they are synthesized wake-up placeholders;
* pass `{ includeHeartbeat: true }` to include them (heartbeat-loop
* callers that already filter by `is_heartbeat` do not need this).
* Returns actionable real work for an agent.
*/
listAgentTasks(
agentId: string,
companyId: string,
statuses: string[],
options: { includeHeartbeat?: boolean } = {},
): TaskRecord[] {
const placeholders = statuses.map(() => "?").join(",");
const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0";
return readAll<TaskRecord>(
`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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, is_heartbeat as isHeartbeat, 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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, 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})${heartbeatClause}
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
order by
case status
when 'in_progress' then 0
@@ -200,21 +194,19 @@ export function buildTaskCrudMethods(
companyId,
agentId,
...statuses,
).map((t) => ({ ...t, isHeartbeat: Boolean(t.isHeartbeat) }));
);
},
/**
* Returns the slim task projection (id, title, status, priority) for a
* single assignee, ordered by created_at desc. Used by the alternate
* me/standup builder in routes-agents-standup.ts. Heartbeat tasks are
* excluded so UI aggregations do not surface synthetic wake tasks.
* single assignee, ordered by created_at desc.
*/
listAssigneeTasksForStandup(
companyId: string,
agentId: string,
): Array<{ id: string; title: string; status: string; priority: string }> {
return readAll(
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? and is_heartbeat = 0 order by created_at desc",
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc",
companyId,
agentId,
);
@@ -222,9 +214,7 @@ export function buildTaskCrudMethods(
/**
* Returns the slim task projection for a set of assignees. Safe on
* an empty assignee list (returns []). Used by the alternate
* me/standup builder to fetch reports' tasks in one query. Heartbeat
* tasks are excluded.
* an empty assignee list (returns []).
*/
listTasksForAssignees(
companyId: string,
@@ -239,7 +229,7 @@ export function buildTaskCrudMethods(
if (assigneeIds.length === 0) return [];
const placeholders = assigneeIds.map(() => "?").join(",");
return readAll(
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) and is_heartbeat = 0 order by created_at desc`,
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`,
companyId,
...assigneeIds,
);
@@ -247,7 +237,7 @@ export function buildTaskCrudMethods(
/**
* Returns the slim task projection used by the me/standup aggregation.
* Ordered by created_at desc. Heartbeat tasks are excluded.
* Ordered by created_at desc.
*/
listTasksForStandup(companyId: string): Array<{
id: string;
@@ -258,32 +248,26 @@ export function buildTaskCrudMethods(
priority: string;
}> {
return readAll(
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? and is_heartbeat = 0 order by created_at desc",
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
companyId,
);
},
/**
* Returns tasks filtered by status. Heartbeat tasks are excluded by
* default — they are synthesized wake placeholders and should not
* appear in the user-facing issue list. Pass `{ includeHeartbeat: true }`
* to include them.
* Returns tasks filtered by status.
*/
listTasksByStatuses(
companyId: string,
statuses: string[],
options: { includeHeartbeat?: boolean } = {},
): TaskListByStatusesRow[] {
const placeholders = statuses.map(() => "?").join(", ");
const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0";
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean; isHeartbeat: number | boolean }>(
`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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, 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})${heartbeatClause} order by created_at desc`,
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
`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, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, 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) => ({
...t,
requiresUserReview: Boolean(t.requiresUserReview),
isHeartbeat: Boolean(t.isHeartbeat),
startedAt: t.startedAt ?? null,
completedAt: t.completedAt ?? null,
cancelledAt: t.cancelledAt ?? null,
@@ -293,7 +277,7 @@ export function buildTaskCrudMethods(
countActiveTasksForCompany(companyId: string): number {
const row = getDb()
.prepare(
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and is_heartbeat = 0",
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled')",
)
.get(companyId) as { cnt: number };
return row.cnt;
+3 -150
View File
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import type { TaskRecord } from "@shared/types";
import {
@@ -12,7 +11,7 @@ import { chooseBestTaskAssignee } from "./task-assignment";
export function buildTaskStatusMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
transaction: Transaction,
_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;
@@ -102,152 +101,6 @@ export function buildTaskStatusMethods(
return { previousStatus };
},
findReusableHeartbeatTask(agentId: string, companyId: string): TaskRecord | null {
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, active_run_id as activeRunId,
session_json as sessionJson, session_display_id as sessionDisplayId,
cost_usd as costUsd, requires_user_review as requiresUserReview,
is_heartbeat as isHeartbeat,
created_at as createdAt, updated_at as updatedAt
FROM tasks
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
AND status IN ('done', 'in_review')
ORDER BY updated_at DESC, rowid DESC
LIMIT 1`,
)
.get(companyId, agentId) as TaskRecord | undefined;
if (row) {
row.requiresUserReview = Boolean(row.requiresUserReview);
row.isHeartbeat = Boolean(row.isHeartbeat);
}
return row ?? null;
},
acquireHeartbeatTask(input: {
agentId: string;
companyId: string;
runId: string;
workspaceId: string | null;
title: string;
description: string;
priority: string;
}): TaskRecord | null {
return transaction<TaskRecord | null>(() => {
const ts = nowIso();
const db = getDb();
const claimable = db
.prepare(
`SELECT id, status FROM tasks
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
AND status IN ('todo', 'backlog', 'done', 'in_review')
AND checkout_run_id IS NULL
ORDER BY
CASE status WHEN 'todo' THEN 0 WHEN 'backlog' THEN 1 WHEN 'in_review' THEN 2 WHEN 'done' THEN 3 ELSE 4 END ASC,
updated_at DESC, rowid DESC
LIMIT 1`,
)
.get(input.companyId, input.agentId) as { id: string; status: string } | undefined;
let taskId: string | null = null;
if (claimable) {
const claimResult = db
.prepare(
`UPDATE tasks
SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress',
started_at = COALESCE(started_at, ?), completed_at = NULL, cancelled_at = NULL,
updated_at = ?
WHERE id = ? AND checkout_run_id IS NULL
AND status IN ('todo', 'backlog', 'done', 'in_review')`,
)
.run(input.runId, ts, ts, ts, claimable.id);
if (claimResult.changes > 0) {
taskId = claimable.id;
} else {
return null;
}
} else {
const inflight = db
.prepare(
`SELECT 1 FROM tasks
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
AND status NOT IN ('done', 'cancelled')
LIMIT 1`,
)
.get(input.companyId, input.agentId);
if (inflight) return null;
taskId = randomUUID();
db.prepare(
`INSERT INTO tasks
(id, company_id, project_id, goal_id, parent_id, title, description,
assignee_agent_id, workspace_id, status, priority,
checkout_run_id, execution_locked_at, started_at, is_heartbeat,
created_at, updated_at)
VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, 1, ?, ?)`,
).run(
taskId,
input.companyId,
input.title,
input.description,
input.agentId,
input.workspaceId,
input.priority,
input.runId,
ts,
ts,
ts,
ts,
);
}
const row = db
.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, active_run_id as activeRunId,
session_json as sessionJson, session_display_id as sessionDisplayId,
cost_usd as costUsd, requires_user_review as requiresUserReview,
is_heartbeat as isHeartbeat, task_type as taskType,
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(taskId) as TaskRecord | undefined;
if (!row) return null;
row.requiresUserReview = Boolean(row.requiresUserReview);
row.isHeartbeat = Boolean(row.isHeartbeat);
row.startedAt = row.startedAt ?? null;
row.completedAt = row.completedAt ?? null;
row.cancelledAt = row.cancelledAt ?? null;
return row;
});
},
cleanupDuplicateHeartbeatTasks(companyId: string): number {
const ts = nowIso();
const result = getDb()
.prepare(
`UPDATE tasks SET status = 'cancelled', updated_at = ?
WHERE company_id = ? AND is_heartbeat = 1
AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog')
AND id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC, rowid DESC) as rn
FROM tasks
WHERE company_id = ? AND is_heartbeat = 1 AND status != 'cancelled'
) WHERE rn = 1
)`,
)
.run(ts, companyId, companyId);
return Number(result.changes);
},
listReviewTasksForManager(managerId: string, companyId: string): TaskRecord[] {
return readAll<TaskRecord>(
`select t.id, t.company_id as companyId, t.project_id as projectId, t.goal_id as goalId,
@@ -255,11 +108,11 @@ export function buildTaskStatusMethods(
t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId,
t.status, t.priority, t.active_run_id as activeRunId,
t.session_json as sessionJson, t.session_display_id as sessionDisplayId,
t.cost_usd as costUsd, t.is_heartbeat as isHeartbeat,
t.cost_usd as costUsd,
t.created_at as createdAt, t.updated_at as updatedAt
from tasks t
inner join agents a on t.assignee_agent_id = a.id
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? and t.is_heartbeat = 0
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ?
order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`,
companyId,
managerId,
+1 -3
View File
@@ -102,9 +102,7 @@ export function registerCoreTaskGraphHandlers({
}
const isNew = !parsed.id;
if (isNew) {
// User-created tasks are never heartbeat tasks; the is_heartbeat flag
// is set only by acquireHeartbeatTask in the orchestrator.
const validation = validateTaskCreation(parsed, false);
const validation = validateTaskCreation(parsed);
if (!validation.valid) {
return fail("TASK_VALIDATION_FAILED", validation.errors.join("; "));
}
-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;
+6 -4
View File
@@ -34,10 +34,12 @@ export function resumeInterruptedTasks({
const resumedAgents = new Set<string>();
for (const run of shutdownRuns) {
try {
const task = db.getTask(run.taskId);
if (task.status === "in_progress" || task.status === "blocked") {
db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
db.releaseTaskLock(run.taskId, run.id);
if (run.taskId) {
const task = db.getTask(run.taskId);
if (task.status === "in_progress" || task.status === "blocked") {
db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
db.releaseTaskLock(run.taskId, run.id);
}
}
if (!resumedAgents.has(run.agentId)) {
resumedAgents.add(run.agentId);
+4 -1
View File
@@ -68,13 +68,16 @@ export function createRunHousekeeping({ db, logger, threadIntegration }: RunHous
return {
onRunCompleted(
runId: string,
taskId: string,
taskId: string | null,
agentId: string,
companyId: string,
status: string,
summary: string | null,
errorMessage: string | null,
): void {
// Skip thread integration + knowledge candidates for heartbeat runs
// (no task to attach them to).
if (!taskId) return;
// 1. Record run completion in the task thread
try {
threadIntegration.onRunFinished(runId, taskId, agentId, companyId, status, errorMessage ?? null);
+2 -2
View File
@@ -44,8 +44,8 @@ interface ServiceGraphDependencies {
notify: (options: NotificationOptions) => void;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
seedDefaultAutomationRules: (companyId: string) => void;
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
onRunStarted?: (runId: string, taskId: string | null, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string | null, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
/** Checkpoint engine for unified co-founder mode pause decisions in scheduler. */
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
/** Trusted-sender IPC wrapper from createIpcSupport. Window-control channels use it. */
@@ -23,8 +23,8 @@ export interface WorkerServiceDeps {
emitEvent: (event: DesktopEvent) => void;
orchestrationRef: LateBinding<ReturnType<typeof createOrchestrationService>>;
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
onRunStarted?: (runId: string, taskId: string | null, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string | null, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
notify: (options: NotificationOptions) => void;
budgetService: ReturnType<typeof createBudgetEnforcementService>;
sessionService: ReturnType<typeof createSessionManagementService>;
-6
View File
@@ -78,13 +78,7 @@ export function checkAcceptanceCriteria(description: string): { hasCriteria: boo
export function validateTaskCreation(
input: { title: string; description: string; type?: string },
isHeartbeat: boolean,
): TaskValidationResult {
// Heartbeat tasks bypass all validation
if (isHeartbeat) {
return { valid: true, errors: [], warnings: [] };
}
const errors: string[] = [];
const warnings: string[] = [];
+2 -1
View File
@@ -202,7 +202,8 @@ export function createThreadIntegration({ db, logger, emitEvent, publishDomainCh
* Called when a run starts for a task.
* Records the run start in the task thread.
*/
onRunStarted(runId: string, taskId: string, agentId: string, companyId: string): void {
onRunStarted(runId: string, taskId: string | null, agentId: string, companyId: string): void {
if (!taskId) return;
try {
const thread = findTaskThread(db, taskId, companyId);
if (!thread) return;
+3 -1
View File
@@ -182,7 +182,9 @@ export function createWorkerService({
if (run.workspaceId) {
db.releaseWorkspaceLock(run.workspaceId, run.id);
}
db.releaseTaskLock(run.taskId, run.id);
if (run.taskId) {
db.releaseTaskLock(run.taskId, run.id);
}
}
}
} catch (error) {
@@ -55,13 +55,15 @@ export function finalizeRun(
});
let task: TaskRecord | null = null;
try {
task = db.getTask(run.taskId);
} catch (error) {
logger.warn(
`[finalizeRun] getTask(${run.taskId}) failed; task may have been deleted: ${error instanceof Error ? error.message : String(error)}`,
);
task = null;
if (run.taskId) {
try {
task = db.getTask(run.taskId);
} catch (error) {
logger.warn(
`[finalizeRun] getTask(${run.taskId}) failed; task may have been deleted: ${error instanceof Error ? error.message : String(error)}`,
);
task = null;
}
}
if (task) {
@@ -192,13 +194,13 @@ export function runPostCompletionAutomation(
budgetService.checkAndCreateIncidents(run.companyId, run.agentId, task?.projectId ?? null);
}
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed && run.taskId) {
handleFailureEscalation(
{
db,
wakeAgentIfPossible,
notify: (title, body) =>
notify({ title, body, urgency: "critical", navigation: { section: "tasks", entityId: run.taskId } }),
notify({ title, body, urgency: "critical", navigation: { section: "tasks", entityId: run.taskId ?? "" } }),
logger,
},
{ taskId: run.taskId, agentId: run.agentId, companyId: run.companyId },
@@ -256,7 +258,7 @@ export function runPostCompletionAutomation(
}
// Reset retry count on successful completion
if (event.status === "succeeded") {
if (event.status === "succeeded" && run.taskId) {
handleSuccessReset(db, run.taskId, logger);
}
+3 -1
View File
@@ -36,7 +36,9 @@ export async function handleRunLog(
if (now - lastRenewal >= LEASE_RENEWAL_INTERVAL_MS) {
try {
const run = db.getRun(event.runId);
db.renewTaskLease(run.taskId, run.id);
if (run.taskId) {
db.renewTaskLease(run.taskId, run.id);
}
leaseRenewalMap.set(event.runId, now);
} catch {
// Run not found — skip lease renewal
@@ -30,7 +30,9 @@ export function handleRunStatus(
// run-started housekeeping run here.
try {
const run = db.getRun(event.runId);
db.renewTaskLease(run.taskId, run.id);
if (run.taskId) {
db.renewTaskLease(run.taskId, run.id);
}
onRunStarted?.(run.id, run.taskId, run.agentId, run.companyId);
} catch {
// Run not found — skip status handling
+5 -3
View File
@@ -24,7 +24,7 @@ export function handleWorkerExit(
const affectedCompanyIds = new Map<string, string>();
const requeueCandidates: Array<{
id: string;
taskId: string;
taskId: string | null;
agentId: string;
companyId: string;
workspaceId: string | null;
@@ -48,7 +48,9 @@ export function handleWorkerExit(
db.releaseWorkspaceLock(run.workspaceId, run.id);
}
// EXEC-02: Release execution lock on crash recovery
db.releaseTaskLock(run.taskId, run.id);
if (run.taskId) {
db.releaseTaskLock(run.taskId, run.id);
}
affectedAgentIds.add(run.agentId);
affectedCompanyIds.set(run.agentId, run.companyId);
@@ -97,7 +99,7 @@ export function handleWorkerExit(
if (candidate.workspaceId) {
db.releaseWorkspaceLock(candidate.workspaceId, candidate.id);
}
const task = db.getTask(candidate.taskId);
const task = candidate.taskId ? db.getTask(candidate.taskId) : null;
const agent = db.getAgent(candidate.agentId);
const connector = db.getConnector(agent.connectorId);
const { workspace, lockMode } = resolveWorkspaceWithLockMode(db, task, agent, candidate.companyId);
+3 -3
View File
@@ -71,7 +71,7 @@ export interface WorkerServiceDependencies {
runId: string;
companyId: string;
agent: ReturnType<AppDatabase["getAgent"]>;
task: ReturnType<AppDatabase["getTask"]>;
task: ReturnType<AppDatabase["getTask"]> | null;
connector: ReturnType<AppDatabase["getConnector"]>;
workspace: ReturnType<AppDatabase["getWorkspace"]>;
workspaceMode: "exclusive" | "shared" | "none";
@@ -81,8 +81,8 @@ export interface WorkerServiceDependencies {
trigger: string;
}) => void;
processRunCompletion: (run: RunRecord, event: z.infer<typeof workerMessageSchema> & { type: "run-finished" }) => void;
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
onRunStarted?: (runId: string, taskId: string | null, agentId: string, companyId: string) => void;
onRunCompleted?: (runId: string, taskId: string | null, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
notify: (options: NotificationOptions) => void;
budgetService?: ReturnType<typeof createBudgetEnforcementService>;
sessionService?: ReturnType<typeof createSessionManagementService>;
@@ -70,7 +70,7 @@ export function Dashboard(props: DashboardProps) {
.filter((r) => r.status === "running" || r.status === "queued")
.map((run) => {
const agent = agentMap.get(run.agentId);
const task = taskMap.get(run.taskId);
const task = run.taskId ? taskMap.get(run.taskId) : undefined;
return { run, agent, task };
})
.slice(0, 8);
+1 -1
View File
@@ -51,7 +51,7 @@ export function RunCenter({
if (!query) {
return true;
}
const task = taskMap.get(run.taskId);
const task = run.taskId ? taskMap.get(run.taskId) : undefined;
const agent = agentMap.get(run.agentId);
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
return [
+1 -1
View File
@@ -47,7 +47,7 @@ export function RunDetail({
onLoadOlderLogs,
t,
}: RunDetailProps) {
const task = taskMap.get(selectedRun.taskId);
const task = selectedRun.taskId ? taskMap.get(selectedRun.taskId) : undefined;
const agent = agentMap.get(selectedRun.agentId);
return (
+1 -1
View File
@@ -62,7 +62,7 @@ export function RunQueue({
</div>
<ListFrame>
{filteredRuns.map((run) => {
const task = taskMap.get(run.taskId);
const task = run.taskId ? taskMap.get(run.taskId) : undefined;
const agent = agentMap.get(run.agentId);
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
const title = task?.title ?? (run.summary || `Run ${run.id.slice(0, 8)}`);
+1 -2
View File
@@ -151,7 +151,6 @@ export interface TaskRecord {
costUsd: number;
taskType: string | null;
requiresUserReview: boolean;
isHeartbeat: boolean;
startedAt: string | null;
completedAt: string | null;
cancelledAt: string | null;
@@ -178,7 +177,7 @@ export interface ApprovalRecord {
export interface RunRecord {
id: string;
companyId: string;
taskId: string;
taskId: string | null;
agentId: string;
workspaceId: string | null;
connectorId: ConnectorId;
@@ -171,7 +171,6 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
description:
"Implement the login page with email and password authentication. Done when: form renders, validation works, and API called on submit.",
},
false,
);
expect(validation.valid).toBe(true);
expect(validation.errors).toHaveLength(0);
@@ -251,7 +250,6 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
it("quality gate rejects short description before pipeline starts", () => {
const result = validateTaskCreation(
{ title: "Fix bug", description: "Short." },
false,
);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
-317
View File
@@ -1,317 +0,0 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createTestCompany(db: AppDatabase): string {
return db.saveCompany({
name: "Test Company",
description: "Heartbeat coalescing tests",
status: "active",
});
}
function createTestConnector(db: AppDatabase): void {
db.saveConnector({
id: "openai-codex",
command: "codex",
model: "gpt-5",
envBindingText: "",
notes: "Test connector",
});
}
function createTestWorkspace(db: AppDatabase, companyId: string): string {
return db.saveWorkspace({
companyId,
name: "Test Workspace",
localPath: "/tmp/test-workspace",
repoUrl: "https://github.com/example/test",
repoRef: "main",
isPrimary: true,
});
}
function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string, name = "Agent"): string {
return db.saveAgent({
companyId,
name,
role: "IC",
title: name,
status: "active",
reportsTo: null,
connectorId: "openai-codex",
workspaceId,
model: null,
capabilities: "Executes tasks",
budgetMonthlyUsd: 100,
});
}
function createHeartbeatTask(
db: AppDatabase,
companyId: string,
agentId: string,
status: string,
agentName = "Agent",
): string {
const id = db.saveTask({
companyId,
title: `[Heartbeat] ${agentName} — Check for work`,
description: `Automated heartbeat run for ${agentName}.`,
assigneeAgentId: agentId,
status,
priority: "medium",
});
// saveTask is a generic user-task write path and does not set the
// heartbeat flag. Flip it directly so this fixture matches what
// acquireHeartbeatTask would produce at runtime.
db.queryAll("UPDATE tasks SET is_heartbeat = 1 WHERE id = ?", id);
return id;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Heartbeat task coalescing", () => {
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-heartbeat-"));
db = new AppDatabase(tempDir);
db.init();
createTestConnector(db);
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
});
// -------------------------------------------------------------------------
// findReusableHeartbeatTask
// -------------------------------------------------------------------------
describe("findReusableHeartbeatTask", () => {
it("returns the most recent done heartbeat task for the agent", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "CEO");
const taskId = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
const result = db.findReusableHeartbeatTask(agentId, companyId);
expect(result).not.toBeNull();
expect(result!.id).toBe(taskId);
expect(result!.status).toBe("done");
});
it("returns null when no heartbeat tasks exist for the agent", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Worker");
const result = db.findReusableHeartbeatTask(agentId, companyId);
expect(result).toBeNull();
});
it("returns null when all heartbeat tasks are still in_progress", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Worker");
createHeartbeatTask(db, companyId, agentId, "in_progress", "Worker");
const result = db.findReusableHeartbeatTask(agentId, companyId);
expect(result).toBeNull();
});
it("returns the most recently updated heartbeat task when multiple done ones exist", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "CEO");
// Create two done heartbeat tasks
const _oldTaskId = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
const newTaskId = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
// Advance the newer one so its updated_at is more recent
db.advanceTaskStatus(newTaskId, "in_review", "Testing");
db.advanceTaskStatus(newTaskId, "done", "Testing done");
const result = db.findReusableHeartbeatTask(agentId, companyId);
expect(result).not.toBeNull();
expect(result!.id).toBe(newTaskId);
});
it("returns in_review task if no done tasks exist", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Lead");
const taskId = createHeartbeatTask(db, companyId, agentId, "in_review", "Lead");
const result = db.findReusableHeartbeatTask(agentId, companyId);
expect(result).not.toBeNull();
expect(result!.id).toBe(taskId);
});
it("does not return heartbeat tasks from other agents", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agent1 = createTestAgent(db, companyId, wsId, "Agent1");
const agent2 = createTestAgent(db, companyId, wsId, "Agent2");
createHeartbeatTask(db, companyId, agent1, "done", "Agent1");
const result = db.findReusableHeartbeatTask(agent2, companyId);
expect(result).toBeNull();
});
it("does not return non-heartbeat tasks", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Worker");
// Create a regular done task (not a heartbeat)
db.saveTask({
companyId,
title: "Regular completed task",
description: "Not a heartbeat",
assigneeAgentId: agentId,
status: "done",
priority: "medium",
});
const result = db.findReusableHeartbeatTask(agentId, companyId);
expect(result).toBeNull();
});
});
// -------------------------------------------------------------------------
// cleanupDuplicateHeartbeatTasks
// -------------------------------------------------------------------------
describe("cleanupDuplicateHeartbeatTasks", () => {
it("cancels duplicate heartbeat tasks, keeping only the most recent per agent", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "CEO");
// Create three done heartbeat tasks
const _task1 = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
const _task2 = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
const task3 = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
// Touch task3 so it becomes the most recent
db.advanceTaskStatus(task3, "in_review", "Touch");
db.advanceTaskStatus(task3, "done", "Touch done");
const cleaned = db.cleanupDuplicateHeartbeatTasks(companyId);
expect(cleaned).toBe(2);
// The most recent one should still be done
const remaining = db.getTask(task3);
expect(remaining.status).toBe("done");
// The older ones should be cancelled
const old1 = db.getTask(_task1);
const old2 = db.getTask(_task2);
expect(old1.status).toBe("cancelled");
expect(old2.status).toBe("cancelled");
});
it("returns the count of cancelled tasks", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Worker");
createHeartbeatTask(db, companyId, agentId, "done", "Worker");
createHeartbeatTask(db, companyId, agentId, "todo", "Worker");
const newest = createHeartbeatTask(db, companyId, agentId, "done", "Worker");
// Touch newest so it has the latest updated_at
db.advanceTaskStatus(newest, "in_review", "Touch");
db.advanceTaskStatus(newest, "done", "Touch done");
const cleaned = db.cleanupDuplicateHeartbeatTasks(companyId);
expect(cleaned).toBe(2);
});
it("does not cancel in_progress heartbeat tasks", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Worker");
const activeTask = createHeartbeatTask(db, companyId, agentId, "in_progress", "Worker");
const doneTask = createHeartbeatTask(db, companyId, agentId, "done", "Worker");
// Touch doneTask to make it more recent
db.advanceTaskStatus(doneTask, "in_review", "Touch");
db.advanceTaskStatus(doneTask, "done", "Touch done");
const cleaned = db.cleanupDuplicateHeartbeatTasks(companyId);
// Only 0 cancelled because in_progress cannot be cancelled and doneTask is the most recent
expect(cleaned).toBe(0);
// in_progress task should be untouched
const active = db.getTask(activeTask);
expect(active.status).toBe("in_progress");
});
it("keeps one heartbeat task per agent when company has multiple agents", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agent1 = createTestAgent(db, companyId, wsId, "Agent1");
const agent2 = createTestAgent(db, companyId, wsId, "Agent2");
// Agent1: 3 heartbeat tasks
createHeartbeatTask(db, companyId, agent1, "done", "Agent1");
createHeartbeatTask(db, companyId, agent1, "done", "Agent1");
const a1newest = createHeartbeatTask(db, companyId, agent1, "done", "Agent1");
db.advanceTaskStatus(a1newest, "in_review", "Touch");
db.advanceTaskStatus(a1newest, "done", "Touch done");
// Agent2: 2 heartbeat tasks
createHeartbeatTask(db, companyId, agent2, "done", "Agent2");
const a2newest = createHeartbeatTask(db, companyId, agent2, "done", "Agent2");
db.advanceTaskStatus(a2newest, "in_review", "Touch");
db.advanceTaskStatus(a2newest, "done", "Touch done");
const cleaned = db.cleanupDuplicateHeartbeatTasks(companyId);
// 2 old from agent1 + 1 old from agent2 = 3
expect(cleaned).toBe(3);
// Most recent per agent should survive
expect(db.getTask(a1newest).status).toBe("done");
expect(db.getTask(a2newest).status).toBe("done");
});
it("returns 0 when no duplicate heartbeat tasks exist", () => {
const companyId = createTestCompany(db);
const wsId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, wsId, "Solo");
// Only one heartbeat task
createHeartbeatTask(db, companyId, agentId, "done", "Solo");
const cleaned = db.cleanupDuplicateHeartbeatTasks(companyId);
expect(cleaned).toBe(0);
});
});
});
-1
View File
@@ -271,7 +271,6 @@ const fakeTask = (id: string, title = "Task"): TaskRecord => ({
sessionJson: null,
sessionDisplayId: null,
requiresUserReview: false,
isHeartbeat: false,
startedAt: null,
completedAt: null,
cancelledAt: null,
+7 -14
View File
@@ -11,14 +11,14 @@ import type { TaskValidationResult as _TaskValidationResult } from "../../src/ma
describe("validateTaskCreation", () => {
it("rejects description with 5 chars when not heartbeat", () => {
const result = validateTaskCreation({ title: "Fix bug", description: "Short" }, false);
const result = validateTaskCreation({ title: "Fix bug", description: "Short" });
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("at least 50 characters"))).toBe(true);
});
it("rejects description with exactly 49 chars when not heartbeat", () => {
const desc = "A".repeat(49);
const result = validateTaskCreation({ title: "Fix bug", description: desc }, false);
const result = validateTaskCreation({ title: "Fix bug", description: desc });
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("at least 50 characters"))).toBe(true);
});
@@ -29,20 +29,13 @@ describe("validateTaskCreation", () => {
const padded = desc.slice(0, 50).padEnd(50, ".");
expect(padded.length).toBe(50);
// Must contain "done when"
const result = validateTaskCreation({ title: "Build login page", description: padded }, false);
const result = validateTaskCreation({ title: "Build login page", description: padded });
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("always returns valid for heartbeat tasks regardless of description", () => {
const result = validateTaskCreation({ title: "[Heartbeat] Check status", description: "Short" }, true);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
it("rejects empty description when not heartbeat", () => {
const result = validateTaskCreation({ title: "Some task", description: "" }, false);
const result = validateTaskCreation({ title: "Some task", description: "" });
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("at least 50 characters"))).toBe(true);
});
@@ -50,7 +43,7 @@ describe("validateTaskCreation", () => {
it("rejects 60-char description without acceptance criteria keywords", () => {
const desc = "This is a description that is long enough to pass length check";
expect(desc.length).toBeGreaterThanOrEqual(50);
const result = validateTaskCreation({ title: "A task", description: desc }, false);
const result = validateTaskCreation({ title: "A task", description: desc });
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.toLowerCase().includes("acceptance criteria"))).toBe(true);
});
@@ -58,13 +51,13 @@ describe("validateTaskCreation", () => {
it("returns valid=true when description has both length and acceptance criteria", () => {
const desc = "Implement the authentication flow for the login page. Done when: all tests pass and the form submits.";
expect(desc.length).toBeGreaterThanOrEqual(50);
const result = validateTaskCreation({ title: "Auth flow", description: desc }, false);
const result = validateTaskCreation({ title: "Auth flow", description: desc });
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("includes current character count in length error message", () => {
const result = validateTaskCreation({ title: "Fix", description: "Short" }, false);
const result = validateTaskCreation({ title: "Fix", description: "Short" });
expect(result.errors.some(e => e.includes("currently 5"))).toBe(true);
});
});