diff --git a/src/main/connector-matching.ts b/src/main/connector-matching.ts new file mode 100644 index 00000000..226cd5e6 --- /dev/null +++ b/src/main/connector-matching.ts @@ -0,0 +1,167 @@ +/** + * Deterministic connector-matching heuristic. + * + * Maps task types to the best available connector based on a static + * preference table. Provides fallback logic when preferred connectors + * are offline or unavailable. + */ +import type { ConnectorId, ConnectorStatus, TaskType } from "@shared/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ConnectorMatchInput { + taskType: TaskType; + agentRole: string; + agentDepartment: string | null; + availableConnectors: Array<{ + id: ConnectorId; + status: ConnectorStatus; + label: string; + }>; +} + +export interface ConnectorMatchResult { + recommendedConnectorId: ConnectorId; + fallbackConnectorId: ConnectorId | null; + reason: string; +} + +// --------------------------------------------------------------------------- +// Static preference tables +// --------------------------------------------------------------------------- + +/** + * For each task type, connectors ordered from most preferred to least. + */ +export const TASK_TYPE_PREFERENCE: Record = { + code: ["claude_local", "codex_local", "gemini_local"], + research: ["gemini_local", "claude_local", "codex_local"], + content: ["claude_local", "gemini_local", "codex_local"], + management: ["claude_local", "gemini_local", "codex_local"], + general: ["claude_local", "codex_local", "gemini_local"], +}; + +/** + * Maps department strings to the most likely task type for that department. + */ +export const ROLE_DEPARTMENT_HINTS: Record = { + engineering: "code", + research: "research", + marketing: "content", + product: "content", + hr: "management", + finance: "management", + executive: "management", + design: "content", + customer_support: "content", + operations: "management", + sales: "content", + legal: "management", +}; + +/** + * Human-readable capability descriptions per connector. + */ +export const CONNECTOR_STRENGTHS: Record = { + claude_local: + "Best for code generation, content writing, complex reasoning. Supports session resume, cost tracking.", + codex_local: + "Optimized for code editing and generation. Supports session resume, structured output.", + gemini_local: + "Strong at research, analysis, and large-context tasks. Good for data processing.", +}; + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +const READY_STATUSES: Set = new Set(["ready"]); + +/** + * Infer the most likely task type from a department name. + * Returns 'general' for null, undefined, or unrecognised departments. + */ +export function inferTaskType( + department: string | null | undefined, +): TaskType { + if (!department) return "general"; + const lower = department.toLowerCase(); + return ROLE_DEPARTMENT_HINTS[lower] ?? "general"; +} + +/** + * Get a human-readable description of a connector's strengths. + */ +export function getConnectorStrengths(connectorId: ConnectorId): string { + return CONNECTOR_STRENGTHS[connectorId] ?? "General-purpose AI connector."; +} + +/** + * Select the best connector for a given task type and set of available + * connectors. Falls back gracefully when preferred connectors are offline. + * + * Algorithm: + * 1. Filter connectors to those with "ready" status. + * 2. Walk the preference list for the given task type. + * 3. Return the first ready match as recommended. + * 4. Set fallback to the next *different* ready connector in the list. + * 5. If no connectors are ready, use the first available connector. + * 6. If no connectors at all, default to 'claude_local'. + */ +export function matchConnector(input: ConnectorMatchInput): ConnectorMatchResult { + const { taskType, availableConnectors } = input; + const prefList = TASK_TYPE_PREFERENCE[taskType] ?? TASK_TYPE_PREFERENCE.general; + + // Partition into ready vs all + const readySet = new Set(); + for (const c of availableConnectors) { + if (READY_STATUSES.has(c.status)) { + readySet.add(c.id); + } + } + + // If we have ready connectors, walk the preference list + if (readySet.size > 0) { + let recommended: ConnectorId | null = null; + let fallback: ConnectorId | null = null; + + for (const connId of prefList) { + if (readySet.has(connId)) { + if (recommended === null) { + recommended = connId; + } else if (fallback === null && connId !== recommended) { + fallback = connId; + break; // We have both recommended and fallback + } + } + } + + // recommended should always be set if readySet.size > 0, but guard + if (recommended) { + return { + recommendedConnectorId: recommended, + fallbackConnectorId: fallback, + reason: `Best match for ${taskType} tasks: ${CONNECTOR_STRENGTHS[recommended]}`, + }; + } + } + + // No ready connectors -- fall back to first available connector + if (availableConnectors.length > 0) { + const first = availableConnectors[0]; + return { + recommendedConnectorId: first.id, + fallbackConnectorId: null, + reason: `No ready connectors available. Using ${first.label} as fallback (status: ${first.status}).`, + }; + } + + // Ultimate fallback -- no connectors at all + return { + recommendedConnectorId: "claude_local", + fallbackConnectorId: null, + reason: "No connectors available. Defaulting to claude_local.", + }; +} diff --git a/src/main/database.ts b/src/main/database.ts index cad06710..dae07aa2 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -2156,6 +2156,9 @@ export class AppDatabase { addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120"); addColumnIfMissing("alter table agents add column last_heartbeat_at text"); addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0"); + addColumnIfMissing("alter table tasks add column task_type text not null default 'general'"); + addColumnIfMissing("alter table tasks add column retry_count integer not null default 0"); + addColumnIfMissing("alter table tasks add column last_failure_at text"); this.db.exec(` create table if not exists task_comments ( id text primary key, @@ -3525,6 +3528,42 @@ export class AppDatabase { }; } + // --------------------------------------------------------------------------- + // Task retry and type helpers (used by connector-matching and crash-recovery) + // --------------------------------------------------------------------------- + + getTaskRetryCount(taskId: string): number { + const row = this.db + .prepare("select retry_count from tasks where id = ?") + .get(taskId) as { retry_count: number } | undefined; + return row?.retry_count ?? 0; + } + + incrementTaskRetryCount(taskId: string): void { + const ts = nowIso(); + this.db + .prepare( + "update tasks set retry_count = retry_count + 1, last_failure_at = ?, updated_at = ? where id = ?", + ) + .run(ts, ts, taskId); + } + + resetTaskRetryCount(taskId: string): void { + const ts = nowIso(); + this.db + .prepare( + "update tasks set retry_count = 0, last_failure_at = null, updated_at = ? where id = ?", + ) + .run(ts, taskId); + } + + setTaskType(taskId: string, taskType: string): void { + const ts = nowIso(); + this.db + .prepare("update tasks set task_type = ?, updated_at = ? where id = ?") + .run(taskType, ts, taskId); + } + private openConnection(): SqliteDatabase { return new DatabaseSync(this.dbPath); } diff --git a/src/shared/types.ts b/src/shared/types.ts index fe37e0de..f4d79ca9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -37,6 +37,8 @@ export type ConnectorStatus = | "degraded" | "error"; +export type TaskType = "code" | "research" | "content" | "management" | "general"; + export type WorkspaceState = "ready" | "busy" | "blocked" | "missing" | "error"; export type CompanyStatus = "active" | "paused" | "archived"; export type AgentStatus =