diff --git a/src/main/database.ts b/src/main/database.ts index 5fb54410..00342e4f 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -165,6 +165,7 @@ export class AppDatabase extends AppDatabaseBase { setThreadWaitState(input: Parameters["setThreadWaitState"]>[0]) { return this.threads.setThreadWaitState(input); } resolveThreadWaitState(waitStateId: string, companyId: string) { return this.threads.resolveThreadWaitState(waitStateId, companyId); } listThreadWaitStates(companyId: string, options?: Parameters["listThreadWaitStates"]>[1]) { return this.threads.listThreadWaitStates(companyId, options); } + hasUnresolvedWaitStateForAgent(companyId: string, agentId: string) { return this.threads.hasUnresolvedWaitStateForAgent(companyId, agentId); } addParticipant(input: Parameters["addParticipant"]>[0]) { return this.threads.addParticipant(input); } listParticipants(threadId: string) { return this.threads.listParticipants(threadId); } removeParticipant(threadId: string, participantId: string) { return this.threads.removeParticipant(threadId, participantId); } diff --git a/src/main/database/threads.ts b/src/main/database/threads.ts index 4c05b1d8..f5516fc2 100644 --- a/src/main/database/threads.ts +++ b/src/main/database/threads.ts @@ -214,6 +214,46 @@ export function buildThreadMethods(getDb: () => DatabaseSync, readAll: ReadAll) })); }, + /** + * Returns true when the agent owns an unresolved wait state on any + * thread type — task, approval, or freeform. Ownership is the union + * of three predicates so approval-only threads and ad-hoc threads + * are no longer invisible to the heartbeat scheduler and the + * `beforeToolCall` gate: + * + * 1. Task thread whose task is assigned to the agent. + * 2. Approval thread whose approval was requested by the agent. + * 3. Any thread on which the agent is a recorded participant. + * + * Each branch uses an EXISTS subquery so the query short-circuits + * as soon as one wait state matches. + */ + hasUnresolvedWaitStateForAgent(companyId: string, agentId: string): boolean { + const rows = readAll<{ id: string }>( + `SELECT tws.id FROM thread_wait_states tws + JOIN conversation_threads ct ON ct.id = tws.thread_id + WHERE tws.company_id = ? + AND tws.resolved_at IS NULL + AND ( + (ct.related_entity_type = 'task' AND EXISTS ( + SELECT 1 FROM tasks t + WHERE t.id = ct.related_entity_id AND t.assignee_agent_id = ? + )) + OR (ct.related_entity_type = 'approval' AND EXISTS ( + SELECT 1 FROM approvals a + WHERE a.id = ct.related_entity_id AND a.requested_by_agent_id = ? + )) + OR EXISTS ( + SELECT 1 FROM conversation_participants cp + WHERE cp.thread_id = tws.thread_id AND cp.participant_id = ? + ) + ) + LIMIT 1`, + companyId, agentId, agentId, agentId, + ); + return rows.length > 0; + }, + // ── Participants ────────────────────────────────────────────────────── addParticipant(input: { diff --git a/src/main/thread-integration/index.ts b/src/main/thread-integration/index.ts index 7773e525..897b706b 100644 --- a/src/main/thread-integration/index.ts +++ b/src/main/thread-integration/index.ts @@ -330,22 +330,14 @@ export function createThreadIntegration({ db, logger, emitEvent, publishDomainCh }, /** - * Checks whether a specific agent has unresolved thread wait states. - * Joins through conversation_threads → tasks to filter by assignee. - * Used by heartbeat scheduler to pause only the affected agent, not all agents. + * Checks whether a specific agent has unresolved thread wait states + * across task, approval, and freeform threads. The query lives on + * `db.hasUnresolvedWaitStateForAgent` so the heartbeat scheduler and + * the pi `beforeToolCall` hook share one source of truth. */ hasUnresolvedWaitStatesForAgent(companyId: string, agentId: string): boolean { try { - const rows = db.queryAll<{ id: string }>( - `SELECT tws.id FROM thread_wait_states tws - JOIN conversation_threads ct ON tws.thread_id = ct.id - JOIN tasks t ON ct.related_entity_id = t.id AND ct.related_entity_type = 'task' - WHERE tws.company_id = ? AND tws.resolved_at IS NULL - AND t.assignee_agent_id = ? - LIMIT 1`, - companyId, agentId, - ); - return rows.length > 0; + return db.hasUnresolvedWaitStateForAgent(companyId, agentId); } catch (err) { logger.warn(`[thread-integration] hasUnresolvedWaitStatesForAgent query failed (company=${companyId} agent=${agentId}):`, err instanceof Error ? err.message : String(err)); return false;