mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected chats show safe notices when a run starts, waits, finishes, or fails. > - A recurring scan projected these notices even when no eligible work existed. > - Eligibility depends on committed run state, conversation lineage, and current authority. > - Existing database rows can reconstruct that work after restart. > - This PR wakes a scoped worker from those writes and removes lifecycle polling. > - Native progress events keep their separate existing owner until the next migration. ## Linked Issues or Issue Description **What happened?** The chat maintenance timer repeatedly scanned run lifecycle state. Empty and settled instances still queried the database. A run-status event alone cannot replace the scan: prompt receipts, continuation receipts, identity changes, and restored authority can make a run eligible later. **What did you expect?** Committed changes wake projection. Startup reconstructs eligible work. A completed pass stops until another relevant change arrives. Failed work retains recovery without scanning settled history forever. **Steps to reproduce** Configure an automatic chat endpoint. Finish its work, or leave it unused. Observe recurring lifecycle projection queries from the chat maintenance callback. Stacked on #17592. Related foundation: #15625. This PR owns lifecycle milestones only. ## What Changed - Preserve upstream coalesced Cloud membership writes together with locked membership restoration and its committed chat notification. - Signal milestone eligibility from the real Slack account-link transaction. - Register the lifecycle worker with the existing delivery coordinator. Remove lifecycle projection from the periodic chat callback. Keep native progress in a separately named lane and preserve the explicit combined API. - Reconstruct company/issue scopes from the existing selector. Drain bounded batches with company fairness. Retry failures, retain writes through drain/resume, and disarm settled work. - Signal from supported run, presentation, interaction, receipt, lineage, conversation, recovery, and authority writers. Preserve their transaction ownership, including nested savepoints and uncertain settlement. No new table is added. - Preserve provider authorization, authored-final suppression, responsible-user continuity, deduplication, the 24-hour selection window, and raw-output restrictions. Keep provider interpretation in its owner. - Preserve PostgreSQL timestamp precision in lifecycle pagination. Rejected submillisecond candidates must not repeat the same page and starve later notices. - Document producer coverage and alternate-writer dispositions in `doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`. ## Verification Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting master after #17592 merged at `7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates the actual landed parent. The entire 77-path milestone patch is binary-identical to the locally verified preparation across that ancestry reconciliation; both unrelated master paths are preserved. The four earlier producer/test integration conflicts retain scoped milestone and voice notices, current onboarding completion admission, and expanded identity query/read-scope coverage. - **330 distinct selected cases pass on the verified unchanged patch:** 305 across 14 complete suites (all five milestone worker suites, run identity, board claim, Cloud provisioning, auth session routes, Cloud actor authentication, stale-lock recovery, GitHub credentials, project Git authorization, and app admission wiring), plus 25 selected real chat producer cases. The remaining 1,590 chat integration cases were skipped, not passed. - The real producer cases cover saved milestone discovery, GitHub and Slack identity changes, account-link rollback/reconfirmation, ordinary ingress reactivation, post-close action/delivery receipt settlement, all ten authority-restoration paths including Cloud synchronization, and changed native review metadata. - Worker coverage verifies outer commit, rollback, uncertain-write recovery, restart, bounded batching/fairness, timestamp precision, retries, drain/resume, and settled SQL quiet. Authorization and existing publication deduplication remain with their owning services. - Server production typechecking, build, and module-boundary checks pass. The tests above were executed separately. Production typechecking excludes `src/__tests__` and does not replace those runs. Each database owner uses its own newly created and app-migrated PostgreSQL fixture. - [Current-parent acceptance and preserved failure ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28) records commands, counts, producer scope, conflict dispositions, and limits. Earlier combined-stack counts and failures keep their original revision scope; local acceptance does not erase old CI failures. - No new migration, queue table, dependency, workflow, or shared scheduler contract is introduced. Parent migration 0397, bounded consent recovery, voice cleanup/retention repairs, and runner checkpoint restoration remain present. - **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No Ponytail review is present, which is an explicitly authorized nonblocker; no failing review is waived. Older reviews and parent checks are not current passes. Actual failing Ponytail findings must be fixed; an absent/stopped/paused turn without a verdict is an authorized nonblocker. Active unfinished Ponytail uses its original request/start timer, with a 25-minute monitor cutoff respecting the user's 30-minute maximum. - No complete local repository matrix, live provider tenant, external alarm, physical host/database suspension, or fleet savings claim is made. Native progress remains a separate following slice. ## Risks - A future eligibility writer must notify the existing projection boundary. Independent SQL writers or another active server process are outside this process-local signal contract. - Authority and lineage remain sensitive. The worker uses the existing selectors and publication policy; it does not grant execution or disclosure authority. - Native progress and other publication maintenance still have recurring work. This PR removes one reason, not the entire chat maintenance timer. - Worker quiet does not prove database suspension, host sleep, or external alarm coverage. Cloud sleep blockers remain unchanged. - Merge eligibility requires passing current-head CI and Greptile 5/5 after this actual-master reconciliation. Subsequent stack slices retain their own gates. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context-window size are not exposed. Used repository inspection, reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub tools. ## 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 available in this session) - [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 the documented focused 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1368 lines
48 KiB
TypeScript
1368 lines
48 KiB
TypeScript
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
|
import { and, asc, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agentWakeupRequests,
|
|
agents,
|
|
activityLog,
|
|
chatPublications,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueQuestionResponseDeliveries,
|
|
issues,
|
|
issueThreadInteractions,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AskUserQuestionsInteraction,
|
|
PaperclipQuestionSetPayload,
|
|
} from "@paperclipai/shared";
|
|
import type { PaperclipQuestionResponse } from "../vendor/paperclip-runner/index.js";
|
|
import { isUniqueViolation } from "../db-errors.js";
|
|
import { getTelemetryClient } from "../telemetry.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import type { heartbeatService } from "./heartbeat.js";
|
|
import { nativeSha256 } from "./native-runtime/canonical.js";
|
|
|
|
const DELIVERY_CLAIM_STALE_MS = 30_000;
|
|
const DELIVERY_CLAIM_REFRESH_MS = 10_000;
|
|
const MAX_DELIVERY_ATTEMPTS = 5;
|
|
const DELIVERY_CORRELATION_PREFIX = "question-response:";
|
|
const QUESTION_RESPONSE_WAKE_IDEMPOTENCY_CONSTRAINT =
|
|
"agent_wakeup_requests_question_response_delivery_idempotency_uq";
|
|
|
|
class DeliveryClaimUnavailableError extends Error {
|
|
constructor() {
|
|
super("question_response_delivery_claim_unavailable");
|
|
this.name = "DeliveryClaimUnavailableError";
|
|
}
|
|
}
|
|
const DURABLE_WAKE_REQUEST_STATUSES = [
|
|
"queued",
|
|
"claimed",
|
|
"running",
|
|
"succeeded",
|
|
"completed",
|
|
"coalesced",
|
|
"deferred_issue_execution",
|
|
"retrying",
|
|
"scheduled_retry",
|
|
] as const;
|
|
|
|
type QuestionInteractionRow = typeof issueThreadInteractions.$inferSelect;
|
|
type DeliveryRow = typeof issueQuestionResponseDeliveries.$inferSelect;
|
|
type Heartbeat = Pick<
|
|
ReturnType<typeof heartbeatService>,
|
|
"wakeup" | "cancelRun"
|
|
>;
|
|
type QuestionResponseSteer = (input: {
|
|
runId: string;
|
|
message: string;
|
|
correlationId: string;
|
|
}) => Promise<{ turnId?: string | null }>;
|
|
type NativeQuestionResponseResolver = (
|
|
interaction: AskUserQuestionsInteraction,
|
|
) => Promise<"not_native" | "pending" | "queued">;
|
|
|
|
export interface QuestionResponseDeliveryEnvelope {
|
|
schema: "paperclip.question_response_delivery.v1";
|
|
interactionId: string;
|
|
sourceRunId: string | null;
|
|
questionSet: PaperclipQuestionSetPayload;
|
|
response: PaperclipQuestionResponse;
|
|
}
|
|
|
|
export interface QuestionResponseDeliveryOutcome {
|
|
deliveryId: string;
|
|
status: DeliveryRow["status"];
|
|
mode: DeliveryRow["deliveryMode"];
|
|
targetRunId: string | null;
|
|
targetTurnId: string | null;
|
|
duplicate: boolean;
|
|
}
|
|
|
|
export interface QuestionResponseDeliveryServiceOptions {
|
|
heartbeat: Heartbeat;
|
|
/** Kept for caller compatibility. Answers never implicitly steer an active turn. */
|
|
steer?: QuestionResponseSteer;
|
|
/** Resolve the original in-flight native input request before considering a continuation run. */
|
|
resolveNativeQuestion?: NativeQuestionResponseResolver;
|
|
now?: () => Date;
|
|
/** Test-only lease timings. Production callers use the bounded defaults. */
|
|
claimStaleMs?: number;
|
|
claimRefreshMs?: number;
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function compactLine(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
return normalized.length > 0 ? normalized : null;
|
|
}
|
|
|
|
function canonicalQuestionSet(
|
|
interaction: Pick<AskUserQuestionsInteraction, "title" | "payload">,
|
|
): PaperclipQuestionSetPayload {
|
|
if (interaction.payload.questionSet)
|
|
return structuredClone(interaction.payload.questionSet);
|
|
return {
|
|
schema: "paperclip.question_set.v1",
|
|
...(interaction.title ? { title: interaction.title } : {}),
|
|
...(interaction.payload.submitLabel
|
|
? { submitLabel: interaction.payload.submitLabel }
|
|
: {}),
|
|
questions: interaction.payload.questions.map((question) => {
|
|
const customOption = question.options.find(
|
|
(option) => option.freeText === true,
|
|
);
|
|
return {
|
|
id: question.id,
|
|
prompt: question.prompt,
|
|
...(question.helpText ? { helpText: question.helpText } : {}),
|
|
required: question.required === true,
|
|
answerMode:
|
|
question.selectionMode === "multi"
|
|
? ("multi_select" as const)
|
|
: ("single_select" as const),
|
|
options: question.options
|
|
.filter((option) => option.freeText !== true)
|
|
.map((option) => ({
|
|
id: option.id,
|
|
label: option.label,
|
|
...(option.description ? { description: option.description } : {}),
|
|
})),
|
|
...(customOption
|
|
? {
|
|
customAnswer: {
|
|
enabled: true as const,
|
|
label: customOption.label,
|
|
...(customOption.description
|
|
? { placeholder: customOption.description }
|
|
: {}),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function buildQuestionResponseDeliveryEnvelope(
|
|
interaction: AskUserQuestionsInteraction,
|
|
): QuestionResponseDeliveryEnvelope {
|
|
if (
|
|
interaction.status !== "answered" ||
|
|
!interaction.result ||
|
|
interaction.result.cancelled === true
|
|
) {
|
|
throw new Error("question_response_interaction_not_answered");
|
|
}
|
|
const questionSet = canonicalQuestionSet(interaction);
|
|
const questionById = new Map(
|
|
questionSet.questions.map((question) => [question.id, question]),
|
|
);
|
|
const response: PaperclipQuestionResponse = {
|
|
schema: "paperclip.question_response.v1",
|
|
answers: Object.fromEntries(
|
|
interaction.result.answers.map((answer) => {
|
|
const question = questionById.get(answer.questionId);
|
|
return [
|
|
answer.questionId,
|
|
question?.answerMode === "text"
|
|
? { ...(answer.otherText ? { text: answer.otherText } : {}) }
|
|
: {
|
|
selectedOptionIds: answer.optionIds,
|
|
...(answer.otherText ? { customText: answer.otherText } : {}),
|
|
},
|
|
];
|
|
}),
|
|
),
|
|
};
|
|
return {
|
|
schema: "paperclip.question_response_delivery.v1",
|
|
interactionId: interaction.id,
|
|
sourceRunId: interaction.sourceRunId ?? null,
|
|
questionSet,
|
|
response,
|
|
};
|
|
}
|
|
|
|
function questionAnswerLines(
|
|
envelope: QuestionResponseDeliveryEnvelope,
|
|
): string[] {
|
|
const lines: string[] = [];
|
|
for (const question of envelope.questionSet.questions) {
|
|
const answer = envelope.response.answers[question.id];
|
|
if (!answer) continue;
|
|
const optionLabelById = new Map(
|
|
(question.options ?? []).map((option) => [option.id, option.label]),
|
|
);
|
|
const values = (answer.selectedOptionIds ?? []).map(
|
|
(optionId) => optionLabelById.get(optionId) ?? optionId,
|
|
);
|
|
const text = compactLine(answer.text);
|
|
const customText = compactLine(answer.customText);
|
|
if (text) values.push(text);
|
|
if (customText) values.push(customText);
|
|
const header = compactLine(question.header);
|
|
const prompt = compactLine(question.prompt);
|
|
const label =
|
|
header && prompt && header !== prompt
|
|
? `${header} — ${prompt}`
|
|
: (header ?? prompt ?? question.id);
|
|
lines.push(`- ${label}: ${values.join(", ") || "No answer"}`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
export function formatQuestionResponseSummary(
|
|
envelope: QuestionResponseDeliveryEnvelope,
|
|
): string {
|
|
const lines = questionAnswerLines(envelope);
|
|
return lines.length > 0
|
|
? ["Resolved questions and answers:", ...lines].join("\n")
|
|
: "Resolved questions and answers.";
|
|
}
|
|
|
|
export function formatDurableQuestionResponseSummary(
|
|
interaction: AskUserQuestionsInteraction,
|
|
): string {
|
|
const existing = compactLine(interaction.result?.summaryMarkdown);
|
|
return (
|
|
existing ??
|
|
formatQuestionResponseSummary(
|
|
buildQuestionResponseDeliveryEnvelope(interaction),
|
|
)
|
|
);
|
|
}
|
|
|
|
export function formatQuestionResponseSteeringMessage(
|
|
envelope: QuestionResponseDeliveryEnvelope,
|
|
): string {
|
|
const lines = questionAnswerLines(envelope);
|
|
return lines.length > 0
|
|
? ["Answered questions", "", ...lines].join("\n")
|
|
: "Answered questions";
|
|
}
|
|
|
|
function hydrateQuestionInteraction(
|
|
row: QuestionInteractionRow,
|
|
): AskUserQuestionsInteraction {
|
|
return {
|
|
...row,
|
|
kind: "ask_user_questions",
|
|
status: row.status as AskUserQuestionsInteraction["status"],
|
|
continuationPolicy:
|
|
row.continuationPolicy as AskUserQuestionsInteraction["continuationPolicy"],
|
|
resolverPolicy: row.effectiveResolverPolicy,
|
|
requestedResolverPolicy: row.requestedResolverPolicy,
|
|
effectiveResolverPolicy: row.effectiveResolverPolicy,
|
|
resolverPolicyProvenance: row.resolverPolicyProvenance,
|
|
effectiveResolverPolicySource: row.effectiveResolverPolicySource,
|
|
legacyResolverPolicyAliases: { requested: null, effective: null },
|
|
payload: row.payload as AskUserQuestionsInteraction["payload"],
|
|
result: row.result as AskUserQuestionsInteraction["result"],
|
|
};
|
|
}
|
|
|
|
export function questionResponseDeliveryValues(
|
|
interaction: AskUserQuestionsInteraction,
|
|
) {
|
|
const envelope = buildQuestionResponseDeliveryEnvelope(interaction);
|
|
return {
|
|
companyId: interaction.companyId,
|
|
issueId: interaction.issueId,
|
|
interactionId: interaction.id,
|
|
sourceRunId: interaction.sourceRunId ?? null,
|
|
correlationId: `${DELIVERY_CORRELATION_PREFIX}${interaction.id}`,
|
|
payloadSha256: nativeSha256(envelope),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* An intentional retired question source is not a failed execution incident.
|
|
* This is only a stop classification: the dedicated answer wake still needs
|
|
* its own fresh source/principal attestation before it can execute. Call while
|
|
* holding the task lock; no provider operation or successor is started here.
|
|
*/
|
|
export async function isRetiredExternalChatQuestionSource(
|
|
database: Db,
|
|
scope: { companyId: string; issueId: string; agentId: string; runId: string },
|
|
): Promise<boolean> {
|
|
const source = await database.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, scope.runId),
|
|
eq(heartbeatRuns.companyId, scope.companyId),
|
|
eq(heartbeatRuns.agentId, scope.agentId),
|
|
eq(heartbeatRuns.nativeIssueId, scope.issueId),
|
|
eq(heartbeatRuns.runtimeMode, "native"),
|
|
eq(heartbeatRuns.status, "cancelled"),
|
|
eq(heartbeatRuns.errorCode, "external_chat_continuation"),
|
|
)).limit(1).then(rows => rows[0] ?? null);
|
|
if (!source) return false;
|
|
const result = record(source.resultJson);
|
|
const cancellation = record(result.nativeCancellation);
|
|
const interactionId = result.interactionId;
|
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
if (
|
|
result.externalChatContinuation !== true ||
|
|
typeof interactionId !== "string" || !uuid.test(interactionId) ||
|
|
cancellation.schema !== "paperclip.native-cancellation.v1" ||
|
|
cancellation.companyId !== scope.companyId ||
|
|
cancellation.runId !== scope.runId ||
|
|
cancellation.issueId !== scope.issueId ||
|
|
cancellation.scope !== "run" ||
|
|
cancellation.dispatchState !== "acknowledged" ||
|
|
typeof cancellation.intentId !== "string" ||
|
|
typeof cancellation.intentAuditId !== "string" || !uuid.test(cancellation.intentAuditId) ||
|
|
typeof cancellation.acknowledgementAuditId !== "string" || !uuid.test(cancellation.acknowledgementAuditId)
|
|
) return false;
|
|
const interaction = await database.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, scope.companyId),
|
|
eq(issueThreadInteractions.issueId, scope.issueId),
|
|
eq(issueThreadInteractions.createdByAgentId, scope.agentId),
|
|
eq(issueThreadInteractions.sourceRunId, scope.runId),
|
|
eq(issueThreadInteractions.kind, "ask_user_questions"),
|
|
eq(issueThreadInteractions.status, "answered"),
|
|
)).limit(1).then(rows => rows[0] ?? null);
|
|
if (!interaction) return false;
|
|
let expected: ReturnType<typeof questionResponseDeliveryValues>;
|
|
try {
|
|
expected = questionResponseDeliveryValues(hydrateQuestionInteraction(interaction));
|
|
} catch {
|
|
return false;
|
|
}
|
|
const delivery = await database.select({ id: issueQuestionResponseDeliveries.id }).from(issueQuestionResponseDeliveries).where(and(
|
|
eq(issueQuestionResponseDeliveries.companyId, scope.companyId),
|
|
eq(issueQuestionResponseDeliveries.issueId, scope.issueId),
|
|
eq(issueQuestionResponseDeliveries.interactionId, interactionId),
|
|
eq(issueQuestionResponseDeliveries.sourceRunId, scope.runId),
|
|
eq(issueQuestionResponseDeliveries.correlationId, expected.correlationId),
|
|
eq(issueQuestionResponseDeliveries.payloadSha256, expected.payloadSha256),
|
|
)).limit(1);
|
|
if (!delivery.length) return false;
|
|
const audits = await database.select().from(activityLog).where(and(
|
|
eq(activityLog.companyId, scope.companyId),
|
|
eq(activityLog.agentId, scope.agentId),
|
|
eq(activityLog.runId, scope.runId),
|
|
eq(activityLog.entityType, "heartbeat_run"),
|
|
eq(activityLog.entityId, scope.runId),
|
|
eq(activityLog.actorType, "system"),
|
|
eq(activityLog.actorId, "native-session-cancellation"),
|
|
inArray(activityLog.id, [cancellation.intentAuditId, cancellation.acknowledgementAuditId]),
|
|
));
|
|
return audits.some(audit => audit.id === cancellation.intentAuditId &&
|
|
audit.action === "native.cancellation_intent_recorded" &&
|
|
record(audit.details).intentId === cancellation.intentId &&
|
|
record(audit.details).scope === "run") &&
|
|
audits.some(audit => audit.id === cancellation.acknowledgementAuditId &&
|
|
audit.action === "native.cancellation_dispatch_acknowledged" &&
|
|
record(audit.details).intentId === cancellation.intentId &&
|
|
record(audit.details).intentAuditId === cancellation.intentAuditId &&
|
|
record(audit.details).scope === "run");
|
|
}
|
|
|
|
function issueIdFromRun(
|
|
run: Pick<typeof heartbeatRuns.$inferSelect, "contextSnapshot">,
|
|
) {
|
|
const context = record(run.contextSnapshot);
|
|
return compactLine(context.issueId) ?? compactLine(context.taskId);
|
|
}
|
|
|
|
function sourceCommentIdFromRun(
|
|
run: Pick<typeof heartbeatRuns.$inferSelect, "contextSnapshot"> | null,
|
|
) {
|
|
const context = record(run?.contextSnapshot);
|
|
const batched = Array.isArray(context.wakeCommentIds)
|
|
? context.wakeCommentIds
|
|
.map((value) => compactLine(value))
|
|
.filter((value): value is string => Boolean(value))
|
|
: [];
|
|
return (
|
|
batched.at(-1) ??
|
|
compactLine(context.wakeCommentId) ??
|
|
compactLine(context.commentId)
|
|
);
|
|
}
|
|
|
|
function hasExternalChatOrigin(
|
|
run: Pick<typeof heartbeatRuns.$inferSelect, "contextSnapshot"> | null,
|
|
) {
|
|
const source = compactLine(record(run?.contextSnapshot).source);
|
|
return (
|
|
source?.startsWith("chat:") === true ||
|
|
source === "external_chat.interaction.resolve"
|
|
);
|
|
}
|
|
|
|
function actorForInteraction(interaction: QuestionInteractionRow) {
|
|
if (interaction.resolvedByUserId) {
|
|
return {
|
|
actorType: "user" as const,
|
|
actorId: interaction.resolvedByUserId,
|
|
};
|
|
}
|
|
if (interaction.resolvedByAgentId) {
|
|
return {
|
|
actorType: "agent" as const,
|
|
actorId: interaction.resolvedByAgentId,
|
|
};
|
|
}
|
|
return { actorType: "system" as const, actorId: "question-response-outbox" };
|
|
}
|
|
|
|
export function questionResponseDeliveryService(
|
|
db: Db,
|
|
options: QuestionResponseDeliveryServiceOptions,
|
|
) {
|
|
const resolveNativeQuestion = options.resolveNativeQuestion;
|
|
const now = options.now ?? (() => new Date());
|
|
const claimStaleMs = Math.max(
|
|
2,
|
|
options.claimStaleMs ?? DELIVERY_CLAIM_STALE_MS,
|
|
);
|
|
const claimRefreshMs = Math.max(
|
|
1,
|
|
Math.min(
|
|
options.claimRefreshMs ?? DELIVERY_CLAIM_REFRESH_MS,
|
|
Math.floor(claimStaleMs / 2),
|
|
),
|
|
);
|
|
|
|
async function claim(interactionId: string): Promise<DeliveryRow | null> {
|
|
const claimAt = now();
|
|
return db.transaction(async (tx) => {
|
|
const current = await tx
|
|
.select()
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(eq(issueQuestionResponseDeliveries.interactionId, interactionId))
|
|
.for("update")
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!current ||
|
|
["delivered", "fallback_queued", "failed"].includes(current.status)
|
|
)
|
|
return null;
|
|
if (
|
|
current.status === "delivering" &&
|
|
current.lastAttemptAt &&
|
|
current.lastAttemptAt.getTime() > claimAt.getTime() - claimStaleMs
|
|
)
|
|
return null;
|
|
if (current.status !== "delivering") {
|
|
await notifyChatRunMilestones(tx, { companyId: current.companyId, issueId: current.issueId });
|
|
}
|
|
return tx
|
|
.update(issueQuestionResponseDeliveries)
|
|
.set({
|
|
status: "delivering",
|
|
attemptCount: sql`${issueQuestionResponseDeliveries.attemptCount} + 1`,
|
|
lastAttemptAt: claimAt,
|
|
updatedAt: claimAt,
|
|
})
|
|
.where(eq(issueQuestionResponseDeliveries.id, current.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
});
|
|
}
|
|
|
|
async function terminalOutcome(
|
|
interactionId: string,
|
|
): Promise<QuestionResponseDeliveryOutcome | null> {
|
|
const row = await db
|
|
.select()
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(eq(issueQuestionResponseDeliveries.interactionId, interactionId))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!row ||
|
|
!["delivered", "fallback_queued", "failed"].includes(row.status)
|
|
)
|
|
return null;
|
|
return {
|
|
deliveryId: row.id,
|
|
status: row.status,
|
|
mode: row.deliveryMode,
|
|
targetRunId: row.targetRunId,
|
|
targetTurnId: row.targetTurnId,
|
|
duplicate: true,
|
|
};
|
|
}
|
|
|
|
async function recordTerminal(input: {
|
|
delivery: DeliveryRow;
|
|
interaction: QuestionInteractionRow;
|
|
status: "delivered" | "fallback_queued" | "failed";
|
|
mode: "steered" | "coalesced" | "wake_fallback" | null;
|
|
targetRunId: string | null;
|
|
targetTurnId?: string | null;
|
|
adapter: string;
|
|
errorCode?: string | null;
|
|
}): Promise<QuestionResponseDeliveryOutcome> {
|
|
const at = now();
|
|
const updated = await db.transaction(async (tx) => {
|
|
await notifyChatRunMilestones(tx, { companyId: input.delivery.companyId, issueId: input.delivery.issueId });
|
|
const row = await tx
|
|
.update(issueQuestionResponseDeliveries)
|
|
.set({
|
|
status: input.status,
|
|
deliveryMode: input.mode,
|
|
targetRunId: input.targetRunId,
|
|
targetTurnId: input.targetTurnId ?? null,
|
|
acknowledgedAt: input.status === "failed" ? null : at,
|
|
lastErrorCode: input.errorCode ?? null,
|
|
updatedAt: at,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueQuestionResponseDeliveries.id, input.delivery.id),
|
|
eq(issueQuestionResponseDeliveries.status, "delivering"),
|
|
eq(
|
|
issueQuestionResponseDeliveries.attemptCount,
|
|
input.delivery.attemptCount,
|
|
),
|
|
),
|
|
)
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: input.interaction.companyId,
|
|
actorType: "system",
|
|
actorId: "question-response-delivery",
|
|
agentId: input.interaction.resolvedByAgentId,
|
|
runId: input.targetRunId,
|
|
action:
|
|
input.status === "failed"
|
|
? "issue.question_response_delivery_failed"
|
|
: "issue.question_response_delivered",
|
|
entityType: "issue",
|
|
entityId: input.interaction.issueId,
|
|
details: {
|
|
deliveryId: row.id,
|
|
interactionId: input.interaction.id,
|
|
sourceRunId: input.interaction.sourceRunId,
|
|
targetRunId: input.targetRunId,
|
|
targetTurnId: input.targetTurnId ?? null,
|
|
correlationId: row.correlationId,
|
|
payloadSha256: row.payloadSha256,
|
|
deliveryStatus: input.status,
|
|
deliveryMode: input.mode,
|
|
adapter: input.adapter,
|
|
errorCode: input.errorCode ?? null,
|
|
},
|
|
});
|
|
return row;
|
|
});
|
|
|
|
const persisted =
|
|
updated ??
|
|
(await db
|
|
.select()
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(eq(issueQuestionResponseDeliveries.id, input.delivery.id))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null));
|
|
const result: DeliveryRow = persisted ?? input.delivery;
|
|
if (updated) {
|
|
getTelemetryClient()?.trackDynamic("question_response.delivery", {
|
|
adapter: input.adapter,
|
|
outcome: input.mode ?? "failed",
|
|
});
|
|
}
|
|
return {
|
|
deliveryId: result.id,
|
|
status: result.status,
|
|
mode: result.deliveryMode,
|
|
targetRunId: result.targetRunId,
|
|
targetTurnId: result.targetTurnId,
|
|
duplicate: !updated,
|
|
};
|
|
}
|
|
|
|
async function releaseForRetry(
|
|
delivery: DeliveryRow,
|
|
errorCode: string,
|
|
options: { bounded: boolean } = { bounded: true },
|
|
) {
|
|
const at = now();
|
|
const nextErrorCount = delivery.errorCount + (options.bounded ? 1 : 0);
|
|
const exhausted =
|
|
options.bounded && nextErrorCount >= MAX_DELIVERY_ATTEMPTS;
|
|
await db.transaction(async (tx) => {
|
|
const ownedClaim = and(
|
|
eq(issueQuestionResponseDeliveries.id, delivery.id),
|
|
eq(issueQuestionResponseDeliveries.status, "delivering"),
|
|
eq(issueQuestionResponseDeliveries.attemptCount, delivery.attemptCount),
|
|
);
|
|
const [current] = await tx.select({ id: issueQuestionResponseDeliveries.id })
|
|
.from(issueQuestionResponseDeliveries).where(ownedClaim).for("update");
|
|
if (!current) return;
|
|
if (!exhausted) {
|
|
await notifyChatRunMilestones(tx, { companyId: delivery.companyId, issueId: delivery.issueId });
|
|
}
|
|
await tx.update(issueQuestionResponseDeliveries)
|
|
.set({
|
|
// Keep an exhausted claim owned until recordTerminal commits its outcome.
|
|
status: exhausted ? "delivering" : "pending",
|
|
...(options.bounded ? { errorCount: nextErrorCount } : {}),
|
|
lastErrorCode: errorCode,
|
|
updatedAt: at,
|
|
})
|
|
.where(ownedClaim);
|
|
});
|
|
return exhausted;
|
|
}
|
|
|
|
async function withClaimLease<T>(
|
|
delivery: DeliveryRow,
|
|
operation: () => Promise<T>,
|
|
): Promise<T> {
|
|
let stopped = false;
|
|
let renewal = Promise.resolve();
|
|
const timer = setInterval(() => {
|
|
renewal = renewal
|
|
.then(async () => {
|
|
if (stopped) return;
|
|
const renewedAt = now();
|
|
const renewed = await db
|
|
.update(issueQuestionResponseDeliveries)
|
|
.set({
|
|
lastAttemptAt: renewedAt,
|
|
updatedAt: renewedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueQuestionResponseDeliveries.id, delivery.id),
|
|
eq(issueQuestionResponseDeliveries.status, "delivering"),
|
|
eq(
|
|
issueQuestionResponseDeliveries.attemptCount,
|
|
delivery.attemptCount,
|
|
),
|
|
),
|
|
)
|
|
.returning({ id: issueQuestionResponseDeliveries.id });
|
|
if (renewed.length === 0) stopped = true;
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(
|
|
{ err: error, deliveryId: delivery.id },
|
|
"question response claim lease renewal failed",
|
|
);
|
|
});
|
|
}, claimRefreshMs);
|
|
timer.unref?.();
|
|
let result: T | undefined;
|
|
let operationError: unknown;
|
|
try {
|
|
result = await operation();
|
|
} catch (error) {
|
|
operationError = error;
|
|
} finally {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
await renewal;
|
|
}
|
|
|
|
// `attemptCount` is the claim generation. A stale worker must not continue
|
|
// after a sweep has reclaimed the row for a newer attempt, even if its
|
|
// external side effect eventually resolves. Confirm ownership after the
|
|
// side effect so a rejected native steer cannot fall through to a second
|
|
// wake after losing its claim.
|
|
let ownsClaim = false;
|
|
try {
|
|
ownsClaim = await db
|
|
.select({ id: issueQuestionResponseDeliveries.id })
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(
|
|
and(
|
|
eq(issueQuestionResponseDeliveries.id, delivery.id),
|
|
eq(issueQuestionResponseDeliveries.status, "delivering"),
|
|
eq(
|
|
issueQuestionResponseDeliveries.attemptCount,
|
|
delivery.attemptCount,
|
|
),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows.length === 1);
|
|
} catch (error) {
|
|
logger.warn(
|
|
{ err: error, deliveryId: delivery.id },
|
|
"question response claim ownership check failed",
|
|
);
|
|
throw new DeliveryClaimUnavailableError();
|
|
}
|
|
if (!ownsClaim) throw new DeliveryClaimUnavailableError();
|
|
if (operationError !== undefined) throw operationError;
|
|
return result as T;
|
|
}
|
|
|
|
async function findDurableWakeRequest(input: {
|
|
companyId: string;
|
|
idempotencyKey: string;
|
|
}) {
|
|
const request = await db
|
|
.select({
|
|
agentId: agentWakeupRequests.agentId,
|
|
id: agentWakeupRequests.id,
|
|
runId: agentWakeupRequests.runId,
|
|
status: agentWakeupRequests.status,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.companyId, input.companyId),
|
|
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
|
|
inArray(agentWakeupRequests.status, [
|
|
...DURABLE_WAKE_REQUEST_STATUSES,
|
|
]),
|
|
),
|
|
)
|
|
.orderBy(desc(agentWakeupRequests.createdAt))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!request?.runId) return request ? { request, run: null } : null;
|
|
const run = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, request.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, request.agentId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
return { request, run };
|
|
}
|
|
|
|
async function deliver(
|
|
interactionId: string,
|
|
): Promise<QuestionResponseDeliveryOutcome | null> {
|
|
const claimed = await claim(interactionId);
|
|
if (!claimed) return terminalOutcome(interactionId);
|
|
|
|
const interaction = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, claimed.companyId),
|
|
eq(issueThreadInteractions.issueId, claimed.issueId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!interaction ||
|
|
interaction.kind !== "ask_user_questions" ||
|
|
interaction.status !== "answered"
|
|
) {
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction:
|
|
interaction ??
|
|
({
|
|
id: interactionId,
|
|
companyId: claimed.companyId,
|
|
issueId: claimed.issueId,
|
|
sourceRunId: claimed.sourceRunId,
|
|
resolvedByAgentId: null,
|
|
} as QuestionInteractionRow),
|
|
status: "failed",
|
|
mode: null,
|
|
targetRunId: null,
|
|
adapter: "unknown",
|
|
errorCode: "question_response_interaction_invalid",
|
|
});
|
|
}
|
|
|
|
const [issue, agent, sourceRun, externalInteractionPublication] =
|
|
await Promise.all([
|
|
db
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, interaction.issueId),
|
|
eq(issues.companyId, interaction.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null),
|
|
interaction.createdByAgentId
|
|
? db
|
|
.select({ adapterType: agents.adapterType })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, interaction.createdByAgentId),
|
|
eq(agents.companyId, interaction.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: Promise.resolve(null),
|
|
interaction.sourceRunId
|
|
? db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, interaction.sourceRunId),
|
|
eq(heartbeatRuns.companyId, interaction.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: Promise.resolve(null),
|
|
db
|
|
.select({ id: chatPublications.id })
|
|
.from(chatPublications)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, interaction.companyId),
|
|
eq(chatPublications.issueId, interaction.issueId),
|
|
eq(
|
|
sql<string>`${chatPublications.payload}->>'interactionId'`,
|
|
interaction.id,
|
|
),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null),
|
|
]);
|
|
const adapter = agent?.adapterType ?? "unknown";
|
|
if (
|
|
!issue ||
|
|
!issue.assigneeAgentId ||
|
|
issue.status === "done" ||
|
|
issue.status === "cancelled"
|
|
) {
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: "failed",
|
|
mode: null,
|
|
targetRunId: null,
|
|
adapter,
|
|
errorCode: !issue
|
|
? "question_response_issue_missing"
|
|
: "question_response_target_unavailable",
|
|
});
|
|
}
|
|
const assigneeAgentId = issue.assigneeAgentId;
|
|
const inferredSourceCommentId =
|
|
sourceRun && issueIdFromRun(sourceRun) === interaction.issueId
|
|
? sourceCommentIdFromRun(sourceRun)
|
|
: null;
|
|
const sourceCommentCandidate =
|
|
interaction.sourceCommentId ?? inferredSourceCommentId;
|
|
const continuationSourceCommentId = sourceCommentCandidate
|
|
? await db
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.id, sourceCommentCandidate),
|
|
eq(issueComments.companyId, interaction.companyId),
|
|
eq(issueComments.issueId, interaction.issueId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0]?.id ?? null)
|
|
: null;
|
|
|
|
const liveRuns = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, interaction.companyId),
|
|
eq(heartbeatRuns.agentId, assigneeAgentId),
|
|
inArray(heartbeatRuns.status, [
|
|
"queued",
|
|
"running",
|
|
"scheduled_retry",
|
|
]),
|
|
),
|
|
)
|
|
.orderBy(asc(heartbeatRuns.createdAt));
|
|
const issueRuns = liveRuns.filter(
|
|
(run) => issueIdFromRun(run) === interaction.issueId,
|
|
);
|
|
const issueRunIds = issueRuns.map((run) => run.id);
|
|
const interactionIdsFromRunContext = issueRuns
|
|
.map((run) => compactLine(record(run.contextSnapshot).interactionId))
|
|
.filter((value): value is string => Boolean(value));
|
|
const deliveryDerivedRuns =
|
|
issueRunIds.length > 0
|
|
? await db
|
|
.select({
|
|
interactionId: issueQuestionResponseDeliveries.interactionId,
|
|
targetRunId: issueQuestionResponseDeliveries.targetRunId,
|
|
})
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(
|
|
and(
|
|
eq(
|
|
issueQuestionResponseDeliveries.companyId,
|
|
interaction.companyId,
|
|
),
|
|
inArray(
|
|
issueQuestionResponseDeliveries.targetRunId,
|
|
issueRunIds,
|
|
),
|
|
inArray(issueQuestionResponseDeliveries.status, [
|
|
"delivering",
|
|
"delivered",
|
|
"fallback_queued",
|
|
]),
|
|
),
|
|
)
|
|
: [];
|
|
const derivedInteractionIds = [
|
|
...new Set([
|
|
...interactionIdsFromRunContext,
|
|
...deliveryDerivedRuns.map((row) => row.interactionId),
|
|
]),
|
|
];
|
|
const externallyPublishedInteractions =
|
|
derivedInteractionIds.length > 0
|
|
? await db
|
|
.select({
|
|
interactionId: sql<string>`${chatPublications.payload}->>'interactionId'`,
|
|
})
|
|
.from(chatPublications)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, interaction.companyId),
|
|
eq(chatPublications.issueId, interaction.issueId),
|
|
inArray(chatPublications.state, [
|
|
"published",
|
|
"streaming",
|
|
"delivery_unknown",
|
|
]),
|
|
inArray(
|
|
sql<string>`${chatPublications.payload}->>'interactionId'`,
|
|
derivedInteractionIds,
|
|
),
|
|
),
|
|
)
|
|
: [];
|
|
const externalInteractionIds = new Set(
|
|
externallyPublishedInteractions.map((row) => row.interactionId),
|
|
);
|
|
const externallyDerivedRunIds = new Set([
|
|
...issueRuns
|
|
.filter((run) => {
|
|
const interactionId = compactLine(
|
|
record(run.contextSnapshot).interactionId,
|
|
);
|
|
return Boolean(
|
|
interactionId && externalInteractionIds.has(interactionId),
|
|
);
|
|
})
|
|
.map((run) => run.id),
|
|
...deliveryDerivedRuns
|
|
.filter((row) => externalInteractionIds.has(row.interactionId))
|
|
.map((row) => row.targetRunId)
|
|
.filter((runId): runId is string => Boolean(runId)),
|
|
]);
|
|
// A run already carrying any external-chat turn is not a safe target for
|
|
// another interaction's answer. Without per-output causality, steering or
|
|
// coalescing would mix both turns and could disclose one provider's work
|
|
// through another provider bot. Use a dedicated continuation instead.
|
|
const externalChatBoundary =
|
|
Boolean(externalInteractionPublication) ||
|
|
hasExternalChatOrigin(sourceRun) ||
|
|
issueRuns.some(hasExternalChatOrigin) ||
|
|
externallyDerivedRunIds.size > 0;
|
|
// `executionRunId` is the issue's authoritative active-run pointer. Fall
|
|
// back to the newest matching running row only for legacy/racy rows where
|
|
// the pointer has not been populated yet; choosing the oldest stale row
|
|
// could steer an answer into the wrong provider turn.
|
|
const successorRunning =
|
|
(issue.executionRunId
|
|
? issueRuns.find(
|
|
(run) =>
|
|
run.id === issue.executionRunId &&
|
|
run.status === "running" &&
|
|
run.id !== interaction.sourceRunId,
|
|
)
|
|
: null) ??
|
|
[...issueRuns]
|
|
.reverse()
|
|
.find(
|
|
(run) =>
|
|
run.status === "running" && run.id !== interaction.sourceRunId,
|
|
) ??
|
|
null;
|
|
const queuedSuccessor =
|
|
issueRuns.find(
|
|
(run) =>
|
|
(run.status === "queued" || run.status === "scheduled_retry") &&
|
|
run.id !== interaction.sourceRunId,
|
|
) ?? null;
|
|
const hydratedInteraction = hydrateQuestionInteraction(interaction);
|
|
const envelope = buildQuestionResponseDeliveryEnvelope(hydratedInteraction);
|
|
if (nativeSha256(envelope) !== claimed.payloadSha256) {
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: "failed",
|
|
mode: null,
|
|
targetRunId: null,
|
|
adapter,
|
|
errorCode: "question_response_payload_digest_mismatch",
|
|
});
|
|
}
|
|
|
|
// A provider input request can keep its source process alive while it waits
|
|
// for an answer in either runtime mode. External chat answers intentionally
|
|
// continue in a fresh run so output from overlapping provider turns cannot
|
|
// be mixed. The old process must therefore release both the per-agent
|
|
// runner slot and the issue execution lock before the isolated continuation
|
|
// is enqueued. In particular, ACPX-backed local adapters may still persist
|
|
// as `legacy` runs even though their interaction is provider-native.
|
|
if (
|
|
externalChatBoundary &&
|
|
sourceRun?.id === interaction.sourceRunId &&
|
|
(sourceRun.status === "queued" || sourceRun.status === "running")
|
|
) {
|
|
try {
|
|
await withClaimLease(claimed, () =>
|
|
options.heartbeat.cancelRun(
|
|
sourceRun.id,
|
|
"Superseded by a dedicated external-chat answer continuation",
|
|
{
|
|
errorCode: "external_chat_continuation",
|
|
resultJson: {
|
|
interactionId: interaction.id,
|
|
externalChatContinuation: true,
|
|
},
|
|
eventMessage:
|
|
"source run cancelled for isolated external-chat answer continuation",
|
|
eventPayload: { interactionId: interaction.id },
|
|
terminationGraceMs: 2_000,
|
|
suppressImmediateRecovery: true,
|
|
},
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof DeliveryClaimUnavailableError)
|
|
return terminalOutcome(interactionId);
|
|
const errorCode =
|
|
error instanceof Error && compactLine(error.message)
|
|
? compactLine(error.message)!.slice(0, 160)
|
|
: "external_chat_source_run_cancellation_failed";
|
|
const exhausted = await releaseForRetry(claimed, errorCode);
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
deliveryId: claimed.id,
|
|
interactionId,
|
|
sourceRunId: sourceRun.id,
|
|
attemptCount: claimed.attemptCount,
|
|
errorCount: claimed.errorCount + 1,
|
|
exhausted,
|
|
},
|
|
"external-chat answer continuation will retry after source run cancellation failure",
|
|
);
|
|
if (!exhausted) return null;
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: "failed",
|
|
mode: null,
|
|
targetRunId: sourceRun.id,
|
|
adapter,
|
|
errorCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (resolveNativeQuestion && !externalChatBoundary) {
|
|
try {
|
|
const nativeDisposition = await withClaimLease(claimed, () =>
|
|
resolveNativeQuestion(hydratedInteraction),
|
|
);
|
|
if (nativeDisposition === "queued") {
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: "delivered",
|
|
mode: "steered",
|
|
targetRunId: interaction.sourceRunId,
|
|
adapter,
|
|
});
|
|
}
|
|
if (nativeDisposition === "pending") {
|
|
await releaseForRetry(
|
|
claimed,
|
|
"native_question_session_unavailable",
|
|
{ bounded: false },
|
|
);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DeliveryClaimUnavailableError)
|
|
return terminalOutcome(interactionId);
|
|
const errorCode =
|
|
error instanceof Error && compactLine(error.message)
|
|
? compactLine(error.message)!.slice(0, 160)
|
|
: "native_question_delivery_failed";
|
|
const exhausted = await releaseForRetry(claimed, errorCode);
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
deliveryId: claimed.id,
|
|
interactionId,
|
|
attemptCount: claimed.attemptCount,
|
|
errorCount: claimed.errorCount + 1,
|
|
exhausted,
|
|
},
|
|
"native question response delivery will retry",
|
|
);
|
|
if (!exhausted) return null;
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: "failed",
|
|
mode: null,
|
|
targetRunId: interaction.sourceRunId,
|
|
adapter,
|
|
errorCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
// An answer to an older question is new input, not implicit permission to
|
|
// steer another active turn. Only the queue's explicit Steer action delivers it.
|
|
const steeringErrorCode = successorRunning && externalChatBoundary
|
|
? "steering_external_chat_context_incompatible" : null;
|
|
|
|
const actor = actorForInteraction(interaction);
|
|
// This is a new, migration-fenced namespace. The partial unique index on
|
|
// agent_wakeup_requests makes the wake transaction itself idempotent, so a
|
|
// reclaimed stale worker cannot create a second continuation run.
|
|
const wakeIdempotencyKey = `question-response:${interaction.id}`;
|
|
try {
|
|
const existingWake = await findDurableWakeRequest({
|
|
companyId: interaction.companyId,
|
|
idempotencyKey: wakeIdempotencyKey,
|
|
});
|
|
const wakeRun =
|
|
existingWake?.run ??
|
|
(existingWake
|
|
? null
|
|
: await withClaimLease(claimed, () =>
|
|
options.heartbeat.wakeup(assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: issue.id,
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
sourceCommentId: continuationSourceCommentId,
|
|
sourceRunId: interaction.sourceRunId,
|
|
externalChatContinuation: externalChatBoundary,
|
|
...(continuationSourceCommentId
|
|
? {
|
|
wakeCommentId: continuationSourceCommentId,
|
|
wakeCommentIds: [continuationSourceCommentId],
|
|
}
|
|
: {}),
|
|
mutation: "interaction",
|
|
},
|
|
idempotencyKey: wakeIdempotencyKey,
|
|
allowRunCoalescing: !externalChatBoundary,
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: issue.id,
|
|
taskId: issue.id,
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
sourceCommentId: continuationSourceCommentId,
|
|
sourceRunId: interaction.sourceRunId,
|
|
externalChatContinuation: externalChatBoundary,
|
|
...(continuationSourceCommentId
|
|
? {
|
|
wakeCommentId: continuationSourceCommentId,
|
|
wakeCommentIds: [continuationSourceCommentId],
|
|
}
|
|
: {}),
|
|
wakeReason: "issue_commented",
|
|
source: "issue.interaction.respond",
|
|
},
|
|
}),
|
|
));
|
|
const durableWake =
|
|
existingWake ??
|
|
(wakeRun
|
|
? null
|
|
: await findDurableWakeRequest({
|
|
companyId: interaction.companyId,
|
|
idempotencyKey: wakeIdempotencyKey,
|
|
}));
|
|
if (!wakeRun && !durableWake) {
|
|
const errorCode = "question_response_wake_skipped";
|
|
// Scheduling suppression is an availability state, not a delivery
|
|
// failure. Keep the durable receipt retryable until the suppression is
|
|
// lifted; the bounded limit remains reserved for actual wake errors.
|
|
await releaseForRetry(claimed, errorCode, { bounded: false });
|
|
return null;
|
|
}
|
|
const eligibleQueuedSuccessor = externalChatBoundary
|
|
? null
|
|
: queuedSuccessor;
|
|
const targetRun =
|
|
wakeRun ?? durableWake?.run ?? eligibleQueuedSuccessor ?? null;
|
|
const coalesced = Boolean(
|
|
eligibleQueuedSuccessor && targetRun?.id === eligibleQueuedSuccessor.id,
|
|
);
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: coalesced ? "delivered" : "fallback_queued",
|
|
mode: coalesced ? "coalesced" : "wake_fallback",
|
|
targetRunId: targetRun?.id ?? null,
|
|
adapter: targetRun?.driverKind ?? adapter,
|
|
errorCode: steeringErrorCode,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof DeliveryClaimUnavailableError)
|
|
return terminalOutcome(interactionId);
|
|
if (
|
|
isUniqueViolation(error, QUESTION_RESPONSE_WAKE_IDEMPOTENCY_CONSTRAINT)
|
|
) {
|
|
// A concurrent claimant won the transactional wake fence after our
|
|
// preflight lookup. Reuse its committed receipt instead of consuming
|
|
// an error retry or issuing another continuation.
|
|
const durableWake = await findDurableWakeRequest({
|
|
companyId: interaction.companyId,
|
|
idempotencyKey: wakeIdempotencyKey,
|
|
});
|
|
if (durableWake) {
|
|
const eligibleQueuedSuccessor = externalChatBoundary
|
|
? null
|
|
: queuedSuccessor;
|
|
const targetRun = durableWake.run ?? eligibleQueuedSuccessor ?? null;
|
|
const coalesced = Boolean(
|
|
eligibleQueuedSuccessor &&
|
|
targetRun?.id === eligibleQueuedSuccessor.id,
|
|
);
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: coalesced ? "delivered" : "fallback_queued",
|
|
mode: coalesced ? "coalesced" : "wake_fallback",
|
|
targetRunId: targetRun?.id ?? null,
|
|
adapter: targetRun?.driverKind ?? adapter,
|
|
errorCode: steeringErrorCode,
|
|
});
|
|
}
|
|
}
|
|
const errorCode =
|
|
error instanceof Error && compactLine(error.message)
|
|
? compactLine(error.message)!.slice(0, 160)
|
|
: "question_response_wake_failed";
|
|
const exhausted = await releaseForRetry(claimed, errorCode);
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
deliveryId: claimed.id,
|
|
interactionId,
|
|
attemptCount: claimed.attemptCount,
|
|
errorCount: claimed.errorCount + 1,
|
|
exhausted,
|
|
},
|
|
"question response delivery will retry after wake failure",
|
|
);
|
|
if (!exhausted) return null;
|
|
return recordTerminal({
|
|
delivery: claimed,
|
|
interaction,
|
|
status: "failed",
|
|
mode: null,
|
|
targetRunId: null,
|
|
adapter,
|
|
errorCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function sweepPending(limit = 50) {
|
|
const sweepAt = now();
|
|
const staleAt = new Date(sweepAt.getTime() - claimStaleMs);
|
|
await db.transaction(async (tx) => {
|
|
const staleClaims = await tx.select({
|
|
id: issueQuestionResponseDeliveries.id,
|
|
companyId: issueQuestionResponseDeliveries.companyId,
|
|
issueId: issueQuestionResponseDeliveries.issueId,
|
|
}).from(issueQuestionResponseDeliveries).where(and(
|
|
eq(issueQuestionResponseDeliveries.status, "delivering"),
|
|
or(
|
|
isNull(issueQuestionResponseDeliveries.lastAttemptAt),
|
|
lte(issueQuestionResponseDeliveries.lastAttemptAt, staleAt),
|
|
),
|
|
)).orderBy(asc(issueQuestionResponseDeliveries.id)).for("update");
|
|
if (staleClaims.length === 0) return;
|
|
const notified = new Set<string>();
|
|
for (const claim of staleClaims) {
|
|
const scope = `${claim.companyId}:${claim.issueId}`;
|
|
if (notified.has(scope)) continue;
|
|
await notifyChatRunMilestones(tx, claim);
|
|
notified.add(scope);
|
|
}
|
|
for (let offset = 0; offset < staleClaims.length; offset += 500) {
|
|
await tx.update(issueQuestionResponseDeliveries)
|
|
.set({ status: "pending", updatedAt: sweepAt })
|
|
.where(inArray(issueQuestionResponseDeliveries.id, staleClaims.slice(offset, offset + 500).map(claim => claim.id)));
|
|
}
|
|
});
|
|
const ids = await db
|
|
.select({ interactionId: issueQuestionResponseDeliveries.interactionId })
|
|
.from(issueQuestionResponseDeliveries)
|
|
.where(eq(issueQuestionResponseDeliveries.status, "pending"))
|
|
.orderBy(asc(issueQuestionResponseDeliveries.createdAt))
|
|
.limit(limit)
|
|
.then((rows) => rows.map((row) => row.interactionId));
|
|
const counts = {
|
|
scanned: ids.length,
|
|
steered: 0,
|
|
coalesced: 0,
|
|
wakeFallback: 0,
|
|
failed: 0,
|
|
};
|
|
for (const id of ids) {
|
|
const outcome = await deliver(id);
|
|
if (outcome?.mode === "steered") counts.steered += 1;
|
|
else if (outcome?.mode === "coalesced") counts.coalesced += 1;
|
|
else if (outcome?.mode === "wake_fallback") counts.wakeFallback += 1;
|
|
else if (outcome?.status === "failed") counts.failed += 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
async function hasPending() {
|
|
return (await db.select({ id: issueQuestionResponseDeliveries.id }).from(issueQuestionResponseDeliveries)
|
|
.where(inArray(issueQuestionResponseDeliveries.status, ["pending", "delivering"])).limit(1)).length > 0;
|
|
}
|
|
return { deliver, sweepPending, hasPending };
|
|
}
|