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).
210 lines
8.1 KiB
TypeScript
210 lines
8.1 KiB
TypeScript
import {
|
|
IPC_CHANNELS,
|
|
deleteEntitySchema,
|
|
goalInputSchema,
|
|
projectInputSchema,
|
|
taskInputSchema,
|
|
} from "@shared/contracts";
|
|
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
|
import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates";
|
|
|
|
export function registerCoreTaskGraphHandlers({
|
|
registerHandle,
|
|
db,
|
|
ensureCompanyExists,
|
|
findInvalidReference,
|
|
publishDomainChanged,
|
|
emitEvent,
|
|
wakeAgentIfPossible,
|
|
handleTaskCreated,
|
|
handleTaskStatusChange,
|
|
logger,
|
|
}: Pick<
|
|
RegisterCoreHandlersDependencies,
|
|
| "registerHandle"
|
|
| "db"
|
|
| "ensureCompanyExists"
|
|
| "findInvalidReference"
|
|
| "publishDomainChanged"
|
|
| "emitEvent"
|
|
| "wakeAgentIfPossible"
|
|
| "handleTaskCreated"
|
|
| "handleTaskStatusChange"
|
|
| "logger"
|
|
>) {
|
|
registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
|
|
const parsed = goalInputSchema.parse(payload);
|
|
const companyError = ensureCompanyExists(parsed.companyId);
|
|
if (companyError) return companyError;
|
|
const invalidReference = findInvalidReference(parsed.companyId, [
|
|
{ id: parsed.parentId, table: "goals", label: "parent goal" },
|
|
{ id: parsed.ownerAgentId, table: "agents", label: "owner agent" },
|
|
]);
|
|
if (invalidReference) {
|
|
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
|
}
|
|
const id = db.saveGoal(parsed);
|
|
db.addActivity({
|
|
companyId: parsed.companyId,
|
|
actor: "board",
|
|
action: parsed.id ? "goal.updated" : "goal.created",
|
|
entityType: "goal",
|
|
entityId: id,
|
|
detail: parsed.title,
|
|
});
|
|
publishDomainChanged();
|
|
if (parsed.status === "active" && parsed.ownerAgentId) {
|
|
wakeAgentIfPossible(parsed.ownerAgentId, parsed.companyId, "goal_activated");
|
|
}
|
|
return ok(id);
|
|
});
|
|
|
|
registerHandle(IPC_CHANNELS.saveProject, async (_event, payload) => {
|
|
const parsed = projectInputSchema.parse(payload);
|
|
const companyError = ensureCompanyExists(parsed.companyId);
|
|
if (companyError) return companyError;
|
|
const invalidReference = findInvalidReference(parsed.companyId, [
|
|
{ id: parsed.goalId, table: "goals", label: "goal" },
|
|
{ id: parsed.leadAgentId, table: "agents", label: "lead agent" },
|
|
]);
|
|
if (invalidReference) {
|
|
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
|
}
|
|
const id = db.saveProject(parsed);
|
|
db.addActivity({
|
|
companyId: parsed.companyId,
|
|
actor: "board",
|
|
action: parsed.id ? "project.updated" : "project.created",
|
|
entityType: "project",
|
|
entityId: id,
|
|
detail: parsed.name,
|
|
});
|
|
if (parsed.leadAgentId) {
|
|
wakeAgentIfPossible(parsed.leadAgentId, parsed.companyId, "assignment");
|
|
}
|
|
publishDomainChanged();
|
|
return ok(id);
|
|
});
|
|
|
|
registerHandle(IPC_CHANNELS.saveTask, async (_event, payload) => {
|
|
const parsed = taskInputSchema.parse(payload);
|
|
const companyError = ensureCompanyExists(parsed.companyId);
|
|
if (companyError) return companyError;
|
|
const invalidReference = findInvalidReference(parsed.companyId, [
|
|
{ id: parsed.projectId, table: "projects", label: "project" },
|
|
{ id: parsed.goalId, table: "goals", label: "goal" },
|
|
{ id: parsed.parentId, table: "tasks", label: "parent task" },
|
|
{ id: parsed.assigneeAgentId, table: "agents", label: "assignee agent" },
|
|
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
|
]);
|
|
if (invalidReference) {
|
|
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
|
}
|
|
const isNew = !parsed.id;
|
|
if (isNew) {
|
|
const validation = validateTaskCreation(parsed);
|
|
if (!validation.valid) {
|
|
return fail("TASK_VALIDATION_FAILED", validation.errors.join("; "));
|
|
}
|
|
}
|
|
let previousStatus: string | null = null;
|
|
let previousAssigneeAgentId: string | null = null;
|
|
if (!isNew) {
|
|
const taskId = parsed.id;
|
|
if (!taskId) return fail("INVALID_TASK_STATE", "Task ID required for updates.");
|
|
try {
|
|
const existingTask = db.getTask(taskId);
|
|
previousStatus = existingTask.status;
|
|
previousAssigneeAgentId = existingTask.assigneeAgentId;
|
|
} catch (err) {
|
|
logger.warn(`[ipc/task-graph] getTask failed for ${taskId}; treating as new task:`, err instanceof Error ? err.message : String(err));
|
|
previousStatus = null;
|
|
previousAssigneeAgentId = null;
|
|
}
|
|
}
|
|
let id: string;
|
|
try {
|
|
id = db.saveTask(parsed);
|
|
} catch (error) {
|
|
return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved.");
|
|
}
|
|
db.addActivity({
|
|
companyId: parsed.companyId,
|
|
actor: "board",
|
|
action: isNew ? "task.created" : "task.updated",
|
|
entityType: "task",
|
|
entityId: id,
|
|
detail: parsed.title,
|
|
});
|
|
if (isNew) {
|
|
handleTaskCreated(id, parsed.companyId);
|
|
} else {
|
|
if (previousStatus && previousStatus !== parsed.status) {
|
|
handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status);
|
|
}
|
|
// User (board) manually advancing a flagged task to done: clear the review flag
|
|
if (parsed.status === "done") {
|
|
try {
|
|
db.setTaskRequiresUserReview(id, false);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[ipc-task] setTaskRequiresUserReview(false) failed for task ${id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
// Wake new assignee when task is reassigned (matches api-server.ts behavior)
|
|
if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) {
|
|
wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment");
|
|
}
|
|
}
|
|
let capabilityWarning: string | undefined;
|
|
if (isNew && parsed.assigneeAgentId) {
|
|
try {
|
|
const match = checkCapabilityMatch(db, parsed.assigneeAgentId, parsed.description, parsed.companyId);
|
|
if (!match.matched) {
|
|
capabilityWarning = match.warning;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[ipc-task] capability check failed for new task ${id} (advisory, not blocking): ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" });
|
|
return ok({ id, capabilityWarning });
|
|
});
|
|
|
|
const deleteHandlers = [
|
|
{ channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) },
|
|
{ channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) },
|
|
{ channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) },
|
|
{ channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) },
|
|
] as const;
|
|
|
|
for (const { channel, table, entity, deleteFn } of deleteHandlers) {
|
|
registerHandle(channel, async (_event, payload) => {
|
|
const parsed = deleteEntitySchema.parse(payload);
|
|
const companyError = ensureCompanyExists(parsed.companyId);
|
|
if (companyError) return companyError;
|
|
if (!db.belongsToCompany(table, parsed.id, parsed.companyId)) {
|
|
return fail(`${entity.toUpperCase()}_NOT_FOUND`, `${entity.charAt(0).toUpperCase() + entity.slice(1)} not found in the current company.`);
|
|
}
|
|
deleteFn(parsed.id, parsed.companyId);
|
|
db.addActivity({
|
|
companyId: parsed.companyId,
|
|
actor: "board",
|
|
action: `${entity}.deleted`,
|
|
entityType: entity,
|
|
entityId: parsed.id,
|
|
detail: "",
|
|
});
|
|
if (entity === "task") {
|
|
emitEvent({ type: "task-updated", taskId: parsed.id, action: "deleted" });
|
|
} else {
|
|
publishDomainChanged();
|
|
}
|
|
return ok(true);
|
|
});
|
|
}
|
|
}
|