Files
PaperClipAI/server/src/services/durable-chat-wakeup.ts
T
DottaandPaperclip 208a093d19 fix(chat): preserve resumed review waits and retry newer saved input (#17725)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/36/changes/paperclip/36
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - GitHub review agents publish findings and wait for an authorized
reply or head change.
> - Review tools could retain the requester after resume while
completion lost that same wait proof.
> - A yielded report could then queue its own continuation or repeat a
missing-wait error.
> - Ordinary Retry could also select an older failed turn while a newer
saved request had no admission.
> - This change shares the existing authority proofs and canonical
admission owners.
> - Current access, publication, execution and budget gates remain
required.

## Linked Issues or Issue Description

Refs #17318. Builds on the canonical saved-input retry owner in #17733.
Related #16550 covers deferred legacy comments after ownership waits.
The separate recovery repair in #17724 owns the cleanup-to-saved-input
handoff; this PR does not add a second admission owner.

A resumed review could publish an assessment, then turn its
`response_wake` into an immediate `issue_status_changed` wake. A
provider that omitted its continuation could instead repeat a completion
error. A newer linked-human message saved during a budget hold could
also remain unstarted because Retry selected the older failed source.

## What Changed

- Resolve passive review waits through the accepted requester ledger
used by GitHub tools. Require the latest reviewed head, exact company,
task, agent, repository and processed publication under current access.
- Fill omitted/null yielded continuation from that server proof. Permit
an inherited normal tree resume only through its accepted parent
identity. Permit an ordinary follow-up only after this run has a
processed useful reply linked to its exact inbound message. Reject
acknowledgements, partial assessments, pending or uncertain writes as
that reply proof.
- Recheck the same run/review/head/source proof during feedback,
finalization and final status commit. Preserve explicit malformed-input
rejection and Done policy. Add no monitor permission or publication
grant.
- Keep heartbeat and publication projections narrow. Preserve both
published Copilot v62 identities and generate source-bound v63 from the
pinned merged base. No v63 live qualification is claimed.
- Certify the structured isolated-directory binding failure only at its
pre-allocation boundary and only through canonical task Retry. Other
workspace validation reasons and unknown effects remain denied.
- Route Retry to eligible newer saved input through the existing
canonical owner. Preserve the old result and skipped receipt. Require
current principal, source, App, runtime fence, budget, native ownership
and exact cleanup proof.
- Recover absent historical App metadata only through an earlier trusted
processed response receipt and an immutable non-null endpoint claim.
Explicit malformed or conflicting metadata stays denied.
- Revalidate execution through the exact claimed wake, coalesced task
Retry and resolved user-continuation receipt after admission. A second
normal Retry can recover the exact never-started authorization failure
with unchanged saved input. Require empty execution evidence, exact
diagnostics and no allocated or active executor.
- Explain when a recorded result prevents exact replay. Ordinary board
Retry directs a new task message to continue from saved work. Inherited
requester identity alone never grants replay.
- Keep accepted board-message preparation failures with the canonical
task Retry owner. Certify the exact unchanged comment, original user
wake, accepted same-user identity and absence of provider execution. Pin
that source, recheck it at admission, and bind identity initialization
to the coalesced intent and exact claimed successor receipt.
Uncertifiable sources retain an actionable fresh-message refusal.
- Return safe named authority categories for board Retry refusals.
Update execution documentation and capability maps. Add no schema or
polling loop.

## Verification

- A fresh external review request exposed a separate isolated-directory
preparation failure before allocation. Its first normal Retry saved an
intent but admitted no successor. This follow-up certifies only that
exact structured preparation boundary through the existing canonical
Retry owner, with trusted failed settlement and no executor, lease,
result or output. A real HTTP/queue regression reproduces the missing
successor before the repair. Twenty-three current-source safety cases
and the final 98-case Retry/HTTP group pass, including a fresh click
after a denied intent, source revocation, accepted results and the older
direct API remaining denied. The warning case uses actual configuration
preparation and production event append; unknown warning shape or
provider provenance stays denied. Full workspace typecheck, build, maps
and diff checks pass. Renewed hosted normal Retry now succeeds on the
final combined source: it preserves the exact saved request and linked
requester, starts one native successor in a fresh attempt workspace,
publishes the current-head 2/5 assessment and commits In review with
passive response wake.

- A later hosted normal Retry published a low-score assessment but lost
its accepted source identity because the previous attempt failed before
identity initialization. It repeated continuation errors, then submitted
Done. This is failed acceptance, not a successful qualification. The
initializer now retains the exact source only after execution-time
failed-chat Retry authorization; generic Retry and Done policy are
unchanged. A real queue/initializer regression fails with a null source
on the previous code and passes with the newer saved source. Two real
database completion cases verify that source yields the authoritative
wait and commits In review. Renewed hosted normal Retry now retains the
accepted source and exposes the authoritative current-review wait. It
completed after two semantic feedback corrections (wrong contract
revision, then wrong criterion IDs); the correct contract was present in
emitted input. The third finish was accepted and committed In review,
with no missing-wait loop or Done escalation.
- Repeated board-message preparation failures now walk their canonical
intent, failed dispatch and accepted parent identity, rechecking each
contained predecessor. A production queue regression fails the second
ordinary HTTP Retry before the repair and passes afterward. Changed
source, contradictory execution evidence and cyclic receipts remain
denied; traversal stops after twenty links.

- New inherited omission cases failed before the repair. The final full
PostgreSQL 18 wait/status suite passes **252 tests** under Node 24.22.
It covers omitted/null input, malformed-first recovery, same-head
resume, exact-source replies, changed heads, revoked access,
pending/uncertain/mixed publications, final commit revalidation, no
duplicate review and no self-wake.
- **15 existing publication tests passed before the pinned merge** on a
separate fresh database. They retain default partial-delivery and
failure-fallback behavior. Context reads remain below the unchanged 16
KiB heartbeat and publication response limits with large saved output
present.
- Saved-input tests cover a real signed PR webhook, production
accounting-budget refusal, normal policy recovery and canonical Retry.
Historical producer-shaped tests prove the missing-App case red, then
check trusted prior receipts and malformed, late, revoked or mismatched
proof. A broader order-coupled test was reproduced on the prior source;
its global fault injector now targets its own authorization query. The
43-case sequence passes without changing production behavior or its
safety assertions.
- New signed-ingress and real heartbeat execution tests reproduced the
admitted-successor failure, then the next normal Retry failure. The
final pinned merged source passes 249 full GitHub review/publication and
Retry cases on a fresh database, including all 49 saved-input cases. A
separate 74-case Retry/HTTP run also passes. Exact dispatch negatives
cover source, company, agent, task, successor, receipt, pause,
assignment, accepted success, controller and cleanup proof. Bootstrap
contradictions remain denied. The canonical Retry suite passes 16 cases
through its supported external PostgreSQL test connection.
- The normal board HTTP route reproduced the unusable inherited-result
refusal. It now returns actionable guidance without starting a
successor. The actual UI Retry hook and banner tests pass 12 cases.
- A production-shaped accepted board comment followed by a pre-provider
qualification failure reproduced HTTP 409 before the repair. The final
real HTTP/queue test certifies one canonical successor and preserves the
accepted identity. It rejects changed source, actor, task, agent,
result, controller, process, profile, allocation and receipt evidence. A
post-acceptance source change keeps the same intent pending. Execution
revalidation rejects removed or mismatched proof. The probe stops
deliberately after production identity initialization; it does not claim
provider execution. The final source passes the 75-case Retry group, the
focused two-case board/inherited-policy group and 19 canonical Retry
cases. Full workspace typecheck, build and map checks pass.
- Full workspace `pnpm -r typecheck` and `pnpm build` pass. Map,
module-boundary and diff checks pass. Full local `pnpm test:run` remains
unverified. The 256-case explicit-native suite cannot initialize its
packaged PostgreSQL because libzstd is absent; an existing
command-scoped fallback did not resolve it. Current-head remote
full-suite checks are required. Merged-source full GitHub+Retry (249),
separate Retry/HTTP (74), full wait/status (252), profile/history and
completion (75), and visible Retry hook/banner (12) checks pass. The
fresh owned PostgreSQL 18 databases applied all 377 migrations.
- Actual Rust Runner, OpenCode 1.18.34 and exact DeepSeek used unchanged
production-generated synthetic contexts. Resume finished with an
explicit server wait. The author-reply trial omitted continuation twice:
its first report failed the existing empty-criteria check; the corrected
second report still omitted continuation and received the exact server
wait. It completed once with no missing-wait loop or Done escalation.
Controller acknowledgments were synthetic; these probes do not establish
hosted publication or board state.
- Hosted combined testing proved automatic admission of the exact saved
author reply, a useful GitHub answer and committed passive wait. It
required two malformed-continuation rejections before a nullish fallback
succeeded. A later normal Pause/Resume completed in one finish with
explicit response_wake and no new review/comment. The admitted normal
Retry then exposed the execution guard gap repaired here. Those remain
checkpoint results. On the final combined source, renewed normal Retry
completed actual native provider work and GitHub publication, committed
the passive wait, released ownership and cleaned up its environment. A
subsequent ordinary unmentioned author reply woke once, made no GitHub
tool calls or new assessment, and completed its first finish with an
explicit response wake. Both exact leases expired with successful
cleanup and the queue was empty. However, final destination inspection
found a separate automatic fallback overwrote the existing summary with
a generic no-final-response message. External summary preservation
therefore remains failed acceptance; the separate publication repair in
#17726 owns that producer. Full combined publication preservation has
not passed. These hosted runs include the pinned-base native additions;
they do not qualify Copilot v63.
- Current-head automated review is 5/5 with zero unresolved threads. All
current-head CI gates passed (54 successful checks/contexts, four
skipped, none pending). The earlier login-cleanup idle-work assertion
also reproduced locally while its controlled baseline passed; its cause
remains unknown, but the exact assertion passed in the fresh
current-head CI shard. No timeout, assertion or production workaround
was added. This PR is ready for review within its source, authority,
native finalization, state, wake and cleanup scope. The independent
automatic fallback publication regression is tracked by #17726; no
complete combined workflow pass is claimed.

## Risks

- The latest saved review defines the durable head boundary. Ingress
must record a new head before admission. A reply receipt proves delivery
only for its exact saved source; a shared GitHub comment ID alone cannot
authorize this wait.
- Historical App recovery cannot infer identity without direct proof or
an earlier valid server response receipt. It never replays that
response.
- This does not infer review-score policy from instructions or override
Done. No deployment, merge or repair of historical tasks is claimed by
the PR.

## Model Used

OpenAI `gpt-6.1-sol`, with reasoning, repository inspection, code
editing and test execution. The context-window size is not exposed in
this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused database and
native suites; full local suite limitation described above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (verified on immutable latest
head)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(verified on the latest head)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-26 22:22:47 -05:00

385 lines
14 KiB
TypeScript

import { createHash } from "node:crypto";
import { agentWakeupRequests, chatActions, chatDeliveries, chatEndpoints, type Db } from "@paperclipai/db";
import { and, eq, inArray, or, sql, type SQLWrapper } from "drizzle-orm";
import { HttpError } from "../errors.js";
export interface CommittedChatResponseAuthorizationInput {
companyId: string;
issueId: string;
agentId: string;
runId: string;
resultId: string;
}
type CommittedChatResponseAuthority = (
tx: Db,
input: CommittedChatResponseAuthorizationInput,
) => Promise<void>;
const committedChatResponseAuthorities = new WeakMap<
Db,
CommittedChatResponseAuthority
>();
export class CommittedChatResponseAuthorizationError extends HttpError {
constructor() {
super(
409,
"The accepted chat response is no longer authorized for this conversation.",
{
code: "chat_committed_response_not_authorized",
},
);
this.name = "CommittedChatResponseAuthorizationError";
}
}
/** Separate, server-only authority for presenting an already accepted result.
* This grants no permission to execute or repair the provider session. */
export function registerCommittedChatResponseAuthority(
db: Db,
authority: CommittedChatResponseAuthority,
): () => void {
committedChatResponseAuthorities.set(db, authority);
return () => {
if (committedChatResponseAuthorities.get(db) === authority) {
committedChatResponseAuthorities.delete(db);
}
};
}
export async function authorizeCommittedChatResponse(
db: Db,
tx: Db,
input: CommittedChatResponseAuthorizationInput,
): Promise<void> {
const authority = committedChatResponseAuthorities.get(db);
if (!authority) throw new CommittedChatResponseAuthorizationError();
await authority(tx, input);
}
export interface FailedChatRunRetryAuthorizationInput {
phase: "admission" | "promotion" | "execution";
wakeupRequestId: string | null;
companyId: string;
agentId: string;
issueId: string | null;
runId?: string;
contextSnapshot: Record<string, unknown>;
}
type FailedChatRunRetryAuthority = (
tx: Db,
input: FailedChatRunRetryAuthorizationInput & {
wakeupRequestId: string;
issueId: string;
},
) => Promise<void>;
const failedChatRunRetryAuthorities = new WeakMap<
Db,
FailedChatRunRetryAuthority
>();
export class FailedChatRunRetryAuthorizationError extends HttpError {
constructor() {
super(
409,
"The exact failed chat request is no longer authorized. Send a new request in the current connected conversation.",
{
code: "chat_failed_run_retry_not_authorized",
},
);
this.name = "FailedChatRunRetryAuthorizationError";
}
}
/** The live chat service supplies the authority; serialized wake hints never do. */
export function registerFailedChatRunRetryAuthority(
db: Db,
authority: FailedChatRunRetryAuthority,
): () => void {
failedChatRunRetryAuthorities.set(db, authority);
return () => {
if (failedChatRunRetryAuthorities.get(db) === authority) {
failedChatRunRetryAuthorities.delete(db);
}
};
}
/** Re-discover retry provenance from the durable action, including after restart
* or when an untrusted caller strips the serialized retry selector. */
export async function authorizeFailedChatRunRetryWake(
db: Db,
tx: Db,
input: FailedChatRunRetryAuthorizationInput,
): Promise<boolean> {
const action = input.wakeupRequestId
? await tx
.select({ companyId: chatActions.companyId, kind: chatActions.kind, payload: chatActions.payload })
.from(chatActions)
.where(eq(chatActions.id, input.wakeupRequestId))
.limit(1)
.then((rows) => rows[0] ?? null)
: null;
if (action?.kind !== "failed_run_retry") {
if (Object.hasOwn(input.contextSnapshot, "chatFailedRunRetry")) {
throw new FailedChatRunRetryAuthorizationError();
}
return false;
}
const authority = failedChatRunRetryAuthorities.get(db);
const selector = input.contextSnapshot.chatFailedRunRetry;
if (
!authority ||
action.companyId !== input.companyId ||
!input.issueId ||
!input.wakeupRequestId ||
!selector ||
typeof selector !== "object" ||
Array.isArray(selector) ||
(selector as Record<string, unknown>).version !== 1 ||
(selector as Record<string, unknown>).actionId !== input.wakeupRequestId ||
typeof (selector as Record<string, unknown>).failedRunId !== "string" ||
!(selector as Record<string, unknown>).failedRunId ||
(selector as Record<string, unknown>).failedRunId !==
input.contextSnapshot.retryOfRunId ||
(input.contextSnapshot.forceFreshSession === true &&
(typeof action.payload.taskRetryIntentId !== "string" ||
input.contextSnapshot.taskRetryRequestId !== action.payload.taskRetryIntentId)) ||
["resumeFromRunId", "resumeSessionParams", "resumeSessionDisplayId"].some(
(key) => Object.hasOwn(input.contextSnapshot, key),
)
) {
throw new FailedChatRunRetryAuthorizationError();
}
await authority(tx, {
...input,
issueId: input.issueId,
wakeupRequestId: input.wakeupRequestId,
});
return true;
}
/** Prevent automatic recovery from treating committed-but-unadmitted input as
* an ordinary stranded task. A receipt is historical admission evidence even
* if its run subsequently fails or is cancelled; skipped was never admitted. */
export function unadmittedChatWakeupCondition(
issueId: SQLWrapper,
companyId: SQLWrapper,
) {
return sql`exists (
select 1 from chat_actions chat_unadmitted
where chat_unadmitted.company_id = ${companyId}
and chat_unadmitted.payload->>'issueId' = ${issueId}::text
and chat_unadmitted.kind = 'inbound_wakeup'
and chat_unadmitted.status in ('preparing', 'issued', 'processing', 'failed')
and not exists (
select 1 from agent_wakeup_requests chat_admission
where chat_admission.id = chat_unadmitted.id
and chat_admission.company_id = chat_unadmitted.company_id
and chat_admission.agent_id::text = chat_unadmitted.payload->>'agentId'
and chat_admission.status <> 'skipped'
)
and not exists (
select 1 from chat_actions chat_retry
join agent_wakeup_requests chat_retry_admission on chat_retry_admission.id = chat_retry.id
where chat_retry.company_id = chat_unadmitted.company_id
and chat_retry.endpoint_id = chat_unadmitted.endpoint_id
and chat_retry.conversation_id = chat_unadmitted.conversation_id
and chat_retry.kind = 'failed_run_retry'
and chat_retry.payload->'taskRetryInput'->'skippedActionIds' ? chat_unadmitted.id::text
and chat_retry.payload->>'issueId' = chat_unadmitted.payload->>'issueId'
and chat_retry_admission.company_id = chat_retry.company_id
and chat_retry_admission.agent_id::text = chat_retry.payload->>'agentId'
and chat_retry_admission.status <> 'skipped'
)
and (chat_unadmitted.status <> 'failed' or not exists (
select 1 from chat_actions chat_later
join agent_wakeup_requests chat_later_admission on chat_later_admission.id = chat_later.id
where chat_later.company_id = chat_unadmitted.company_id
and chat_later.endpoint_id = chat_unadmitted.endpoint_id
and chat_later.conversation_id = chat_unadmitted.conversation_id
and chat_later.payload->>'sessionGeneration' = chat_unadmitted.payload->>'sessionGeneration'
and chat_later.payload->>'issueId' = chat_unadmitted.payload->>'issueId'
and chat_later.kind = 'inbound_wakeup'
and (chat_later.created_at, chat_later.id) > (chat_unadmitted.created_at, chat_unadmitted.id)
and chat_later_admission.company_id = chat_later.company_id
and chat_later_admission.agent_id::text = chat_later.payload->>'agentId'
and chat_later_admission.status <> 'skipped'
))
)`;
}
export interface DeferredChatWakeScope {
companyId: string;
agentId: string;
issueId: string;
requestId: string;
}
const deferredChatWakeOwners = new WeakMap<Db, (input: DeferredChatWakeScope) => Promise<void>>();
/** The live inbound owner reauthorizes the original source. Cleanup owns no chat authority. */
export function registerDeferredChatWakeOwner(db: Db, owner: (input: DeferredChatWakeScope) => Promise<void>) {
deferredChatWakeOwners.set(db, owner);
return () => { if (deferredChatWakeOwners.get(db) === owner) deferredChatWakeOwners.delete(db); };
}
export async function resumeDeferredChatWake(db: Db, input: DeferredChatWakeScope) {
await deferredChatWakeOwners.get(db)?.(input);
}
/** Internal authority: this object cannot be supplied through a JSON API. */
export interface DurableChatWakeupRequest {
readonly id: string;
readonly companyId: string;
readonly agentId: string;
readonly issueId: string;
readonly commentId: string;
readonly requestedByActorType: "user" | "system";
readonly requestedByActorId: string;
readonly requestedAt: Date;
readonly idempotencyKey: string;
readonly failedRunRetry?: { readonly failedRunId: string };
/** Only the current inbound owner may reconsider its already saved, unconsumed receipt. */
readonly resumeDeferred?: true;
readonly authorize: (tx: Db) => Promise<void>;
readonly onAdmitted?: (tx: Db, receipt: { runId: string | null }) => Promise<void>;
}
const trustedRequests = new WeakSet<object>();
export function createDurableChatWakeupRequest(
input: Omit<DurableChatWakeupRequest, "idempotencyKey">,
): DurableChatWakeupRequest {
const idempotencyKey = `chat-inbound:${createHash("sha256")
.update(
JSON.stringify([
input.id,
input.companyId,
input.agentId,
input.issueId,
input.commentId,
input.requestedByActorType,
input.requestedByActorId,
]),
)
.digest("hex")}`;
const request = Object.freeze({
...input,
...(input.failedRunRetry
? { failedRunRetry: Object.freeze({ ...input.failedRunRetry }) }
: {}),
idempotencyKey,
});
trustedRequests.add(request);
return request;
}
export function assertDurableChatWakeupRequest(
request: DurableChatWakeupRequest,
scope: {
agentId: string;
companyId: string;
issueId: string | null;
commentId: string | null;
requestedByActorType?: string;
requestedByActorId?: string | null;
},
) {
if (
!trustedRequests.has(request) ||
!scope.issueId ||
request.agentId !== scope.agentId ||
request.companyId !== scope.companyId ||
request.issueId !== scope.issueId ||
request.commentId !== scope.commentId ||
request.requestedByActorType !== scope.requestedByActorType ||
request.requestedByActorId !== scope.requestedByActorId
) {
throw new Error("chat_inbound_wakeup_binding_denied");
}
}
export function assertDurableChatWakeupReceipt(
request: DurableChatWakeupRequest,
receipt: {
id: string;
companyId: string;
agentId: string;
idempotencyKey: string | null;
requestedByActorType: string | null;
requestedByActorId: string | null;
},
) {
if (
receipt.id !== request.id ||
receipt.companyId !== request.companyId ||
receipt.agentId !== request.agentId ||
receipt.idempotencyKey !== request.idempotencyKey ||
receipt.requestedByActorType !== request.requestedByActorType ||
receipt.requestedByActorId !== request.requestedByActorId
) {
throw new Error("chat_inbound_wakeup_receipt_conflict");
}
}
export function withDurableChatAdmission<T extends { kind: string; admitted?: boolean }>(request: DurableChatWakeupRequest | undefined,
admit: (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => Promise<T>) {
return async (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => {
const outcome = await admit(tx);
// An existing deferred receipt is not proof this transaction passed its
// gates. Only dispatch or an explicitly persisted wait admits the input.
if (request?.onAdmitted && (outcome.admitted === true || ["queued", "durable", "coalesced"].includes(outcome.kind))) {
const [receipt] = await tx.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, request.companyId), eq(agentWakeupRequests.id, request.id),
inArray(agentWakeupRequests.status, ["queued", "claimed", "deferred_issue_execution", "coalesced"]),
));
if (receipt) {
assertDurableChatWakeupReceipt(request, receipt);
await request.onAdmitted(tx as unknown as Db, { runId: receipt.runId });
}
}
return outcome;
};
}
/** Work-start policy only: ongoing tool and publication authorization must not
* revoke an already-started run when the operator enables mention gating. */
export async function slackMentionAllowsRunStart(
db: Db,
run: { id: string; companyId: string; agentId: string; wakeupRequestId: string | null },
): Promise<boolean> {
if (!run.wakeupRequestId) return true;
const inputs = await db.select({ event: chatDeliveries.normalizedEvent })
.from(agentWakeupRequests)
.innerJoin(chatActions, and(
eq(chatActions.id, agentWakeupRequests.id),
eq(chatActions.companyId, run.companyId),
eq(chatActions.kind, "inbound_wakeup"),
))
.innerJoin(chatEndpoints, and(
eq(chatEndpoints.id, chatActions.endpointId),
eq(chatEndpoints.companyId, run.companyId),
eq(chatEndpoints.provider, "slack"),
eq(chatEndpoints.requireAtMention, true),
))
.innerJoin(chatDeliveries, and(
eq(chatDeliveries.id, chatActions.deliveryId),
eq(chatDeliveries.endpointId, chatEndpoints.id),
eq(chatDeliveries.companyId, run.companyId),
))
.where(and(
eq(agentWakeupRequests.companyId, run.companyId),
eq(agentWakeupRequests.agentId, run.agentId),
or(
eq(agentWakeupRequests.runId, run.id),
eq(agentWakeupRequests.id, run.wakeupRequestId),
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${run.wakeupRequestId}`,
),
));
return inputs.every(({ event }) =>
(event.conversation as { isDirectMessage?: boolean } | undefined)?.isDirectMessage === true ||
event.trigger === "mention" ||
(event.message as { mentionedBot?: boolean } | undefined)?.mentionedBot === true);
}