mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Task chat lets a person send follow-up messages to a running agent, and steer them into the live turn > - A sandboxed run (for example on Daytona) can take minutes to boot before its provider session exists > - A message sent during that boot cannot be steered, so it waits in the steering pane > - The runner then puts the message into its first turn, and the server removes it from the queue, but the view has no signal for this, so the message stays in the steering pane as "queued" > - That stale local entry also hides the queue id, so Steer and Discard fail for every later message > - This pull request tells the view which messages the first turn takes, and drops local queue entries whose run has settled > - The benefit is that a message sent while the sandbox boots shows as sent at once, and later messages keep working Steer and Discard ## Linked Issues or Issue Description **What happened?** A person sent a follow-up while the agent's sandbox was booting. The message stayed in the steering pane with Steer unavailable. After the agent started, the message stayed there. The agent had received it in its first turn. Later, Discard on that message failed with "Couldn't discard. Message is still queued.", and a new message also stayed in the steering pane. **Steps to reproduce** 1. Assign a task to a Paperclip Runner agent that runs in a Daytona sandbox. 2. While the sandbox boots, send a follow-up message. 3. Wait for the agent's first response. The message is still in the steering pane. 4. After the run hands off to a successor run, send another message and try to discard the first one. **Expected behavior** A message sent while the run is starting shows as a sent message. Later messages keep working Steer and Discard. **Actual behavior** The first message stays in the steering pane. Discard fails, and the new message cannot be steered until the page reloads. ## What Changed - `firstTurnQueuedCommentIds` (server, `first-turn-queued-input.ts`) returns the messages in a deferred queue that the target run's first turn already sealed, or will take while it is still starting. It uses the same eligibility rule as `claimFirstTurnQueuedInput`. It reads only lean columns, so the queue poll does not transfer run snapshots or large wake payloads. - The queue read path and the queue mutation path both put these ids in a new optional `firstTurnCommentIds` field on `IssueQueuedCommentQueue`. - The task view puts each of those messages in the conversation as input of that run, at its submission time, and not in the steering pane. - The task view no longer treats a saved message as a pending local queue entry after its target run is no longer live. A request that is still in flight keeps its original target. ## Verification - Red-green server test: `issue-queued-comments-routes.test.ts` "reports the messages a preparing/native run's first turn takes, before and after it seals them" and "reports no first-turn messages once the run's turn already started". Before the fix, `firstTurnCommentIds` was `undefined`. - Red-green UI tests: `IssueDetail.test.tsx` "shows a message the booting run's first turn takes as sent, not queued" (still starting, and sealed) and "keeps queue controls for a new message after the run that took an earlier one hands off". Before the fix, the queue held `['new-message', 'boot-message']` and the queue id was withheld. - Reproduced on staging first: the run's context held a first-turn receipt for the stuck message, and the server queue had no entries, while the view still showed it as queued. - `npx vitest run` for `issue-queued-comments-routes`, `queued-comment-consumption`, `queued-comment-postgres`, `queued-comment-use-cases`: 172 passed. The existing queue-poll byte budget tests still pass. - `npx vitest run src/pages/IssueDetail.test.tsx src/components/task-chat src/lib/issue-queued-comment-queue src/lib/optimistic-issue-comments`: 1288 passed. - `tsc --noEmit` for shared, server, and ui: no new errors. - `pnpm verify` exits 2 because the change touches `packages/shared`. Required CI supplies the broad coverage. ## Risks - Low risk. The new field is optional and only for display. Delivery still belongs to the first-turn claim and the deferred wake. - If a starting run fails before its first turn, the field goes away and the message shows in the queue again for the next run. This is correct, because the deferred wake still holds it. - The queue read adds one lean run read and one comment read only while a deferred queue targets a running run. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file edits, browser). ## 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 - [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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
345 lines
19 KiB
TypeScript
345 lines
19 KiB
TypeScript
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agentWakeupRequests, heartbeatRunEvents, heartbeatRuns, issueAttachments, issueComments, issues } from "@paperclipai/db";
|
|
import type { SourceTrustMetadata } from "@paperclipai/shared";
|
|
|
|
import { queuedCommentIdsFromWakePayload } from "../issue-queued-comment-queue.js";
|
|
import { deliveredQueuedCommentIds } from "../queued-comment-delivery.js";
|
|
import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js";
|
|
import { sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
|
|
import { PROVIDER_TURN_ACCEPTED_EVENT_TYPES } from "./provider-attempt-outcome.js";
|
|
|
|
/**
|
|
* Queued input for a run's first provider turn.
|
|
*
|
|
* A user's message that arrives while a run is still preparing waits as a
|
|
* deferred wake behind that run. Interrupting the run for it throws away the
|
|
* whole preparation. Instead, the run takes the waiting messages into the
|
|
* turn it is about to start: the runner calls `claimFirstTurnQueuedInput`
|
|
* immediately before `turn.start`, and the claim and the first-turn input are
|
|
* sealed in one transaction on the run row.
|
|
*
|
|
* The claim does not remove the messages from their deferred wake. It records
|
|
* a receipt in the run's context. A message counts as delivered only after the
|
|
* provider accepted that run's turn (`deliveredQueuedCommentIds`). If the run
|
|
* ends before that, the receipt proves nothing and the deferred wake still
|
|
* holds the message, so the next run delivers it exactly once. A restart
|
|
* before the turn was accepted repeats the same claim from the receipt.
|
|
*/
|
|
export const FIRST_TURN_QUEUED_INPUT_KEY = "firstTurnQueuedInput";
|
|
export const FIRST_TURN_QUEUED_INPUT_SCHEMA = "paperclip.first-turn-queued-input.v1";
|
|
|
|
const DEFERRED_WAKE_STATUS = "deferred_issue_execution";
|
|
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
|
const COMMENT_WAKE_REASONS = new Set(["issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned"]);
|
|
const MAX_COMMENT_CHARS = 16_000;
|
|
const MAX_TOTAL_CHARS = 48_000;
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
export type FirstTurnQueuedInputReceipt = {
|
|
schema: typeof FIRST_TURN_QUEUED_INPUT_SCHEMA;
|
|
runId: string;
|
|
wakeupRequestIds: string[];
|
|
commentIds: string[];
|
|
claimedAt: string;
|
|
};
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function stringIds(value: unknown): string[] {
|
|
return Array.isArray(value) ? [...new Set(value.filter((id): id is string => typeof id === "string" && UUID.test(id)))] : [];
|
|
}
|
|
|
|
/**
|
|
* The receipt this exact run sealed. A retry copies its predecessor's context,
|
|
* so a receipt naming another run is inert.
|
|
*/
|
|
export function readFirstTurnQueuedInputReceipt(run: { id: string; contextSnapshot?: unknown }): FirstTurnQueuedInputReceipt | null {
|
|
const receipt = record(record(run.contextSnapshot)[FIRST_TURN_QUEUED_INPUT_KEY]);
|
|
if (receipt.schema !== FIRST_TURN_QUEUED_INPUT_SCHEMA || receipt.runId !== run.id) return null;
|
|
const claimedAt = nonEmptyString(receipt.claimedAt);
|
|
if (!claimedAt) return null;
|
|
return {
|
|
schema: FIRST_TURN_QUEUED_INPUT_SCHEMA,
|
|
runId: run.id,
|
|
wakeupRequestIds: stringIds(receipt.wakeupRequestIds),
|
|
commentIds: stringIds(receipt.commentIds),
|
|
claimedAt,
|
|
};
|
|
}
|
|
|
|
/** Message ids a live run already took into its first turn; queue views hide them. */
|
|
export function firstTurnClaimedCommentIds(run: { id: string; status?: string | null; contextSnapshot?: unknown } | null | undefined): Set<string> {
|
|
if (!run || (run.status !== undefined && run.status !== "running")) return new Set();
|
|
return new Set(readFirstTurnQueuedInputReceipt(run)?.commentIds ?? []);
|
|
}
|
|
|
|
/**
|
|
* Whether a deferred wake is plain user input that the run's first turn may
|
|
* take. Interaction answers, interrupt intents, independent continuations and
|
|
* chat-conversation wakes keep their own delivery contracts. Only the run's
|
|
* responsible user's own messages qualify, so taking them cannot change the
|
|
* run's execution authority.
|
|
*/
|
|
export function deferredWakeJoinsFirstTurn(
|
|
wake: { agentId: string; status: string; requestedByActorType: string | null; requestedByActorId: string | null; reason: string | null; payload: unknown },
|
|
run: { agentId: string; responsibleUserId: string | null },
|
|
): boolean {
|
|
const payload = record(wake.payload);
|
|
const context = record(payload[DEFERRED_WAKE_CONTEXT_KEY]);
|
|
const reason = nonEmptyString(context.wakeReason) ?? nonEmptyString(wake.reason);
|
|
return wake.status === DEFERRED_WAKE_STATUS &&
|
|
wake.agentId === run.agentId &&
|
|
wake.requestedByActorType === "user" &&
|
|
Boolean(run.responsibleUserId) && wake.requestedByActorId === run.responsibleUserId &&
|
|
reason !== null && COMMENT_WAKE_REASONS.has(reason) &&
|
|
!payload.interactionId && !context.interactionId && !context.interactionStatus &&
|
|
!payload.queuedCommentInterrupt && context.resumeIntent !== true &&
|
|
!nonEmptyString(context.chatConversationId) && !nonEmptyString(payload.externalChatProvider) &&
|
|
queuedCommentIdsFromWakePayload(payload).length > 0;
|
|
}
|
|
|
|
type QueuedMessage = { id: string; body: string; createdAt: Date };
|
|
|
|
export function renderFirstTurnQueuedInput(messages: readonly QueuedMessage[]): string | null {
|
|
if (messages.length === 0) return null;
|
|
let remaining = MAX_TOTAL_CHARS;
|
|
const sections = messages.map((message, index) => {
|
|
const limit = Math.max(0, Math.min(MAX_COMMENT_CHARS, remaining));
|
|
const truncated = message.body.length > limit;
|
|
const body = truncated ? message.body.slice(0, limit) : message.body;
|
|
remaining -= body.length;
|
|
return [
|
|
`[Message ${index + 1} of ${messages.length} · comment ${message.id} · ${message.createdAt.toISOString()}]`,
|
|
body,
|
|
...(truncated ? [`(Truncated here. Read comment ${message.id} on the task for the rest.)`] : []),
|
|
].join("\n");
|
|
});
|
|
return [
|
|
"New messages from the user arrived while this run was starting. They were not in the request above. Handle each of them in this turn, oldest first, together with that request:",
|
|
...sections,
|
|
].join("\n\n");
|
|
}
|
|
|
|
/**
|
|
* The one rule for which queued comment a first turn may take: a live, plain
|
|
* comment written directly by the run's responsible user, with no files. The
|
|
* prompt carries text only; a message with files keeps its own delivery, which
|
|
* stages those files for the turn that reads it.
|
|
*/
|
|
function firstTurnEligibleComment(input: { companyId: string; issueId: string; responsibleUserId: string }) {
|
|
return and(
|
|
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId),
|
|
eq(issueComments.authorUserId, input.responsibleUserId),
|
|
isNull(issueComments.authorAgentId), isNull(issueComments.createdByRunId), isNull(issueComments.deletedAt),
|
|
sql`not exists (select 1 from ${issueAttachments} where ${issueAttachments.companyId} = ${input.companyId}
|
|
and ${issueAttachments.issueCommentId} = ${issueComments.id})`);
|
|
}
|
|
|
|
async function readQueuedMessages(
|
|
tx: Db,
|
|
input: { companyId: string; issueId: string; agentId: string; responsibleUserId: string; commentIds: string[]; excludeDelivered: boolean },
|
|
): Promise<QueuedMessage[]> {
|
|
if (input.commentIds.length === 0) return [];
|
|
const rows = await tx.select({
|
|
id: issueComments.id, body: issueComments.body, createdAt: issueComments.createdAt,
|
|
sourceTrust: issueComments.sourceTrust,
|
|
}).from(issueComments).where(and(inArray(issueComments.id, input.commentIds), firstTurnEligibleComment(input)));
|
|
const delivered = input.excludeDelivered
|
|
? await deliveredQueuedCommentIds(tx, { companyId: input.companyId, issueId: input.issueId, agentId: input.agentId,
|
|
commentIds: rows.map(row => row.id) })
|
|
: new Set<string>();
|
|
const byId = new Map(rows.map(row => [row.id, row]));
|
|
return input.commentIds.flatMap((id) => {
|
|
const row = byId.get(id);
|
|
if (!row || delivered.has(id)) return [];
|
|
const safe = sanitizeQuarantinedCommentForHigherTrust({ ...row, sourceTrust: row.sourceTrust as SourceTrustMetadata | null });
|
|
return [{ id, body: safe.body, createdAt: row.createdAt }];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Seals the first provider turn's input. Returns the queued messages to add to
|
|
* that turn, or null. Called once per turn start, immediately before
|
|
* `turn.start`; repeated calls for the same run return the same messages.
|
|
*/
|
|
export async function claimFirstTurnQueuedInput(
|
|
db: Db,
|
|
binding: { companyId: string; runId: string; issueId: string; agentId: string },
|
|
): Promise<string | null> {
|
|
return db.transaction(async (tx) => {
|
|
// Redact on this transaction's own connection: a second pool connection
|
|
// while the seal holds its locks can wait forever on a one-connection pool.
|
|
const render = async (messages: QueuedMessage[]) => messages.length === 0 ? null : renderFirstTurnQueuedInput(
|
|
await createRunSecretRedactionRegistry(tx as unknown as Db).redactForIssue(binding.companyId, binding.issueId, messages));
|
|
// Lock in the queue routes' order (task, queue, run) so a concurrent
|
|
// Interrupt and this seal serialize instead of deadlocking.
|
|
const [issue] = await tx.select({ conversationAgentId: issues.conversationAgentId, executionRunId: issues.executionRunId })
|
|
.from(issues).where(and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId))).for("update").limit(1);
|
|
const wakes = !issue ? [] : await tx.select().from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.companyId, binding.companyId), eq(agentWakeupRequests.agentId, binding.agentId),
|
|
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
|
|
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${binding.issueId}`,
|
|
)).orderBy(asc(agentWakeupRequests.requestedAt), asc(agentWakeupRequests.id)).for("update");
|
|
const [run] = await tx.select({
|
|
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status,
|
|
runtimeMode: heartbeatRuns.runtimeMode, responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
}).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId),
|
|
eq(heartbeatRuns.agentId, binding.agentId))).for("update").limit(1);
|
|
if (!run || run.status !== "running" || run.runtimeMode !== "native" || !run.responsibleUserId) return null;
|
|
const receipt = readFirstTurnQueuedInputReceipt(run);
|
|
// A restart before the provider accepted the turn repeats the sealed input.
|
|
if (receipt) {
|
|
return render(await readQueuedMessages(tx as unknown as Db, { ...binding, responsibleUserId: run.responsibleUserId,
|
|
commentIds: receipt.commentIds, excludeDelivered: false }));
|
|
}
|
|
// Only the run that holds the task takes its queue. Conversations keep
|
|
// their own turn-boundary contract.
|
|
const joining = !issue || issue.conversationAgentId || issue.executionRunId !== run.id
|
|
? [] : wakes.filter(wake => deferredWakeJoinsFirstTurn(wake, run));
|
|
const commentIds = [...new Set(joining.flatMap(wake => queuedCommentIdsFromWakePayload(wake.payload)))];
|
|
const taken = await readQueuedMessages(tx as unknown as Db, { ...binding, responsibleUserId: run.responsibleUserId,
|
|
commentIds, excludeDelivered: true });
|
|
// Render before sealing: once the receipt commits, nothing can fail
|
|
// between it and the turn input that the receipt describes.
|
|
const text = await render(taken);
|
|
// Seal even when nothing joined: later messages wait for the turn boundary.
|
|
const sealed: FirstTurnQueuedInputReceipt = {
|
|
schema: FIRST_TURN_QUEUED_INPUT_SCHEMA,
|
|
runId: run.id,
|
|
wakeupRequestIds: joining.filter(wake => queuedCommentIdsFromWakePayload(wake.payload)
|
|
.some(id => taken.some(message => message.id === id))).map(wake => wake.id),
|
|
commentIds: taken.map(message => message.id),
|
|
claimedAt: new Date().toISOString(),
|
|
};
|
|
await tx.update(heartbeatRuns).set({
|
|
contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ [FIRST_TURN_QUEUED_INPUT_KEY]: sealed })}::jsonb`,
|
|
updatedAt: new Date(),
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
return text;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* True while a running native run can still take queued input into its first
|
|
* turn: it has not sealed that input and no provider turn was accepted. A run
|
|
* saved by a release without the seal counts as started once its provider
|
|
* accepted a turn, so a mid-turn Interrupt keeps its old meaning.
|
|
*/
|
|
export async function runAcceptsFirstTurnQueuedInput(
|
|
tx: Pick<Db, "select">,
|
|
run: { id: string; companyId: string; status: string; runtimeMode: string | null; runtimeModeResolvedAt?: Date | null;
|
|
runnerProfileJson?: unknown; contextSnapshot?: unknown },
|
|
): Promise<boolean> {
|
|
if (run.status !== "running") return false;
|
|
const preparingNative = run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
|
|
record(record(run.runnerProfileJson).adapterDispatch).adapterType === "paperclip_runner";
|
|
if (run.runtimeMode !== "native" && !preparingNative) return false;
|
|
if (readFirstTurnQueuedInputReceipt(run)) return false;
|
|
const [accepted] = await tx.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
inArray(heartbeatRunEvents.eventType, [...PROVIDER_TURN_ACCEPTED_EVENT_TYPES]))).limit(1);
|
|
return !accepted;
|
|
}
|
|
|
|
/** The wake facts `deferredWakeJoinsFirstTurn` reads, without the rest of a large payload. */
|
|
export const firstTurnWakeColumns = {
|
|
agentId: agentWakeupRequests.agentId,
|
|
reason: agentWakeupRequests.reason,
|
|
requestedByActorType: agentWakeupRequests.requestedByActorType,
|
|
requestedByActorId: agentWakeupRequests.requestedByActorId,
|
|
};
|
|
|
|
/** The lean payload fields `deferredWakeJoinsFirstTurn` reads, for a `jsonb_build_object` projection. */
|
|
export function firstTurnWakePayloadFields(payload: typeof agentWakeupRequests.payload) {
|
|
return sql`'interactionId', ${payload}->'interactionId',
|
|
'queuedCommentInterrupt', ${payload}->'queuedCommentInterrupt'->'requestedAt',
|
|
'externalChatProvider', ${payload}->'externalChatProvider'`;
|
|
}
|
|
|
|
/** The lean `_paperclipWakeContext` fields `deferredWakeJoinsFirstTurn` reads. */
|
|
export function firstTurnWakeContextFields(payload: typeof agentWakeupRequests.payload) {
|
|
return sql`'wakeReason', ${payload}->'_paperclipWakeContext'->'wakeReason',
|
|
'interactionId', ${payload}->'_paperclipWakeContext'->'interactionId',
|
|
'interactionStatus', ${payload}->'_paperclipWakeContext'->'interactionStatus',
|
|
'resumeIntent', ${payload}->'_paperclipWakeContext'->'resumeIntent',
|
|
'chatConversationId', ${payload}->'_paperclipWakeContext'->'chatConversationId'`;
|
|
}
|
|
|
|
/**
|
|
* The messages in one deferred queue that the running run's first turn takes:
|
|
* the ones it already sealed, or, while it is still starting, the ones its
|
|
* claim will take under the same rule as `claimFirstTurnQueuedInput`. A queue
|
|
* view shows these as sent instead of waiting for Steer, which cannot reach a
|
|
* run whose provider session (for example a booting sandbox) is not up yet.
|
|
* Presentation only: delivery still belongs to the claim and the deferred wake.
|
|
*/
|
|
export async function firstTurnQueuedCommentIds(
|
|
tx: Pick<Db, "select">,
|
|
input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
runId: string;
|
|
wake: { agentId: string; status: string; reason: string | null; requestedByActorType: string | null;
|
|
requestedByActorId: string | null; payload: unknown };
|
|
},
|
|
): Promise<string[]> {
|
|
const { wake } = input;
|
|
if (wake.status !== DEFERRED_WAKE_STATUS) return [];
|
|
const wakeIds = queuedCommentIdsFromWakePayload(wake.payload);
|
|
if (wakeIds.length === 0) return [];
|
|
// Read only the receipt and dispatch choice; a run snapshot can be large.
|
|
const [row] = await tx.select({
|
|
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
|
|
runtimeModeResolvedAt: heartbeatRuns.runtimeModeResolvedAt, responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
dispatchAdapterType: sql<string | null>`${heartbeatRuns.runnerProfileJson}->'adapterDispatch'->>'adapterType'`,
|
|
receipt: sql<unknown>`${heartbeatRuns.contextSnapshot}->${FIRST_TURN_QUEUED_INPUT_KEY}`,
|
|
executionRunId: issues.executionRunId, conversationAgentId: issues.conversationAgentId,
|
|
}).from(heartbeatRuns)
|
|
.leftJoin(issues, and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)))
|
|
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))).limit(1);
|
|
if (!row || row.status !== "running" || row.agentId !== wake.agentId) return [];
|
|
const run = { ...row, runnerProfileJson: { adapterDispatch: { adapterType: row.dispatchAdapterType } },
|
|
contextSnapshot: { [FIRST_TURN_QUEUED_INPUT_KEY]: row.receipt } };
|
|
const receipt = readFirstTurnQueuedInputReceipt(run);
|
|
if (receipt) {
|
|
const sealed = new Set(receipt.commentIds);
|
|
return wakeIds.filter(id => sealed.has(id));
|
|
}
|
|
if (row.conversationAgentId || row.executionRunId !== run.id || !run.responsibleUserId) return [];
|
|
if (!deferredWakeJoinsFirstTurn(wake, run)) return [];
|
|
if (!(await runAcceptsFirstTurnQueuedInput(tx, run))) return [];
|
|
const eligible = await tx.select({ id: issueComments.id }).from(issueComments).where(and(
|
|
inArray(issueComments.id, wakeIds),
|
|
firstTurnEligibleComment({ companyId: input.companyId, issueId: input.issueId, responsibleUserId: run.responsibleUserId })));
|
|
const ids = new Set(eligible.map(row => row.id));
|
|
return wakeIds.filter(id => ids.has(id));
|
|
}
|
|
|
|
/**
|
|
* True when the first turn would take every pending message in this queue,
|
|
* under the same rule as the claim. Another user's message, an agent's, or one
|
|
* with files keeps its own delivery, so Interrupt must still apply.
|
|
*/
|
|
export async function firstTurnTakesAllQueuedComments(
|
|
tx: Pick<Db, "select">,
|
|
input: { companyId: string; issueId: string; responsibleUserId: string | null; commentIds: string[] },
|
|
): Promise<boolean> {
|
|
if (input.commentIds.length === 0 || !input.responsibleUserId) return false;
|
|
const rows = await tx.select({
|
|
id: issueComments.id,
|
|
eligible: sql<boolean>`coalesce((${firstTurnEligibleComment({ ...input, responsibleUserId: input.responsibleUserId })}), false)`,
|
|
}).from(issueComments).where(and(eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId),
|
|
inArray(issueComments.id, input.commentIds), isNull(issueComments.deletedAt)));
|
|
return rows.length > 0 && rows.every(row => row.eligible === true);
|
|
}
|