fix(threads): unify wait-state ownership across thread types

`hasUnresolvedWaitStatesForAgent` only joined through tasks, so
approval-only threads and ad-hoc threads where the agent is the
requesting party or an explicit participant were invisible. Result:
the heartbeat scheduler silently resumed agents that the board had
asked to wait on, Co-Founder approval chains deadlocked on the
agent side, and any new gate that relies on the query inherits the
same hole.

Root-fix the query at the SQL layer. `hasUnresolvedWaitStateForAgent`
now returns true when any of the three ownership predicates hold:

  - task thread whose task.assignee_agent_id = agent
  - approval thread whose approval.requested_by_agent_id = agent
  - any thread on which the agent is a recorded participant

Each branch is an EXISTS subquery so the query short-circuits on
first match. The thread-integration helper delegates here so the
heartbeat scheduler and the `beforeToolCall` gate share one source
of truth rather than reimplementing half-policies in parallel.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 00:51:40 -04:00
1 parent e1693899a2
commit 6a37f41dfd
3 files changed
+46 -13

No files matched your search

+40
View File
@@ -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: {