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:
1 parent
e1693899a2
commit
6a37f41dfd
3 files changed
+46
-13
No files matched your search
@@ -165,6 +165,7 @@ export class AppDatabase extends AppDatabaseBase {
|
|||||||
setThreadWaitState(input: Parameters<ReturnType<typeof buildThreadMethods>["setThreadWaitState"]>[0]) { return this.threads.setThreadWaitState(input); }
|
setThreadWaitState(input: Parameters<ReturnType<typeof buildThreadMethods>["setThreadWaitState"]>[0]) { return this.threads.setThreadWaitState(input); }
|
||||||
resolveThreadWaitState(waitStateId: string, companyId: string) { return this.threads.resolveThreadWaitState(waitStateId, companyId); }
|
resolveThreadWaitState(waitStateId: string, companyId: string) { return this.threads.resolveThreadWaitState(waitStateId, companyId); }
|
||||||
listThreadWaitStates(companyId: string, options?: Parameters<ReturnType<typeof buildThreadMethods>["listThreadWaitStates"]>[1]) { return this.threads.listThreadWaitStates(companyId, options); }
|
listThreadWaitStates(companyId: string, options?: Parameters<ReturnType<typeof buildThreadMethods>["listThreadWaitStates"]>[1]) { return this.threads.listThreadWaitStates(companyId, options); }
|
||||||
|
hasUnresolvedWaitStateForAgent(companyId: string, agentId: string) { return this.threads.hasUnresolvedWaitStateForAgent(companyId, agentId); }
|
||||||
addParticipant(input: Parameters<ReturnType<typeof buildThreadMethods>["addParticipant"]>[0]) { return this.threads.addParticipant(input); }
|
addParticipant(input: Parameters<ReturnType<typeof buildThreadMethods>["addParticipant"]>[0]) { return this.threads.addParticipant(input); }
|
||||||
listParticipants(threadId: string) { return this.threads.listParticipants(threadId); }
|
listParticipants(threadId: string) { return this.threads.listParticipants(threadId); }
|
||||||
removeParticipant(threadId: string, participantId: string) { return this.threads.removeParticipant(threadId, participantId); }
|
removeParticipant(threadId: string, participantId: string) { return this.threads.removeParticipant(threadId, participantId); }
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
// ── Participants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
addParticipant(input: {
|
addParticipant(input: {
|
||||||
|
|||||||
@@ -330,22 +330,14 @@ export function createThreadIntegration({ db, logger, emitEvent, publishDomainCh
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a specific agent has unresolved thread wait states.
|
* Checks whether a specific agent has unresolved thread wait states
|
||||||
* Joins through conversation_threads → tasks to filter by assignee.
|
* across task, approval, and freeform threads. The query lives on
|
||||||
* Used by heartbeat scheduler to pause only the affected agent, not all agents.
|
* `db.hasUnresolvedWaitStateForAgent` so the heartbeat scheduler and
|
||||||
|
* the pi `beforeToolCall` hook share one source of truth.
|
||||||
*/
|
*/
|
||||||
hasUnresolvedWaitStatesForAgent(companyId: string, agentId: string): boolean {
|
hasUnresolvedWaitStatesForAgent(companyId: string, agentId: string): boolean {
|
||||||
try {
|
try {
|
||||||
const rows = db.queryAll<{ id: string }>(
|
return db.hasUnresolvedWaitStateForAgent(companyId, agentId);
|
||||||
`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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[thread-integration] hasUnresolvedWaitStatesForAgent query failed (company=${companyId} agent=${agentId}):`, err instanceof Error ? err.message : String(err));
|
logger.warn(`[thread-integration] hasUnresolvedWaitStatesForAgent query failed (company=${companyId} agent=${agentId}):`, err instanceof Error ? err.message : String(err));
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in new issue
Block a user