Files
Devin FoleyandPaperclip 17fa718675 Retry transient disconnects in scoped read queries (#15248)
Apply existing bounded transient-disconnect retries to audited dashboard, heartbeat-list, and base issue reads. Rebuild each query per attempt and preserve authorization, company scope, enrichment, and final error propagation.

Validation: exact-head Greptile 5/5, passing CI, no unresolved review threads, and a clean merge. Detailed verification and limitations are recorded in the pull request.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-05 10:58:56 -07:00

13473 lines
457 KiB
TypeScript

import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js";
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js";
import { documentService } from "./documents.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "./task-search.js";
import { createdFromIssueCondition } from "./issue-creation-origin.js";
import { executionProjectionsForRuns } from "./execution-projection.js";
import type { ExecutionProjection } from "@paperclipai/shared";
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
import { isExplicitContinuationRetryClaim } from "./explicit-continuation-retry-claim.js";
import { markdownToPlainText, parseMarkdown } from "chat";
import {
and,
asc,
desc,
eq,
gt,
getTableColumns,
gte,
inArray,
isNotNull,
isNull,
like,
lt,
ne,
notExists,
notInArray,
or,
sql,
type SQL,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
chatActions,
chatConversations,
chatDeliveries,
chatEndpoints,
chatMessageLinks,
chatPublications,
agentWakeupRequests,
agents,
authUsers,
approvals,
assets,
companies,
companyMemberships,
documentRevisions,
documents,
goals,
heartbeatRuns,
routineRuns,
executionWorkspaces,
issueApprovals,
issueAttachments,
issueCreateIdempotencyKeys,
issueInboxArchives,
issueLabels,
issueWatchdogs,
issuePlanDecompositions,
issueRecoveryActions,
issueRelations,
issueComments,
issueDocuments,
issueQuestionResponseDeliveries,
issueWorkProducts,
issueReadStates,
issueThreadInteractions,
toolActionRequests,
toolActionDeliveries,
toolInvocations,
issues,
labels,
projectWorkspaces,
projects,
toolConnections,
workspaceOperations,
} from "@paperclipai/db";
import type {
AcceptedPlanDecomposition,
IssueComment,
IssueCommentAuthorType,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentPresentation,
IssueBlockerAttention,
IssueReviewAttention,
IssueReviewAttentionPath,
IssueBlockedInboxAttention,
IssueBlockedInboxIssueRef,
IssueRelationIssueSummary,
IssueWatchdogSummary,
LowTrustBoundary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import {
clampIssueRequestDepth,
extractAgentMentionIds,
extractProjectMentionIds,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
} from "@paperclipai/shared";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { isForeignKeyViolation } from "../db-errors.js";
import { logger } from "../middleware/logger.js";
import { parseObject } from "../adapters/utils.js";
import {
hydrateSuccessfulRunHandoffLiveness,
SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES,
} from "./successful-run-handoff-state.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
isUnrunnableWorktreeCombo,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolvePinnedIssueWorkspaceStrategyType,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
type ParsedExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { hasChatRunOwnedProviderInteraction } from "./chat-interaction-arbitration.js";
import { readChatControlChronology } from "./chat-control-chronology.js";
import { authorizeChatConversationForBoundRun } from "./native-runtime/chat-attachment-reuse.js";
import {
assertDurableChatWakeupReceipt,
createDurableChatWakeupRequest,
} from "./durable-chat-wakeup.js";
import {
authorizeNativeChatReviewPresentation,
retryNativeChatReviewPresentation,
} from "./native-runtime/native-chat-review-presentation.js";
import {
buildInitialIssueMonitorFields,
normalizeIssueExecutionPolicy,
} from "./issue-execution-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { redactSensitiveText } from "../redaction.js";
import {
resolveIssueGoalId,
resolveNextIssueGoalId,
} from "./issue-goal-fallback.js";
import { getRunLogStore } from "./run-log-store.js";
import { getDefaultCompanyGoal } from "./goals.js";
import { assertAssignableAgent } from "./agent-assignability.js";
import {
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON,
isExternalChatPresentationContext,
LEGACY_WITHHELD_RUN_COMMENT,
projectHistoricalHeartbeatRunComment,
} from "./heartbeat-run-summary.js";
import {
DEFAULT_INSERT_CHUNK_ROWS,
insertRowsInChunks,
} from "./batch-insert.js";
import type {
ImportIssueRow,
ImportIssueCommentRow,
ImportIssueAttachmentRow,
} from "./import-write-types.js";
import {
summarizeIssueWatchdog,
upsertIssueWatchdogForIssue,
} from "./task-watchdogs.js";
import {
isVerifiedIssueTreeControlInteractionWake,
issueTreeControlService,
type ActiveIssueTreePauseHoldGate,
} from "./issue-tree-control.js";
import {
parseIssueGraphLivenessIncidentKey,
RECOVERY_ORIGIN_KINDS,
} from "./recovery/origins.js";
import {
classifyIssueGraphLiveness,
classifyIssueReviewPaths,
type IssueGraphLivenessInput,
type IssueLivenessFinding,
} from "./recovery/issue-graph-liveness.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js";
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
import {
logActivity,
persistActivity,
publishActivity,
type ActivityPublication,
} from "./activity-log.js";
import { buildIssueChanges } from "./issue-change-receipt.js";
import { projectSafeChatPublication } from "./chat-publication-projection.js";
import { issueThreadInteractionAttentionAgentAllowed } from "./issue-thread-interaction-resolution.js";
const ALL_ISSUE_STATUSES = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
"cancelled",
];
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
const MAX_CHAT_PRESENTATION_ATTACHMENTS = 20;
export const ISSUE_LIST_DEFAULT_LIMIT = 500;
export const ISSUE_LIST_MAX_LIMIT = 1000;
export const ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS = 100;
export const ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS = 50;
export const ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS = 50;
export const ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS = 14;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH = 8;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES = 100;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE = 20;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE = 5;
export const ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE = 5;
const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
// Non-human author sentinels that agents post under. These ARE eligible for
// agent-attribution derivation even though `local-board` is also materialized
// as a row in the `user` table (it is the implicit board admin). Genuine human
// users — real signups with their own ids — are never reattributed.
const NON_HUMAN_SENTINEL_AUTHOR_USER_IDS = new Set<string>(["local-board"]);
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS = 3_000;
export const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS = 7;
const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS =
ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE = 500;
const DELETED_ISSUE_COMMENT_BODY = "";
const ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS = [
"issue.tree_hold_wakeup_deferred",
] as const;
export type IssuePostCommitAction = {
type: "cancel_native_question_run";
runId: string;
issueId: string;
issueStatus: string;
};
/** Execute side effects that must never run before the issue transaction commits. */
export async function executeIssuePostCommitActions(
db: Db,
actions: readonly IssuePostCommitAction[],
): Promise<void> {
if (actions.length === 0) return;
const { heartbeatService } = await import("./heartbeat.js");
const heartbeat = heartbeatService(db);
const cancelledRunIds = new Set<string>();
for (const action of actions) {
if (cancelledRunIds.has(action.runId)) continue;
cancelledRunIds.add(action.runId);
try {
await heartbeat.cancelRun(
action.runId,
"Task closed while waiting for operator input",
{
resultJson: {
cancelledByIssueStatus: action.issueStatus,
cancelledIssueId: action.issueId,
},
},
);
} catch (err) {
// The durable marker written by the issue transaction remains available
// to startup and periodic recovery. Do not report a post-commit failure
// as though the already-committed issue transition had rolled back.
logger.warn(
{ err, runId: action.runId, issueId: action.issueId },
"native question cancellation deferred to recovery sweep",
);
}
}
}
function wakeRequestTargetsIssue(issueId: string) {
return sql`(
${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issueId}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issueId}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issueId}
)`;
}
function wakeDiagnosticActivityTargetsIssue(issueId: string) {
return sql`(
(${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId})
or ${activityLog.details} ->> 'issueId' = ${issueId}
or ${activityLog.details} ->> 'rootIssueId' = ${issueId}
)`;
}
function assertTransition(from: string, to: string) {
if (from === to) return;
if (!ALL_ISSUE_STATUSES.includes(to)) {
throw conflict(`Unknown issue status: ${to}`);
}
}
function applyStatusSideEffects(
status: string | undefined,
patch: Partial<typeof issues.$inferInsert>,
): Partial<typeof issues.$inferInsert> {
if (!status) return patch;
if (status === "in_progress" && !patch.startedAt) {
patch.startedAt = new Date();
}
if (status === "done") {
patch.completedAt = new Date();
}
if (status === "cancelled") {
patch.cancelledAt = new Date();
}
return patch;
}
function workspaceWorktreeRequiresProjectDetails() {
return {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
};
}
function assertExplicitPinnedWorktreeIssueRunnable(input: {
projectId: string | null | undefined;
projectWorkspaceId: string | null | undefined;
executionWorkspaceId: string | null | undefined;
executionWorkspacePreference: string | null | undefined;
executionWorkspaceSettings: unknown;
}) {
const settings = parseIssueExecutionWorkspaceSettings(
input.executionWorkspaceSettings,
);
const mode = settings?.mode;
if (mode !== "isolated_workspace" && mode !== "operator_branch") return;
const resolvedMode = mode as ParsedExecutionWorkspaceMode;
if (
isUnrunnableWorktreeCombo({
issue: {
projectId: input.projectId ?? null,
projectWorkspaceId: input.projectWorkspaceId ?? null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference:
input.executionWorkspacePreference ?? null,
},
resolvedMode,
resolvedStrategy: resolvePinnedIssueWorkspaceStrategyType({
mode: resolvedMode,
issueSettings: settings,
}),
hasResolvablePriorSessionWorkspace: false,
})
) {
throw unprocessable(
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
workspaceWorktreeRequiresProjectDetails(),
);
}
}
function readStringFromRecord(record: unknown, key: string) {
if (!record || typeof record !== "object") return null;
const value = (record as Record<string, unknown>)[key];
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
async function resolveResponsibleUserIdForIssueCreate(
reader: DbReader,
companyId: string,
input: {
explicitResponsibleUserId?: string | null;
createdByUserId?: string | null;
parentId?: string | null;
originKind?: string | null;
originRunId?: string | null;
actorRunId?: string | null;
actorResponsibleUserId?: string | null;
trustExplicitResponsibleUserId?: boolean;
},
) {
const explicitResponsibleUserId = readStringFromRecord(
input,
"explicitResponsibleUserId",
);
if (
explicitResponsibleUserId &&
input.trustExplicitResponsibleUserId === true
)
return explicitResponsibleUserId;
if (input.originKind === "routine_execution" && input.originRunId) {
const routineRun = await reader
.select({ responsibleUserId: routineRuns.responsibleUserId })
.from(routineRuns)
.where(
and(
eq(routineRuns.companyId, companyId),
eq(routineRuns.id, input.originRunId),
),
)
.then((rows) => rows[0] ?? null);
if (routineRun?.responsibleUserId) return routineRun.responsibleUserId;
}
const actorResponsibleUserId = readStringFromRecord(
input,
"actorResponsibleUserId",
);
if (actorResponsibleUserId) return actorResponsibleUserId;
if (input.actorRunId) {
const actorRun = await reader
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.id, input.actorRunId),
),
)
.then((rows) => rows[0] ?? null);
if (actorRun?.responsibleUserId) return actorRun.responsibleUserId;
}
if (input.parentId) {
const parent = await reader
.select({
responsibleUserId: issues.responsibleUserId,
createdByUserId: issues.createdByUserId,
})
.from(issues)
.where(
and(eq(issues.companyId, companyId), eq(issues.id, input.parentId)),
)
.then((rows) => rows[0] ?? null);
if (parent?.responsibleUserId) return parent.responsibleUserId;
if (parent?.createdByUserId) return parent.createdByUserId;
}
return input.createdByUserId ?? null;
}
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
settings: ReturnType<typeof parseIssueExecutionWorkspaceSettings>,
) {
return {
environmentId: settings?.environmentId ?? null,
provisionCommand: settings?.workspaceStrategy?.provisionCommand ?? null,
runtimeProvisionCommand:
settings?.workspaceStrategy?.runtimeProvisionCommand ?? null,
teardownCommand: settings?.workspaceStrategy?.teardownCommand ?? null,
workspaceRuntime: settings?.workspaceRuntime ?? null,
};
}
// Accepted-plan children are not realized yet, so carry only unresolved
// workspace intent and let the first child run render/persist its own branch.
function buildPreRealizationExecutionWorkspaceSettings(
raw: unknown,
): Record<string, unknown> | null {
const settings = parseIssueExecutionWorkspaceSettings(raw, {
includeEnvironmentId: true,
});
if (!settings) return null;
const mode =
settings.mode &&
settings.mode !== "inherit" &&
settings.mode !== "reuse_existing"
? settings.mode
: null;
const next: Record<string, unknown> = {};
if (mode) next.mode = mode;
if (settings.environmentId !== undefined)
next.environmentId = settings.environmentId;
if (settings.workspaceRuntime)
next.workspaceRuntime = settings.workspaceRuntime;
if (settings.workspaceStrategy) {
next.workspaceStrategy = {
type: settings.workspaceStrategy.type,
...(settings.workspaceStrategy.baseRef
? { baseRef: settings.workspaceStrategy.baseRef }
: {}),
...(settings.workspaceStrategy.branchTemplate
? { branchTemplate: settings.workspaceStrategy.branchTemplate }
: {}),
...(settings.workspaceStrategy.existingBranch
? { existingBranch: settings.workspaceStrategy.existingBranch }
: {}),
...(settings.workspaceStrategy.worktreeParentDir
? { worktreeParentDir: settings.workspaceStrategy.worktreeParentDir }
: {}),
...(settings.workspaceStrategy.provisionCommand
? { provisionCommand: settings.workspaceStrategy.provisionCommand }
: {}),
...(settings.workspaceStrategy.runtimeProvisionCommand
? {
runtimeProvisionCommand:
settings.workspaceStrategy.runtimeProvisionCommand,
}
: {}),
...(settings.workspaceStrategy.teardownCommand
? { teardownCommand: settings.workspaceStrategy.teardownCommand }
: {}),
};
}
return Object.keys(next).length > 0 ? next : null;
}
function toTimestampMs(value: Date | string | null | undefined) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
const timestamp = date.getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
type IssueCommentRunLogAttributionCandidate = {
id: string;
createdAt: Date | string;
authorAgentId?: string | null;
authorUserId?: string | null;
createdByRunId?: string | null;
};
type IssueCommentRunLogAttributionRun = {
runId: string;
agentId: string;
createdAt: Date | string;
startedAt?: Date | string | null;
finishedAt?: Date | string | null;
// Best-effort run log text. May be empty when logs were not read for a tier
// that does not need them (run-id / run-window-unique); only the
// `run_log_comment_post` tier consults this.
logContent: string;
};
type DerivedIssueCommentAttribution = {
derivedAuthorAgentId: string;
derivedCreatedByRunId: string;
derivedAuthorSource: IssueCommentDerivedAuthorSource;
};
/**
* Resolve a `created_by_run_id` safe for the heartbeat_runs FK; returns null for
* missing/invalid ids so an unknown run id never 500s a comment insert.
*/
async function resolveCommentCreatedByRun(
dbOrTx: any,
companyId: string,
runId: string | null | undefined,
): Promise<{ id: string; contextSnapshot: unknown } | null> {
const normalized = typeof runId === "string" ? runId.trim() : "";
if (!normalized || !isUuidLike(normalized)) return null;
const existing = await dbOrTx
.select({
id: heartbeatRuns.id,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, normalized),
eq(heartbeatRuns.companyId, companyId),
),
)
.then(
(rows: Array<{ id: string; contextSnapshot: unknown }>) =>
rows[0] ?? null,
);
return existing;
}
export type ChatPublicationBinding = {
companyId: string;
conversationId: string;
endpointId: string;
};
async function resolvePublishedInteractionPromptBindings(
dbOrTx: any,
companyId: string,
issueId: string,
interactionId: string,
agentId: string,
): Promise<ChatPublicationBinding[]> {
const [publications, processedActions] = (await Promise.all([
dbOrTx
.select({
companyId: chatConversations.companyId,
conversationId: chatConversations.id,
endpointId: chatConversations.endpointId,
idempotencyKey: chatPublications.idempotencyKey,
payload: chatPublications.payload,
})
.from(chatPublications)
.innerJoin(
chatConversations,
and(
eq(chatConversations.companyId, chatPublications.companyId),
eq(chatConversations.endpointId, chatPublications.endpointId),
eq(chatConversations.id, chatPublications.conversationId),
),
)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.companyId, chatConversations.companyId),
eq(chatEndpoints.id, chatConversations.endpointId),
),
)
.where(
and(
eq(chatPublications.companyId, companyId),
eq(chatPublications.issueId, issueId),
eq(chatPublications.state, "published"),
eq(chatConversations.issueId, issueId),
eq(chatEndpoints.assignedAgentId, agentId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interactionId,
),
notExists(
dbOrTx
.select({ id: chatPublications.id })
.from(chatPublications)
.where(
and(
eq(chatPublications.conversationId, chatConversations.id),
eq(chatPublications.state, "published"),
or(
like(chatPublications.idempotencyKey, "control:new:%"),
like(chatPublications.idempotencyKey, "control:close:%"),
),
),
),
),
),
),
dbOrTx
.select({
conversationId: chatActions.conversationId,
kind: chatActions.kind,
result: chatActions.result,
})
.from(chatActions)
.where(
and(
eq(chatActions.companyId, companyId),
eq(chatActions.status, "processed"),
or(
inArray(chatActions.kind, [
"question_answer",
"question_form_submit",
]),
and(
eq(chatActions.kind, "confirmation_response"),
eq(
sql<string>`${chatActions.result}->>'code'`,
"confirmation_resolution_committed_by_provider",
),
),
),
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
interactionId,
),
),
),
])) as [
Array<
ChatPublicationBinding & {
idempotencyKey: string;
payload: { card?: { actions?: Array<{ type: string }> } };
}
>,
Array<{ conversationId: string }>,
];
const providerWinnerConversationIds = new Set(
processedActions.map(
(action: { conversationId: string }) => action.conversationId,
),
);
const exactProviderWinner = providerWinnerConversationIds.size > 0;
const bindings = publications
.filter(
(publication) =>
publication.idempotencyKey ===
`interaction:${interactionId}:${publication.endpointId}` &&
(!exactProviderWinner ||
providerWinnerConversationIds.has(publication.conversationId)),
)
.map((publication) => ({
companyId: publication.companyId,
conversationId: publication.conversationId,
endpointId: publication.endpointId,
}));
return [
...new Map(
bindings.map((binding) => [binding.conversationId, binding]),
).values(),
];
}
function readChatWakeCommentIds(
contextSnapshot: Record<string, unknown>,
): string[] {
const ids: string[] = [];
const append = (value: unknown) => {
if (typeof value !== "string") return;
const normalized = value.trim();
if (!normalized || !isUuidLike(normalized) || ids.includes(normalized)) {
return;
}
ids.push(normalized);
};
const batched = contextSnapshot.wakeCommentIds;
if (Array.isArray(batched)) {
for (const value of batched) append(value);
}
append(contextSnapshot.wakeCommentId);
append(contextSnapshot.commentId);
return ids;
}
/** A close retires its admitted sources, not every future source in a reusable
* provider thread. This exception requires a complete, independently admitted
* fresh batch; active state or copied run context alone never crosses a close.
* It grants presentation only. Provider dispatch retains its own current gate. */
async function freshChatSourceAfterPublishedControl(
dbOrTx: any,
companyId: string,
issueId: string,
runId: string,
binding: ChatPublicationBinding,
): Promise<boolean> {
const [run] = (await dbOrTx
.select()
.from(heartbeatRuns)
.where(
and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)),
)
.limit(1)) as Array<typeof heartbeatRuns.$inferSelect>;
const context = run?.contextSnapshot ?? {};
const commentIds = readChatWakeCommentIds(context);
if (
!run?.wakeupRequestId ||
!run.startedAt ||
!commentIds.length ||
commentIds.length > 50 ||
(run.nativeIssueId !== null && run.nativeIssueId !== issueId) ||
(context.issueId ?? context.taskId) !== issueId
)
return false;
const [owner] = (await dbOrTx
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.id, run.wakeupRequestId),
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, run.agentId),
eq(agentWakeupRequests.runId, run.id),
),
)
.limit(1)) as Array<typeof agentWakeupRequests.$inferSelect>;
if (!owner || ["skipped", "cancelled"].includes(owner.status)) return false;
const admitted = (await dbOrTx
.select({ commentId: sql<string>`${chatActions.payload}->>'commentId'` })
.from(chatActions)
.innerJoin(
agentWakeupRequests,
and(
eq(agentWakeupRequests.id, chatActions.id),
eq(agentWakeupRequests.companyId, chatActions.companyId),
),
)
.where(
and(
eq(chatActions.companyId, companyId),
eq(chatActions.kind, "inbound_wakeup"),
or(
eq(agentWakeupRequests.runId, run.id),
eq(agentWakeupRequests.id, owner.id),
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${owner.id}`,
),
),
)
.limit(51)) as Array<{ commentId: string }>;
if (
admitted.length !== commentIds.length ||
admitted.some((row) => !commentIds.includes(row.commentId))
)
return false;
const [latestControl] = (await dbOrTx
.select({ publishedAt: chatPublications.publishedAt })
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, companyId),
eq(chatPublications.endpointId, binding.endpointId),
eq(chatPublications.conversationId, binding.conversationId),
eq(chatPublications.state, "published"),
or(
like(chatPublications.idempotencyKey, "control:new:%"),
like(chatPublications.idempotencyKey, "control:close:%"),
),
),
)
.orderBy(desc(chatPublications.publishedAt))
.limit(1)) as Array<{ publishedAt: Date | null }>;
if (!latestControl?.publishedAt) return false;
const rows = (await dbOrTx
.select({
action: chatActions,
receipt: agentWakeupRequests,
link: chatMessageLinks,
delivery: chatDeliveries,
comment: issueComments,
conversation: chatConversations,
endpoint: chatEndpoints,
connection: toolConnections,
})
.from(chatActions)
.innerJoin(
agentWakeupRequests,
and(
eq(agentWakeupRequests.id, chatActions.id),
eq(agentWakeupRequests.companyId, chatActions.companyId),
),
)
.innerJoin(
chatMessageLinks,
and(
eq(chatMessageLinks.companyId, chatActions.companyId),
eq(chatMessageLinks.endpointId, chatActions.endpointId),
eq(chatMessageLinks.conversationId, chatActions.conversationId),
eq(chatMessageLinks.deliveryId, chatActions.deliveryId),
sql`${chatMessageLinks.commentId}::text = ${chatActions.payload}->>'commentId'`,
eq(chatMessageLinks.direction, "inbound"),
),
)
.innerJoin(
chatDeliveries,
and(
eq(chatDeliveries.id, chatActions.deliveryId),
eq(chatDeliveries.companyId, chatActions.companyId),
eq(chatDeliveries.endpointId, chatActions.endpointId),
eq(chatDeliveries.conversationId, chatActions.conversationId),
eq(chatDeliveries.principalId, chatActions.principalId),
),
)
.innerJoin(
issueComments,
and(
eq(issueComments.id, chatMessageLinks.commentId),
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
),
)
.innerJoin(
chatConversations,
and(
eq(chatConversations.id, binding.conversationId),
eq(chatConversations.id, chatActions.conversationId),
eq(chatConversations.companyId, companyId),
eq(chatConversations.issueId, issueId),
eq(chatConversations.endpointId, binding.endpointId),
),
)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.id, chatConversations.endpointId),
eq(chatEndpoints.companyId, companyId),
eq(chatEndpoints.assignedAgentId, run.agentId),
),
)
.innerJoin(
issues,
and(
eq(issues.id, issueId),
eq(issues.companyId, companyId),
eq(issues.assigneeAgentId, run.agentId),
isNull(issues.hiddenAt),
),
)
.innerJoin(
toolConnections,
and(
eq(toolConnections.id, chatEndpoints.connectionId),
eq(toolConnections.companyId, companyId),
),
)
.where(
and(
eq(chatActions.companyId, companyId),
eq(chatActions.kind, "inbound_wakeup"),
or(
eq(agentWakeupRequests.runId, run.id),
eq(agentWakeupRequests.id, owner.id),
sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${owner.id}`,
),
isNull(issueComments.deletedAt),
sql`${issueComments.updatedAt} = ${issueComments.createdAt}`,
sql`${issueComments.createdAt} <= (select started_at from heartbeat_runs where id = ${run.id})`,
// Compare in SQL, retaining microseconds. A delayed pre-close source or
// a source preceding any later close remains internal after reopening.
notExists(
dbOrTx
.select({ id: chatPublications.id })
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, companyId),
eq(chatPublications.endpointId, binding.endpointId),
eq(chatPublications.conversationId, binding.conversationId),
eq(chatPublications.state, "published"),
or(
like(chatPublications.idempotencyKey, "control:new:%"),
like(chatPublications.idempotencyKey, "control:close:%"),
),
or(
isNull(chatPublications.publishedAt),
gte(chatPublications.publishedAt, chatDeliveries.receivedAt),
),
),
),
),
),
)
.limit(51)) as Array<{
action: typeof chatActions.$inferSelect;
receipt: typeof agentWakeupRequests.$inferSelect;
link: typeof chatMessageLinks.$inferSelect;
delivery: typeof chatDeliveries.$inferSelect;
comment: typeof issueComments.$inferSelect;
conversation: typeof chatConversations.$inferSelect;
endpoint: typeof chatEndpoints.$inferSelect;
connection: typeof toolConnections.$inferSelect;
}>;
if (
rows.length !== commentIds.length ||
new Set(rows.map((row) => row.comment.id)).size !== commentIds.length ||
rows.some((row) => !commentIds.includes(row.comment.id))
)
return false;
for (const row of rows) {
const {
action,
receipt,
delivery,
comment,
conversation,
endpoint,
connection,
} = row;
const payload = action.payload;
const event = parseObject(delivery.normalizedEvent);
const fence = parseObject(event.runtimeContext);
const sourceConversation = parseObject(event.conversation);
const message = parseObject(event.message);
const sentAt =
typeof message.providerSentAt === "string" &&
message.providerSentAt.length <= 64
? Date.parse(message.providerSentAt)
: Number.NaN;
const refs = connection.credentialSecretRefs
.map((ref) => ({
configPath: ref.configPath,
secretId: ref.secretId,
versionSelector: ref.versionSelector ?? "latest",
}))
.sort((left, right) =>
`${left.configPath}:${left.secretId}:${left.versionSelector}`.localeCompare(
`${right.configPath}:${right.secretId}:${right.versionSelector}`,
),
);
if (
action.status !== "processed" ||
action.providerActionId !== `inbound_wakeup:${delivery.id}` ||
payload.version !== 1 ||
!action.principalId ||
payload.issueId !== issueId ||
payload.agentId !== run.agentId ||
payload.commentId !== comment.id ||
payload.sessionGeneration !== conversation.sessionGeneration ||
delivery.state !== "processed" ||
// Receipt time alone cannot make a delayed old provider message fresh.
// Missing or future provider time denies. The shared control reader below
// proves chronology against the actual command, not its later receipt.
!Number.isFinite(sentAt) ||
// Historical SDK display clocks were sometimes server-local fallbacks.
// They cannot be retrospectively promoted to post-control chronology.
(endpoint.provider === "microsoft-teams" &&
message.providerSentAtSource !== "teams_activity_timestamp") ||
(endpoint.provider === "telegram" &&
message.providerSentAtSource !== "telegram_message_date") ||
sentAt > delivery.receivedAt.getTime() ||
!delivery.processedAt ||
!connection.enabled ||
connection.status !== "active" ||
fence.generation !==
Number(parseObject(endpoint.setup).runtimeGeneration ?? 0) ||
fence.credentialFingerprint !==
createHash("sha256").update(JSON.stringify(refs)).digest("hex") ||
sourceConversation.externalConversationId !==
conversation.externalConversationId ||
sourceConversation.externalThreadId !== conversation.externalThreadId ||
message.providerMessageId !== row.link.providerMessageId ||
!["user", "system"].includes(String(payload.requestedByActorType)) ||
typeof payload.requestedByActorId !== "string" ||
receipt.payload?.issueId !== issueId ||
receipt.payload?.wakeCommentId !== comment.id ||
(receipt.id !== owner.id &&
(receipt.status !== "coalesced" ||
receipt.payload?.coalescedIntoWakeupRequestId !== owner.id)) ||
(payload.requestedByActorType === "user"
? comment.authorUserId !== payload.requestedByActorId
: comment.authorUserId !== null ||
payload.requestedByActorId !== action.principalId)
)
return false;
// The SQL above independently requires admission after every published
// control. A provider source after the command but before its confirmation
// can qualify only with complete exact proof for this published-control set.
if (
(await readChatControlChronology(
dbOrTx,
endpoint,
{
id: conversation.externalThreadId,
channelId: conversation.externalConversationId,
},
delivery,
{
requirePublishedControlProofForConversationId: binding.conversationId,
},
)) !== "after_all_proven_controls"
)
return false;
try {
assertDurableChatWakeupReceipt(
createDurableChatWakeupRequest({
id: action.id,
companyId,
agentId: run.agentId,
issueId,
commentId: comment.id,
requestedByActorType: payload.requestedByActorType as
"user" | "system",
requestedByActorId: payload.requestedByActorId,
requestedAt: action.createdAt,
authorize: async () => {},
}),
receipt,
);
} catch {
return false;
}
const [lifecycle] = await dbOrTx
.select({ id: chatDeliveries.id })
.from(chatDeliveries)
.where(
and(
eq(chatDeliveries.companyId, companyId),
eq(chatDeliveries.endpointId, binding.endpointId),
ne(chatDeliveries.state, "filtered"),
inArray(chatDeliveries.eventKind, [
"message_updated",
"message_deleted",
"message_restored",
]),
sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(fence)}::jsonb`,
sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`,
),
)
.limit(1);
if (lifecycle) return false;
}
try {
const current = await authorizeChatConversationForBoundRun(
dbOrTx,
{ companyId, issueId, runId, agentId: run.agentId },
context,
"read",
);
return (
current.conversationId === binding.conversationId &&
current.endpointId === binding.endpointId
);
} catch (error) {
if (
error instanceof Error &&
/^paperclip_runner_chat_attachment_(binding|destination|principal)_denied$/.test(
error.message,
)
)
return false;
throw error;
}
}
export async function resolveChatOriginPublicationBindings(
dbOrTx: any,
companyId: string,
issueId: string,
runId: string | null,
): Promise<ChatPublicationBinding[]> {
if (!runId) return [];
const explicitEmail = await dbOrTx.select({ id: chatEndpoints.id }).from(chatEndpoints)
.innerJoin(chatConversations, eq(chatConversations.endpointId, chatEndpoints.id))
.where(and(eq(chatConversations.companyId, companyId), eq(chatConversations.issueId, issueId), eq(chatEndpoints.publicationMode, "explicit"))).limit(1);
if (explicitEmail.length) return [];
let originRunId = runId;
let contextSnapshot: Record<string, unknown> | null = null;
let lineageAgentId: string | null = null;
const visitedRunIds = new Set<string>();
for (let depth = 0; depth < 16; depth += 1) {
if (visitedRunIds.has(originRunId)) return [];
visitedRunIds.add(originRunId);
const run = await dbOrTx
.select({
agentId: heartbeatRuns.agentId,
responsibleUserId: heartbeatRuns.responsibleUserId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, originRunId),
eq(heartbeatRuns.companyId, companyId),
),
)
.then(
(
rows: Array<{
agentId: string;
responsibleUserId: string | null;
contextSnapshot: Record<string, unknown> | null;
}>,
) => rows[0] ?? null,
);
if (!run?.agentId) return [];
if (lineageAgentId === null) lineageAgentId = run.agentId;
else if (run.agentId !== lineageAgentId) return [];
const snapshot = run?.contextSnapshot;
if (!snapshot) return [];
const snapshotIssueId =
readStringFromRecord(snapshot, "issueId") ??
readStringFromRecord(snapshot, "taskId");
if (snapshotIssueId && snapshotIssueId !== issueId) return [];
const boardBindings = await slackBoardReplyBindings(dbOrTx, {
companyId, issueId, agentId: lineageAgentId!,
commentIds: readChatWakeCommentIds(snapshot), userId: run.responsibleUserId,
});
if (boardBindings.length) return boardBindings;
// A native runner can emit its continuation immediately after the durable
// `request.resolve` command is queued, before the delivery worker records
// its terminal receipt. The source run plus the already-published native
// prompt is a stronger causal edge than that tiny receipt-commit window.
const inFlightNativeInteractions: Array<{ interactionId: string }> =
await dbOrTx
.select({
interactionId: issueQuestionResponseDeliveries.interactionId,
})
.from(issueQuestionResponseDeliveries)
.innerJoin(
issueThreadInteractions,
and(
eq(
issueThreadInteractions.companyId,
issueQuestionResponseDeliveries.companyId,
),
eq(
issueThreadInteractions.issueId,
issueQuestionResponseDeliveries.issueId,
),
eq(
issueThreadInteractions.id,
issueQuestionResponseDeliveries.interactionId,
),
),
)
.where(
and(
eq(issueQuestionResponseDeliveries.companyId, companyId),
eq(issueQuestionResponseDeliveries.issueId, issueId),
eq(issueQuestionResponseDeliveries.sourceRunId, originRunId),
eq(issueQuestionResponseDeliveries.status, "delivering"),
eq(issueThreadInteractions.kind, "ask_user_questions"),
eq(issueThreadInteractions.status, "answered"),
),
);
for (const candidate of inFlightNativeInteractions) {
const promptBindings = await resolvePublishedInteractionPromptBindings(
dbOrTx,
companyId,
issueId,
candidate.interactionId,
lineageAgentId!,
);
if (promptBindings.length > 0) return promptBindings;
}
// A question response can be delivered directly into an already-running
// successor without rewriting that run's pre-existing context snapshot.
// The durable delivery receipt is the authoritative causal edge and must
// win even when the target run already has a different `chat:*` source;
// otherwise a Slack answer steered into a Telegram-origin run on the same
// issue could publish the result to the wrong provider identity.
const steeredQuestionParents: Array<{
interactionId: string;
sourceRunId: string | null;
}> = await dbOrTx
.select({
interactionId: issueQuestionResponseDeliveries.interactionId,
sourceRunId: issueQuestionResponseDeliveries.sourceRunId,
})
.from(issueQuestionResponseDeliveries)
.innerJoin(
issueThreadInteractions,
and(
eq(
issueThreadInteractions.companyId,
issueQuestionResponseDeliveries.companyId,
),
eq(
issueThreadInteractions.issueId,
issueQuestionResponseDeliveries.issueId,
),
eq(
issueThreadInteractions.id,
issueQuestionResponseDeliveries.interactionId,
),
eq(
issueThreadInteractions.sourceRunId,
issueQuestionResponseDeliveries.sourceRunId,
),
),
)
.where(
and(
eq(issueQuestionResponseDeliveries.companyId, companyId),
eq(issueQuestionResponseDeliveries.issueId, issueId),
eq(issueQuestionResponseDeliveries.targetRunId, originRunId),
inArray(issueQuestionResponseDeliveries.status, [
"delivered",
"fallback_queued",
]),
eq(issueThreadInteractions.kind, "ask_user_questions"),
eq(issueThreadInteractions.status, "answered"),
),
);
if (steeredQuestionParents.length > 0) {
const parentRunIds = [
...new Set(
steeredQuestionParents
.map((candidate) => candidate.sourceRunId)
.filter((candidate): candidate is string => Boolean(candidate)),
),
];
// Multiple responses may coalesce into one target only when they share
// the same verified origin. Incompatible origins are intentionally
// internal rather than guessing a provider destination.
if (parentRunIds.length !== 1 || !isUuidLike(parentRunIds[0])) return [];
const promptBindings = (
await Promise.all(
[
...new Set(
steeredQuestionParents.map(
(candidate) => candidate.interactionId,
),
),
].map((interactionId) =>
resolvePublishedInteractionPromptBindings(
dbOrTx,
companyId,
issueId,
interactionId,
lineageAgentId!,
),
),
)
).flat();
if (promptBindings.length > 0) {
return [
...new Map(
promptBindings.map((binding) => [binding.conversationId, binding]),
).values(),
];
}
// Native delivery resumes the source run in place and records that same
// run as its target. That is already the chat root, not a lineage hop.
if (parentRunIds[0] !== originRunId) {
originRunId = parentRunIds[0];
continue;
}
}
const contextSource = readStringFromRecord(snapshot, "source");
if (contextSource === "tool_action_review") {
// Review results use their own durable wake, not the ordinary interaction
// response key. Attest the entire batch before following its origin; the
// model's context/sourceRunId alone never grants publication authority.
const [wake] = await dbOrTx.select({ payload: agentWakeupRequests.payload,
idempotencyKey: agentWakeupRequests.idempotencyKey })
.from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, lineageAgentId!),
eq(agentWakeupRequests.runId, originRunId),
like(agentWakeupRequests.idempotencyKey, "tool-action-response:%"),
)).limit(1);
const requestIds = wake?.payload?.toolActionRequestIds;
const sourceRunId = readStringFromRecord(snapshot, "sourceRunId");
if (!Array.isArray(requestIds) || requestIds.length === 0 ||
requestIds.some((id: unknown) => typeof id !== "string" || !isUuidLike(id)) ||
!sourceRunId || !isUuidLike(sourceRunId) || sourceRunId !== wake.payload.sourceRunId ||
wake.idempotencyKey !== `tool-action-response:${requestIds[0]}` ||
snapshot.interactionId !== wake.payload.interactionId) return [];
const receipts = await dbOrTx.select({ id: toolActionRequests.id })
.from(toolActionRequests)
.innerJoin(toolInvocations, and(
eq(toolInvocations.id, toolActionRequests.invocationId),
eq(toolInvocations.companyId, companyId),
eq(toolInvocations.issueId, issueId),
eq(toolInvocations.agentId, lineageAgentId!),
eq(toolInvocations.runId, sourceRunId),
))
.innerJoin(toolActionDeliveries, and(
eq(toolActionDeliveries.actionRequestId, toolActionRequests.id),
eq(toolActionDeliveries.companyId, companyId),
eq(toolActionDeliveries.issueId, issueId),
eq(toolActionDeliveries.interactionId, toolActionRequests.interactionId),
))
.where(and(
eq(toolActionRequests.companyId, companyId),
eq(toolActionRequests.issueId, issueId),
eq(toolActionRequests.requestedByAgentId, lineageAgentId!),
inArray(toolActionRequests.id, requestIds),
inArray(toolActionRequests.status, ["executed", "failed", "rejected", "expired", "cancelled"]),
));
if (receipts.length !== requestIds.length) return [];
originRunId = sourceRunId;
continue;
}
if (contextSource?.startsWith("chat:")) {
contextSnapshot = snapshot;
break;
}
if (
contextSource !== "issue.interaction.respond" &&
contextSource !== "issue.interaction.accept" &&
contextSource !== "issue.interaction.reject" &&
contextSource !== "issue.interaction.cancel" &&
contextSource !== "issue.interaction.withdraw" &&
contextSource !== "external_chat.interaction.resolve"
)
return [];
const interactionId = readStringFromRecord(snapshot, "interactionId");
const sourceRunId = readStringFromRecord(snapshot, "sourceRunId");
if (
!interactionId ||
!isUuidLike(interactionId) ||
!sourceRunId ||
!isUuidLike(sourceRunId)
)
return [];
const interaction = await dbOrTx
.select({
kind: issueThreadInteractions.kind,
status: issueThreadInteractions.status,
sourceRunId: issueThreadInteractions.sourceRunId,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, companyId),
eq(issueThreadInteractions.issueId, issueId),
eq(issueThreadInteractions.sourceRunId, sourceRunId),
),
)
.then(
(
rows: Array<{
kind: string;
status: string;
sourceRunId: string | null;
}>,
) => rows[0] ?? null,
);
if (!interaction?.sourceRunId) return [];
const isAnsweredQuestion =
interaction.kind === "ask_user_questions" &&
interaction.status === "answered";
const isResolvedConfirmation =
interaction.kind === "request_confirmation" &&
(interaction.status === "accepted" || interaction.status === "rejected");
const isCancelledSupportedInteraction =
interaction.status === "cancelled" &&
(interaction.kind === "ask_user_questions" ||
interaction.kind === "request_confirmation");
if (
!isAnsweredQuestion &&
!isResolvedConfirmation &&
!isCancelledSupportedInteraction
)
return [];
const responseDelivery = isAnsweredQuestion
? await dbOrTx
.select({
status: issueQuestionResponseDeliveries.status,
targetRunId: issueQuestionResponseDeliveries.targetRunId,
})
.from(issueQuestionResponseDeliveries)
.where(
and(
eq(issueQuestionResponseDeliveries.companyId, companyId),
eq(issueQuestionResponseDeliveries.issueId, issueId),
eq(issueQuestionResponseDeliveries.interactionId, interactionId),
eq(issueQuestionResponseDeliveries.sourceRunId, sourceRunId),
),
)
.then(
(rows: Array<{ status: string; targetRunId: string | null }>) =>
rows[0] ?? null,
)
: null;
const recordedTarget =
isAnsweredQuestion &&
responseDelivery &&
(responseDelivery.status === "delivered" ||
responseDelivery.status === "fallback_queued")
? responseDelivery.targetRunId
: null;
if (recordedTarget && recordedTarget !== originRunId) return [];
const continuationIdempotencyKey = isAnsweredQuestion
? `question-response:${interactionId}`
: `interaction:${interactionId}:${interaction.status}`;
const durableWakeTarget = recordedTarget
? null
: await dbOrTx
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(
agentWakeupRequests.idempotencyKey,
continuationIdempotencyKey,
),
eq(agentWakeupRequests.agentId, lineageAgentId!),
eq(agentWakeupRequests.runId, originRunId),
inArray(agentWakeupRequests.status, [
"queued",
"claimed",
"running",
"succeeded",
"completed",
"failed",
"cancelled",
"coalesced",
"deferred_issue_execution",
"retrying",
"scheduled_retry",
]),
),
)
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
if (recordedTarget !== originRunId && !durableWakeTarget) return [];
const promptBindings = await resolvePublishedInteractionPromptBindings(
dbOrTx,
companyId,
issueId,
interactionId,
lineageAgentId!,
);
if (promptBindings.length > 0) return promptBindings;
originRunId = interaction.sourceRunId;
}
if (!contextSnapshot || !lineageAgentId) return [];
const wakeCommentIds = readChatWakeCommentIds(contextSnapshot);
if (wakeCommentIds.length === 0) return [];
const originEndpointId = readStringFromRecord(contextSnapshot, "endpointId");
const bindings: Array<ChatPublicationBinding & { hasControl: boolean }> =
await dbOrTx
.select({
companyId: chatConversations.companyId,
conversationId: chatConversations.id,
endpointId: chatConversations.endpointId,
hasControl: sql<boolean>`exists (select 1 from chat_publications control where control.company_id = ${chatConversations.companyId} and control.endpoint_id = ${chatConversations.endpointId} and control.conversation_id = ${chatConversations.id} and control.state = 'published' and (control.idempotency_key like 'control:new:%' or control.idempotency_key like 'control:close:%'))`,
})
.from(chatMessageLinks)
.innerJoin(
chatConversations,
and(
eq(chatConversations.companyId, chatMessageLinks.companyId),
eq(chatConversations.endpointId, chatMessageLinks.endpointId),
eq(chatConversations.id, chatMessageLinks.conversationId),
),
)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.companyId, chatConversations.companyId),
eq(chatEndpoints.id, chatConversations.endpointId),
),
)
.where(
and(
eq(chatMessageLinks.companyId, companyId),
eq(chatMessageLinks.direction, "inbound"),
inArray(chatMessageLinks.commentId, wakeCommentIds),
eq(chatConversations.issueId, issueId),
eq(chatEndpoints.assignedAgentId, lineageAgentId),
...(originEndpointId
? [eq(chatConversations.endpointId, originEndpointId)]
: []),
),
);
const authorized: ChatPublicationBinding[] = [];
// A coalesced batch has one inbound link per comment. Prove each destination
// once, not once per link (the proof itself still checks the complete batch).
const candidates = new Map(
bindings.map((binding) => [binding.conversationId, binding]),
);
for (const { hasControl, ...binding } of candidates.values()) {
if (
!hasControl ||
(await freshChatSourceAfterPublishedControl(
dbOrTx,
companyId,
issueId,
originRunId,
binding,
))
)
authorized.push(binding);
}
return authorized;
}
async function resolveCommentResponsibleUserId(
dbOrTx: any,
companyId: string,
createdByRunId: string | null,
actorResponsibleUserId: string | null | undefined,
): Promise<string | null> {
const actorValue = actorResponsibleUserId?.trim() || null;
if (actorValue) return actorValue;
if (!createdByRunId) return null;
return dbOrTx
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, createdByRunId),
eq(heartbeatRuns.companyId, companyId),
),
)
.then(
(rows: Array<{ responsibleUserId: string | null }>) =>
rows[0]?.responsibleUserId?.trim() || null,
);
}
function withAgentCommentAuthorizationMetadata(
metadata: IssueCommentMetadata | null,
authorizationReason: string | null | undefined,
): IssueCommentMetadata {
const reason = authorizationReason?.trim() || "internal_agent_write";
return {
version: 1,
...(metadata?.sourceRunId !== undefined
? { sourceRunId: metadata.sourceRunId }
: {}),
authorizationReason: reason,
sections: metadata?.sections.length
? metadata.sections
: [
{
title: "Authorization",
rows: [{ type: "key_value", label: "Reason", value: reason }],
},
],
};
}
/**
* Chat-origin runs may create internal presentation and bookkeeping comments.
* Only comments produced through an explicit agent write surface are authored
* for the external conversation and therefore eligible for auto-publication.
*/
export function isExplicitExternalAgentComment(
metadata: IssueCommentMetadata | null | undefined,
): boolean {
const reason = metadata?.authorizationReason?.trim() ?? "";
return reason === "paperclip_runner_protocol" || reason.startsWith("allow_");
}
type SelectedChatPresentationAttachment = {
id: string;
commentId: string;
commentMetadata: IssueCommentMetadata | null;
originalFilename: string | null;
};
async function listSelectedChatPresentationAttachments(
dbOrTx: any,
input: {
companyId: string;
issueId: string;
agentId: string;
runId: string;
},
): Promise<SelectedChatPresentationAttachment[]> {
const rows = await dbOrTx
.select({
id: issueAttachments.id,
commentId: issueComments.id,
commentMetadata: issueComments.metadata,
originalFilename: assets.originalFilename,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.innerJoin(
issueComments,
eq(issueAttachments.issueCommentId, issueComments.id),
)
.where(
and(
eq(issueAttachments.companyId, input.companyId),
eq(issueAttachments.issueId, input.issueId),
eq(issueAttachments.originatingRunId, input.runId),
eq(assets.companyId, input.companyId),
eq(assets.createdByAgentId, input.agentId),
eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, input.issueId),
eq(issueComments.createdByRunId, input.runId),
eq(issueComments.authorType, "agent"),
eq(issueComments.authorAgentId, input.agentId),
isNull(issueComments.deletedAt),
sql<boolean>`(
coalesce(${issueComments.metadata}->>'authorizationReason', '') = 'paperclip_runner_protocol'
or left(coalesce(${issueComments.metadata}->>'authorizationReason', ''), 6) = 'allow_'
)`,
),
)
.orderBy(
asc(issueComments.createdAt),
asc(issueComments.id),
asc(issueAttachments.createdAt),
asc(issueAttachments.id),
);
return (rows as SelectedChatPresentationAttachment[]).filter((row) =>
isExplicitExternalAgentComment(row.commentMetadata),
);
}
/**
* Best-effort agent attribution for comments whose stored author is a non-human
* sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any
* comment whose `authorUserId` maps to a genuine user profile so a real board /
* user comment is never reattributed.
*
* Only LOSSLESS signals are used — a comment is reattributed solely when a run
* provably authored it. Pure run-window timing overlap is intentionally NOT a
* signal: because agents post through the `local-board` subprocess, an agent
* comment and a genuine human board comment are indistinguishable rows, so any
* timing-based guess mis-attributes human board comments that merely coincided
* with an agent run (Option A).
*
* Tiers, in descending confidence (first match wins per comment):
* 1. `run_id` — the comment's own `createdByRunId` resolves to an agent run
* (lossless: that run authored the comment).
* 2. `run_log_comment_post` — an overlapping run log contains the explicit
* `comment id: {id}` post marker (lossless: the run recorded posting it).
*/
export function deriveIssueCommentRunLogAttribution(
comments: readonly IssueCommentRunLogAttributionCandidate[],
runs: readonly IssueCommentRunLogAttributionRun[],
) {
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>();
const runById = new Map(runs.map((run) => [run.runId, run] as const));
for (const comment of comments) {
if (comment.authorAgentId || !comment.authorUserId) continue;
// Tier 1: the comment carries the run that authored it. Lossless even when
// the author was recorded as the `local-board` sentinel.
if (comment.createdByRunId) {
const ownRun = runById.get(comment.createdByRunId);
if (ownRun?.agentId) {
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: ownRun.agentId,
derivedCreatedByRunId: ownRun.runId,
derivedAuthorSource: "run_id",
});
continue;
}
}
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
if (commentCreatedAtMs === null) continue;
const overlappingRuns: Array<{
run: IssueCommentRunLogAttributionRun;
runEndMs: number;
}> = [];
for (const run of runs) {
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
if (runStartMs === null || runEndMs === null) continue;
if (
commentCreatedAtMs < runStartMs ||
commentCreatedAtMs >
runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS
) {
continue;
}
overlappingRuns.push({ run, runEndMs });
}
// Tier 2: an overlapping run log explicitly recorded posting this comment.
let bestLogMatch: {
runId: string;
agentId: string;
distanceMs: number;
} | null = null;
for (const { run, runEndMs } of overlappingRuns) {
if (!run.logContent.includes(`comment id: ${comment.id}`)) continue;
const distanceMs = Math.abs(runEndMs - commentCreatedAtMs);
if (!bestLogMatch || distanceMs < bestLogMatch.distanceMs) {
bestLogMatch = { runId: run.runId, agentId: run.agentId, distanceMs };
}
}
if (bestLogMatch) {
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: bestLogMatch.agentId,
derivedCreatedByRunId: bestLogMatch.runId,
derivedAuthorSource: "run_log_comment_post",
});
continue;
}
// No lossless signal — leave unresolved. A pure run-window timing overlap is
// deliberately NOT enough to reattribute (it cannot tell an agent comment
// from a human board comment that happened during the run).
}
return derivedByCommentId;
}
// Express's default `qs` parser binds repeated query keys to a `string[]`,
// so a request like `?status=todo&status=in_progress` arrives here as an
// array. Single-key + comma-separated forms remain valid too; normalize the
// supported shapes once so the service contract matches runtime reality.
export function parseStatusFilter(
input: string | readonly string[] | undefined,
): string[] {
if (input === undefined || input === null) return [];
const entries = Array.isArray(input)
? input
: typeof input === "string"
? [input]
: [];
return entries
.flatMap((entry) => (typeof entry === "string" ? entry.split(",") : []))
.map((status) => status.trim())
.filter(Boolean);
}
export interface IssueFilters {
attention?: "blocked";
status?: string | readonly string[];
/**
* Filter by assignee agent ID.
* - `string` (UUID): match issues assigned to that agent.
* - `null`: match unassigned issues (IS NULL).
* - The literal string `"null"` is also accepted as a sentinel for `null`
* so that query-string callers can pass `?assigneeAgentId=null` directly.
* The route layer normalises it before calling the service, but the service
* also normalises it for direct callers.
*/
assigneeAgentId?: string | null;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
workspaceId?: string;
executionWorkspaceId?: string;
parentId?: string;
descendantOf?: string;
createdFromIssueId?: string;
labelId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
includeRoutineExecutions?: boolean;
excludeRoutineExecutions?: boolean;
includePluginOperations?: boolean;
includeBlockedBy?: boolean;
includeBlockedInboxAttention?: boolean;
includeLiveDescendantSummary?: boolean;
hasPlanDocument?: boolean;
lowTrustBoundary?: LowTrustBoundary & { companyId: string };
q?: string;
limit?: number;
offset?: number;
sortField?: "updated" | "id";
afterId?: string;
sortDir?: "asc" | "desc";
/** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */
updatedSince?: string;
}
type IssueRow = typeof issues.$inferSelect & { externalConversationState?: "active" | "waiting" | null };
type IssueLabelRow = typeof labels.$inferSelect;
type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect;
type IssueActiveRunRow = {
execution?: ExecutionProjection;
id: string;
status: string;
agentId: string;
invocationSource: string;
triggerDetail: string | null;
startedAt: Date | null;
finishedAt: Date | null;
createdAt: Date;
};
type IssueScheduledRetryRow = {
runId: string;
status: "scheduled_retry" | "queued" | "running" | "cancelled";
agentId: string;
agentName: string | null;
retryOfRunId: string | null;
scheduledRetryAt: Date | null;
scheduledRetryAttempt: number;
scheduledRetryReason: string | null;
retryExhaustedReason?: string | null;
error?: string | null;
errorCode?: string | null;
};
type IssueWithLabels = IssueRow & {
labels: IssueLabelRow[];
labelIds: string[];
watchdog?: IssueWatchdogSummary | null;
};
type IssueWithLabelsAndRun = IssueWithLabels & {
activeRun: IssueActiveRunRow | null;
};
type IssueUserCommentStats = {
issueId: string;
myLastCommentAt: Date | null;
lastExternalCommentAt: Date | null;
};
type IssueReadStat = {
issueId: string;
myLastReadAt: Date | null;
};
type IssueLastActivityStat = {
issueId: string;
latestCommentAt: Date | null;
latestLogAt: Date | null;
};
function serializeAcceptedPlanDecomposition(
decomposition: IssuePlanDecompositionRow,
): AcceptedPlanDecomposition {
return {
id: decomposition.id,
companyId: decomposition.companyId,
sourceIssueId: decomposition.sourceIssueId,
acceptedPlanRevisionId: decomposition.acceptedPlanRevisionId,
acceptedInteractionId: decomposition.acceptedInteractionId,
status: decomposition.status as AcceptedPlanDecomposition["status"],
requestFingerprint: decomposition.requestFingerprint,
// Intentionally omit requestedChildren here; the API only needs stable counts
// and child ids, while the durable table keeps the full child draft payload.
requestedChildCount: decomposition.requestedChildCount,
childIssueIds: normalizeIssuePlanDecompositionChildIds(
decomposition.childIssueIds,
),
ownerAgentId: decomposition.ownerAgentId,
ownerUserId: decomposition.ownerUserId,
ownerRunId: decomposition.ownerRunId,
completedAt: decomposition.completedAt,
createdAt: decomposition.createdAt,
updatedAt: decomposition.updatedAt,
};
}
type IssueUserContextInput = {
createdByUserId: string | null;
assigneeUserId: string | null;
createdAt: Date | string;
updatedAt: Date | string;
};
type ProjectGoalReader = Pick<Db, "select">;
type DbReader = Pick<Db, "select">;
/** Conversation containers cannot acquire new child edges, even with the experiment disabled. */
async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: string | null) {
if (!parentId) return;
const [parent] = await db.select({ conversationAgentId: issues.conversationAgentId })
.from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, companyId)));
if (parent?.conversationAgentId) throw unprocessable("Conversations cannot have new subtasks; create a task in a project instead");
}
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration"> & {
title?: string;
initialPlan?: string | null;
labelIds?: string[];
blockedByIssueIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
skipExecutionWorkspaceInheritance?: boolean;
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
actorRunId?: string | null;
actorResponsibleUserId?: string | null;
trustExplicitResponsibleUserId?: boolean;
idempotencyKey?: string | null;
allowDuplicate?: boolean;
assertCanReuseIssue?: (issue: typeof issues.$inferSelect) => Promise<void>;
onDeduplicated?: (reason: "idempotency_key" | "recent_open_title") => void;
};
type IssueChildCreateInput = IssueCreateInput & {
acceptanceCriteria?: string[];
blockParentUntilDone?: boolean;
executionWorkspaceInheritanceMode?: "linkage" | "strategy_only";
actorAgentId?: string | null;
actorUserId?: string | null;
};
type AcceptedPlanDecompositionInput = {
acceptedPlanRevisionId: string;
children: IssueChildCreateInput[];
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
};
type AcceptedPlanDocumentInteraction = {
id: string;
};
type IssueRelationSummaryMap = {
blockedBy: IssueRelationIssueSummary[];
blocks: IssueRelationIssueSummary[];
};
type IssueBlockerDiagnosticsIssueRow = {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
identifier: string | null;
title: string;
status: (typeof ALL_ISSUE_STATUSES)[number];
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
type IssueWakeDiagnosticsWakeRequestRow = {
agentId: string;
source: string;
reason: string | null;
status: string;
coalescedCount: number;
runId: string | null;
requestedAt: Date;
claimedAt: Date | null;
finishedAt: Date | null;
error: string | null;
};
type IssueWakeDiagnosticsActivityRow = {
action: string;
entityType: string;
entityId: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
type IssueSubtreeDiagnosticsIssueRow = IssueBlockerDiagnosticsIssueRow & {
depth: number;
createdAt: Date;
updatedAt: Date;
};
type IssueSubtreeDiagnosticsBlockerRow = IssueBlockerDiagnosticsIssueRow & {
blockedIssueId: string;
relationCreatedAt: Date;
};
type IssueSubtreeDiagnosticsWakeRequestRow =
IssueWakeDiagnosticsWakeRequestRow & {
issueId: string;
};
type IssueSubtreeDiagnosticsActivityRow = IssueWakeDiagnosticsActivityRow & {
issueId: string;
};
type IssueSubtreeDiagnosticsBlockerResultRow =
IssueSubtreeDiagnosticsBlockerRow & {
rowNumber: number | string;
};
type IssueSubtreeDiagnosticsWakeRequestResultRow =
IssueSubtreeDiagnosticsWakeRequestRow & {
rowNumber: number | string;
};
type IssueSubtreeDiagnosticsActivityResultRow =
IssueSubtreeDiagnosticsActivityRow & {
rowNumber: number | string;
};
export type IssueDependencyReadiness = {
issueId: string;
blockerIssueIds: string[];
unresolvedBlockerIssueIds: string[];
unresolvedBlockerCount: number;
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
pendingFinalizeBlockerIssueIds: string[];
allBlockersDone: boolean;
isDependencyReady: boolean;
};
export type ChildIssueCompletionSummary = {
id: string;
identifier: string | null;
title: string;
status: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
updatedAt: Date;
summary: string | null;
};
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
}
export const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set([
"succeeded",
"interrupted",
"failed",
"cancelled",
"timed_out",
]);
const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200;
const ISSUE_LIST_DESCRIPTION_MAX_BYTES = ISSUE_LIST_DESCRIPTION_MAX_CHARS * 4;
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
}
export function clampIssueListLimit(limit: number): number {
return Math.min(ISSUE_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit)));
}
function chunkList<T>(values: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size));
}
return chunks;
}
function truncateInlineSummary(
value: string | null | undefined,
maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS,
) {
const normalized = value?.trim();
if (!normalized) return null;
return normalized.length > maxChars
? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]`
: normalized;
}
function truncateByCodePoint(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return Array.from(value).slice(0, maxChars).join("");
}
function decodeDatabaseTextPreview(
value: string | null | undefined,
maxChars: number,
): string | null {
if (value == null) return null;
return truncateByCodePoint(
Buffer.from(value, "base64").toString("utf8"),
maxChars,
);
}
function appendAcceptanceCriteriaToDescription(
description: string | null | undefined,
acceptanceCriteria: string[] | undefined,
) {
const criteria = (acceptanceCriteria ?? [])
.map((item) => item.trim())
.filter(Boolean);
if (criteria.length === 0) return description ?? null;
const base = description?.trim() ?? "";
const criteriaMarkdown = [
"## Acceptance Criteria",
"",
...criteria.map((item) => `- ${item}`),
].join("\n");
return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown;
}
function normalizeAcceptedPlanDecompositionFingerprintValue(
value: unknown,
): unknown {
if (value === undefined) return null;
if (
value == null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) {
return value.map((item) =>
normalizeAcceptedPlanDecompositionFingerprintValue(item),
);
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.sort()
.map((key) => [
key,
normalizeAcceptedPlanDecompositionFingerprintValue(record[key]),
]),
);
}
return String(value);
}
const ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS = new Set([
"id",
"companyId",
"parentId",
"identifier",
"checkoutRunId",
"executionRunId",
"executionLockedAt",
"startedAt",
"completedAt",
"cancelledAt",
"hiddenAt",
"createdAt",
"updatedAt",
"createdByAgentId",
"createdByUserId",
"updatedByAgentId",
"updatedByUserId",
"actorAgentId",
"actorUserId",
"executionWorkspaceInheritanceMode",
"skipExecutionWorkspaceInheritance",
]);
function normalizeAcceptedPlanDecompositionFingerprintChild(
child: IssueChildCreateInput,
) {
return Object.fromEntries(
Object.entries(child).filter(
([key]) =>
!ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS.has(key),
),
);
}
function createAcceptedPlanDecompositionRequestFingerprint(input: {
acceptedPlanRevisionId: string;
children: IssueChildCreateInput[];
}) {
const canonical = JSON.stringify(
normalizeAcceptedPlanDecompositionFingerprintValue({
acceptedPlanRevisionId: input.acceptedPlanRevisionId,
children: input.children.map(
normalizeAcceptedPlanDecompositionFingerprintChild,
),
}),
);
return createHash("sha256").update(canonical).digest("hex");
}
function normalizeIssuePlanDecompositionChildIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is string => typeof item === "string" && item.length > 0,
);
}
export function readAcceptedPlanConfirmationTarget(
payload: unknown,
fallbackIssueId?: string,
): {
revisionId: string;
key: string;
issueId: string;
} | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload))
return null;
const target = (payload as Record<string, unknown>).target;
if (!target || typeof target !== "object" || Array.isArray(target))
return null;
const record = target as Record<string, unknown>;
if (record.type !== "issue_document") return null;
const revisionId = readStringFromRecord(record, "revisionId");
const key = readStringFromRecord(record, "key");
const issueId = readStringFromRecord(record, "issueId") ?? fallbackIssueId;
if (!revisionId || !key || !issueId) return null;
return { revisionId, key, issueId };
}
async function resolveAcceptedPlanClaimOwner(input: {
dbOrTx: Pick<Db, "select">;
claim: Pick<
typeof issuePlanDecompositions.$inferSelect,
"ownerAgentId" | "ownerUserId" | "ownerRunId"
>;
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
}) {
const nextOwner = {
ownerAgentId: input.actorAgentId ?? null,
ownerUserId: input.actorUserId ?? null,
ownerRunId: input.actorRunId ?? null,
};
if (
input.claim.ownerAgentId === nextOwner.ownerAgentId &&
input.claim.ownerUserId === nextOwner.ownerUserId &&
input.claim.ownerRunId === nextOwner.ownerRunId
) {
return nextOwner;
}
if (!input.claim.ownerRunId) {
return nextOwner;
}
const existingOwnerRun = await input.dbOrTx
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.claim.ownerRunId))
.then((rows) => rows[0] ?? null);
if (
existingOwnerRun &&
!TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingOwnerRun.status)
) {
return {
ownerAgentId: input.claim.ownerAgentId,
ownerUserId: input.claim.ownerUserId,
ownerRunId: input.claim.ownerRunId,
};
}
return nextOwner;
}
async function findAcceptedPlanDocumentInteraction(
dbOrTx: Pick<Db, "select">,
input: {
companyId: string;
sourceIssueId: string;
acceptedPlanRevisionId: string;
},
): Promise<AcceptedPlanDocumentInteraction | null> {
const rows = await dbOrTx
.select({
id: issueThreadInteractions.id,
payload: issueThreadInteractions.payload,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.sourceIssueId),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "accepted"),
),
)
.orderBy(
desc(issueThreadInteractions.resolvedAt),
desc(issueThreadInteractions.createdAt),
);
for (const row of rows) {
const target = readAcceptedPlanConfirmationTarget(
row.payload,
input.sourceIssueId,
);
if (
target?.issueId === input.sourceIssueId &&
target.key === "plan" &&
target.revisionId === input.acceptedPlanRevisionId
) {
return { id: row.id };
}
}
return null;
}
function createIssueDependencyReadiness(
issueId: string,
): IssueDependencyReadiness {
return {
issueId,
blockerIssueIds: [],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
pendingFinalizeBlockerIssueIds: [],
allBlockersDone: true,
isDependencyReady: true,
};
}
/**
* Returns the set of execution-workspace ids whose most recent workspace operation
* is NOT a successful `workspace_finalize`. These workspaces have either an in-flight
* run, a failed finalize, or never reached the finalize barrier — dependents that
* read this workspace must wait until finalize succeeds.
*
* Workspaces with no recorded operations are considered finalized (nothing has
* touched them since they were realized).
*/
export async function listUnfinalizedExecutionWorkspaceIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
executionWorkspaceIds: string[],
): Promise<Set<string>> {
const unfinalized = new Set<string>();
if (executionWorkspaceIds.length === 0) return unfinalized;
// Pull every workspace op for the candidate workspaces and pick the latest per
// workspace in memory. Per-workspace LATERAL queries would be tighter, but the
// candidate set is tiny in practice (one workspace per blocker per readiness call).
const rows = await dbOrTx
.select({
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(
workspaceOperations.executionWorkspaceId,
executionWorkspaceIds,
),
),
);
const latestByWorkspace = new Map<
string,
{ phase: string; status: string; startedAt: Date }
>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
const current = latestByWorkspace.get(row.executionWorkspaceId);
if (!current || row.startedAt > current.startedAt) {
latestByWorkspace.set(row.executionWorkspaceId, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
}
for (const workspaceId of executionWorkspaceIds) {
const latest = latestByWorkspace.get(workspaceId);
if (!latest) continue; // no ops recorded → treat as finalized
if (latest.phase === "workspace_finalize" && latest.status === "succeeded")
continue;
unfinalized.add(workspaceId);
}
return unfinalized;
}
async function listPendingFinalizeBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
blockerWorkspacePairs: Array<{
blockerIssueId: string;
executionWorkspaceId: string;
}>,
): Promise<Set<string>> {
const pending = new Set<string>();
const blockerIssueIds = [
...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId)),
];
const executionWorkspaceIds = [
...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId)),
];
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0)
return pending;
const blockerWorkspaceKeys = new Set(
blockerWorkspacePairs.map(
(pair) => `${pair.blockerIssueId}:${pair.executionWorkspaceId}`,
),
);
const rows = await dbOrTx
.select({
issueId: workspaceOperations.issueId,
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(
workspaceOperations.executionWorkspaceId,
executionWorkspaceIds,
),
),
);
const latestAttributedByBlockerWorkspace = new Map<
string,
{ phase: string; status: string; startedAt: Date }
>();
const latestUnattributedByWorkspace = new Map<
string,
{ phase: string; status: string; startedAt: Date }
>();
const latestSuccessfulFinalizeByWorkspace = new Map<string, Date>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
if (row.phase === "workspace_finalize" && row.status === "succeeded") {
const current = latestSuccessfulFinalizeByWorkspace.get(
row.executionWorkspaceId,
);
if (!current || row.startedAt > current) {
latestSuccessfulFinalizeByWorkspace.set(
row.executionWorkspaceId,
row.startedAt,
);
}
}
if (row.issueId) {
const key = `${row.issueId}:${row.executionWorkspaceId}`;
if (!blockerWorkspaceKeys.has(key)) continue;
const current = latestAttributedByBlockerWorkspace.get(key);
if (!current || row.startedAt > current.startedAt) {
latestAttributedByBlockerWorkspace.set(key, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
continue;
}
const current = latestUnattributedByWorkspace.get(row.executionWorkspaceId);
if (!current || row.startedAt > current.startedAt) {
latestUnattributedByWorkspace.set(row.executionWorkspaceId, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
}
for (const pair of blockerWorkspacePairs) {
const latest =
latestAttributedByBlockerWorkspace.get(
`${pair.blockerIssueId}:${pair.executionWorkspaceId}`,
) ?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId);
if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker
if (latest.phase === "workspace_finalize" && latest.status === "succeeded")
continue;
const laterSuccessfulFinalize = latestSuccessfulFinalizeByWorkspace.get(
pair.executionWorkspaceId,
);
if (laterSuccessfulFinalize && laterSuccessfulFinalize > latest.startedAt)
continue;
pending.add(pair.blockerIssueId);
}
return pending;
}
/**
* Whether a heartbeat run has reached a terminal state or no longer exists.
* A terminal/missing run can make no further progress on its execution
* workspace, so callers must not wait on it to advance an in-flight operation.
*/
export async function heartbeatRunIsTerminalOrMissing(
dbOrTx: Pick<Db, "select">,
runId: string,
): Promise<boolean> {
const run = await dbOrTx
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows: Array<{ status: string }>) => rows[0] ?? null);
if (!run) return true;
return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status);
}
/**
* Returns whether a specific run's sync-back on a specific execution workspace
* has settled — i.e. the accept/review gates that guard against a still-in-flight
* worktree sync no longer need to block on this run.
*
* Semantics:
* - No operations recorded → settled. The run never touched the workspace state
* the gates protect.
* - Earlier phases recorded but no `workspace_finalize` yet → NOT settled. The
* sync-back hasn't been attempted; the gate should wait for it.
* - Latest `workspace_finalize` reached a terminal status (`succeeded`, `failed`,
* or `skipped`) → settled. A finalize that ran and finished is done even if it
* failed: it will not retry within this run, so continuing to block would wedge
* the gate forever — a failed sync-back must not permanently block a
* confirmation accept behind a misleading "still syncing" error.
* - Latest `workspace_finalize` is still `running` → in flight, so NOT settled —
* unless the owning run has itself ended, in which case the `running` record is
* stale (the process died mid-finalize) and we treat it as settled rather than
* wait on a run that can never make progress.
*/
export async function runWorkspaceIsFinalized(
dbOrTx: Pick<Db, "select">,
companyId: string,
executionWorkspaceId: string,
runId: string,
): Promise<boolean> {
const rows = await dbOrTx
.select({
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
eq(workspaceOperations.heartbeatRunId, runId),
),
);
if (rows.length === 0) return true;
let latestFinalize: { status: string; startedAt: Date } | null = null;
for (const row of rows) {
if (row.phase !== "workspace_finalize") continue;
if (!latestFinalize || row.startedAt > latestFinalize.startedAt)
latestFinalize = row;
}
// The run touched the workspace but hasn't reached the sync-back phase yet.
if (!latestFinalize) return false;
// A finalize that reached any terminal status is settled — including `failed`
// and `skipped`. It will not retry within this run, so gates must stop waiting.
if (latestFinalize.status !== "running") return true;
// Finalize is still marked `running`. It is only genuinely in flight while the
// owning run is alive; a `running` record left behind by an ended run is stale.
return heartbeatRunIsTerminalOrMissing(dbOrTx, runId);
}
async function listIssueDependencyReadinessMap(
dbOrTx: Pick<Db, "select">,
companyId: string,
issueIds: string[],
) {
const uniqueIssueIds = [...new Set(issueIds.filter(Boolean))];
const readinessMap = new Map<string, IssueDependencyReadiness>();
for (const issueId of uniqueIssueIds) {
readinessMap.set(issueId, createIssueDependencyReadiness(issueId));
}
if (uniqueIssueIds.length === 0) return readinessMap;
const blockerRows = await dbOrTx
.select({
issueId: issueRelations.relatedIssueId,
blockerIssueId: issueRelations.issueId,
blockerStatus: issues.status,
blockerExecutionWorkspaceId: issues.executionWorkspaceId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
),
);
// Collect issue/workspace pairs of "done" blockers — these are the only ones
// subject to the workspace-finalize barrier. Blockers that aren't done already
// mark the dependent as not-ready and don't need a finalize check.
const doneBlockerWorkspacePairs: Array<{
blockerIssueId: string;
executionWorkspaceId: string;
}> = [];
for (const row of blockerRows) {
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
doneBlockerWorkspacePairs.push({
blockerIssueId: row.blockerIssueId,
executionWorkspaceId: row.blockerExecutionWorkspaceId,
});
}
}
const pendingFinalizeBlockerIssueIds =
await listPendingFinalizeBlockerIssueIds(
dbOrTx,
companyId,
doneBlockerWorkspacePairs,
);
for (const row of blockerRows) {
const current =
readinessMap.get(row.issueId) ??
createIssueDependencyReadiness(row.issueId);
current.blockerIssueIds.push(row.blockerIssueId);
// Only done blockers resolve dependents; cancelled blockers stay unresolved
// until an operator removes or replaces the blocker relationship explicitly.
if (row.blockerStatus !== "done") {
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
current.unresolvedBlockerCount += 1;
current.allBlockersDone = false;
current.isDependencyReady = false;
} else if (
row.blockerExecutionWorkspaceId &&
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
) {
// Workspace-finalize barrier: the blocker's most recent run on its
// execution workspace hasn't recorded a successful workspace_finalize.
// Treat the dependent as not-ready until sync-back lands (or the run
// finalizes); a subsequent finalize wake will re-evaluate readiness.
// `allBlockersDone` is cleared too so that callers using it as a
// proxy for "this dependent can proceed" still see the gate.
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
current.unresolvedBlockerCount += 1;
current.pendingFinalizeBlockerIssueIds.push(row.blockerIssueId);
current.allBlockersDone = false;
current.isDependencyReady = false;
}
readinessMap.set(row.issueId, current);
}
return readinessMap;
}
async function listUnresolvedBlockerDetails(
dbOrTx: Pick<Db, "select">,
companyId: string,
unresolvedBlockerIssueIds: string[],
pendingFinalizeBlockerIssueIds: string[] = [],
) {
if (unresolvedBlockerIssueIds.length === 0) return [];
const pendingFinalizeIds = new Set(pendingFinalizeBlockerIssueIds);
const rows = await dbOrTx
.select({
issueId: issues.id,
identifier: issues.identifier,
title: issues.title,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.id, unresolvedBlockerIssueIds),
),
);
const rowsById = new Map(rows.map((row) => [row.issueId, row]));
return unresolvedBlockerIssueIds.map((issueId) => {
const row = rowsById.get(issueId);
return {
issueId,
identifier: row?.identifier ?? null,
title: row?.title ?? null,
reason: pendingFinalizeIds.has(issueId)
? ("pending_finalize" as const)
: ("not_done" as const),
};
});
}
async function listUnresolvedBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
blockerIssueIds: string[],
) {
const uniqueBlockerIssueIds = [...new Set(blockerIssueIds.filter(Boolean))];
if (uniqueBlockerIssueIds.length === 0) return [];
return dbOrTx
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.id, uniqueBlockerIssueIds),
// Cancelled blockers intentionally remain unresolved until the relation changes.
ne(issues.status, "done"),
),
)
.then((rows) => rows.map((row) => row.id));
}
async function getProjectDefaultGoalId(
db: ProjectGoalReader,
companyId: string,
projectId: string | null | undefined,
) {
if (!projectId) return null;
const row = await db
.select({ goalId: projects.goalId })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return row?.goalId ?? null;
}
async function getWorkspaceInheritanceIssue(
db: DbReader,
companyId: string,
issueId: string,
) {
const issue = await db
.select({
id: issues.id,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!issue) {
throw notFound("Workspace inheritance issue not found");
}
return issue;
}
// Mine participation fails closed. Add new user-authored issue mutation actions
// here instead of admitting every issue activity, because reads, previews, and
// denied resource requests are audited too.
const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [
"issue.accepted_plan_decomposition_updated",
"issue.admin_force_release",
"issue.approval_linked",
"issue.approval_unlinked",
"issue.approvers_updated",
"issue.assigned",
"issue.attachment_added",
"issue.attachment_removed",
"issue.blockers.updated",
"issue.blockers_updated",
"issue.checked_out",
"issue.checkout",
"issue.child_created",
"issue.comment_cancelled",
"issue.document_annotation_comment_added",
"issue.document_annotation_remapped",
"issue.document_annotation_thread_created",
"issue.document_annotation_thread_resolved",
"issue.document_deleted",
"issue.document_locked",
"issue.document_restored",
"issue.document_unlocked",
"issue.document_updated",
"issue.document_upserted",
"issue.feedback_vote_saved",
"issue.inbox_touched",
"issue.low_trust_output_promoted",
"issue.monitor_cleared",
"issue.monitor_scheduled",
"issue.recovery_action_resolved",
"issue.relations.updated",
"issue.released",
"issue.reviewers_updated",
"issue.scheduled_retry_retry_now",
"issue.successful_run_handoff_resolved",
"issue.task_watchdog_fingerprint_reviewed",
"issue.thread_interaction_accepted",
"issue.thread_interaction_answered",
"issue.thread_interaction_cancelled",
"issue.thread_interaction_created",
"issue.thread_interaction_item_verdicts_submitted",
"issue.thread_interaction_withdrawn",
"issue.tree_cancel_status_updated",
"issue.tree_hold_created",
"issue.tree_hold_released",
"issue.tree_restore_status_updated",
"issue.updated",
"issue.watchdog_created",
"issue.watchdog_removed",
"issue.work_product_created",
"issue.work_product_deleted",
"issue.work_product_updated",
] as const;
function touchedByUserCondition(companyId: string, userId: string) {
return sql<boolean>`
(
${issues.createdByUserId} = ${userId}
OR ${issues.assigneeUserId} = ${userId}
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.companyId} = ${companyId}
AND ${activityLog.actorType} = 'user'
AND ${activityLog.actorId} = ${userId}
AND ${activityLog.action} IN (${sql.join(
ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
),
sql`, `,
)})
)
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorUserId} = ${userId}
)
)
`;
}
function participatedByAgentCondition(companyId: string, agentId: string) {
return sql<boolean>`
(
${issues.createdByAgentId} = ${agentId}
OR ${issues.assigneeAgentId} = ${agentId}
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorAgentId} = ${agentId}
)
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.agentId} = ${agentId}
)
)
`;
}
function myLastCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorUserId} = ${userId}
)
`;
}
function myLastReadAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueReadStates.lastReadAt})
FROM ${issueReadStates}
WHERE ${issueReadStates.issueId} = ${issues.id}
AND ${issueReadStates.companyId} = ${companyId}
AND ${issueReadStates.userId} = ${userId}
)
`;
}
function myLastTouchAtExpr(companyId: string, userId: string) {
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
const myLastReadAt = myLastReadAtExpr(companyId, userId);
return sql<Date | null>`
GREATEST(
COALESCE(${myLastCommentAt}, to_timestamp(0)),
COALESCE(${myLastReadAt}, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
)
`;
}
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
"issue.read_marked",
"issue.read_unmarked",
"issue.inbox_archived",
"issue.inbox_unarchived",
] as const;
function issueLatestCommentAtExpr(companyId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
)
`;
}
function issueLatestLogAtExpr(companyId: string) {
return sql<Date | null>`
(
SELECT MAX(${activityLog.createdAt})
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
sql`, `,
)})
)
`;
}
function issueCanonicalLastActivityAtExpr(companyId: string) {
const latestCommentAt = issueLatestCommentAtExpr(companyId);
const latestLogAt = issueLatestLogAtExpr(companyId);
return sql<Date>`
GREATEST(
${issues.updatedAt},
COALESCE(${latestCommentAt}, to_timestamp(0)),
COALESCE(${latestLogAt}, to_timestamp(0))
)
`;
}
function unreadForUserCondition(companyId: string, userId: string) {
const touchedCondition = touchedByUserCondition(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
return sql<boolean>`
(
${touchedCondition}
AND EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND (
${issueComments.authorUserId} IS NULL
OR ${issueComments.authorUserId} <> ${userId}
)
AND ${issueComments.createdAt} > ${myLastTouchAt}
)
)
`;
}
function inboxVisibleForUserCondition(companyId: string, userId: string) {
return sql<boolean>`
NOT EXISTS (
SELECT 1
FROM ${issueInboxArchives}
WHERE ${issueInboxArchives.issueId} = ${issues.id}
AND ${issueInboxArchives.companyId} = ${companyId}
AND ${issueInboxArchives.userId} = ${userId}
AND NOT (
EXISTS (
SELECT 1
FROM ${issueThreadInteractions}
WHERE ${issueThreadInteractions.issueId} = ${issues.id}
AND ${issueThreadInteractions.companyId} = ${companyId}
AND ${issueThreadInteractions.kind} IN (
'suggest_tasks',
'ask_user_questions',
'request_confirmation'
)
AND ${issueThreadInteractions.createdAt} > ${issueInboxArchives.archivedAt}
)
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.action} = 'issue.updated'
AND ${activityLog.createdAt} > ${issueInboxArchives.archivedAt}
AND ${activityLog.details}->>'status' IN ('in_review', 'blocked', 'done')
AND ${activityLog.details}->'_previous'->>'status'
IS DISTINCT FROM ${activityLog.details}->>'status'
AND NOT (
${activityLog.details}->>'status' = 'done'
AND ${issues.completedAt} IS NOT NULL
AND ${issueInboxArchives.archivedAt} >= ${issues.completedAt}
)
)
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.createdAt} > ${issueInboxArchives.archivedAt}
AND ${issueComments.deletedAt} IS NULL
AND (
(
${issueComments.authorUserId} IS NOT NULL
AND ${issueComments.authorUserId} <> ${userId}
AND ${issueComments.authorAgentId} IS NULL
AND ${issueComments.derivedAuthorAgentId} IS NULL
)
OR POSITION(${`](user://${userId})`} IN ${issueComments.body}) > 0
)
)
)
)
`;
}
const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [
"plugin:paperclipai.content-machine:case",
"plugin:paperclipai.content-machine:evaluation",
"plugin:paperclipai.content-machine:source-sync",
] as const;
function nonPluginOperationIssueCondition() {
return sql<boolean>`NOT (
${issues.originKind} LIKE 'plugin:%:operation'
OR ${issues.originKind} LIKE 'plugin:%:operation:%'
OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)}
)`;
}
function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
return Boolean(
filters?.includePluginOperations ||
filters?.originKind ||
filters?.originKindPrefix ||
filters?.originId ||
filters?.projectId,
);
}
export function deriveIssueUserContext(
issue: IssueUserContextInput,
userId: string,
stats:
| {
myLastCommentAt: Date | string | null;
myLastReadAt: Date | string | null;
lastExternalCommentAt: Date | string | null;
}
| null
| undefined,
) {
const normalizeDate = (value: Date | string | null | undefined) => {
if (!value) return null;
if (value instanceof Date)
return Number.isNaN(value.getTime()) ? null : value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
const myLastReadAt = normalizeDate(stats?.myLastReadAt);
const createdTouchAt =
issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
const assignedTouchAt =
issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
const myLastTouchAt =
[myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
const isUnreadForMe = Boolean(
myLastTouchAt &&
lastExternalCommentAt &&
lastExternalCommentAt.getTime() > myLastTouchAt.getTime(),
);
return {
myLastTouchAt,
lastExternalCommentAt,
isUnreadForMe,
};
}
function latestIssueActivityAt(
...values: Array<Date | string | null | undefined>
): Date | null {
const normalized = values
.map((value) => {
if (!value) return null;
if (value instanceof Date)
return Number.isNaN(value.getTime()) ? null : value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
})
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime());
return normalized[0] ?? null;
}
type InboxArchiveAttributionRow = {
issueId: string;
archivedAt: Date;
archivedByActorType: "user" | "agent";
archivedByAgentId: string | null;
archivedByRunId: string | null;
};
async function inboxArchiveRowsForIssues(
dbOrTx: Db,
companyId: string,
userId: string,
issueIds: string[],
): Promise<InboxArchiveAttributionRow[]> {
if (issueIds.length === 0) return [];
return dbOrTx
.select({
issueId: issueInboxArchives.issueId,
archivedAt: issueInboxArchives.archivedAt,
archivedByActorType: issueInboxArchives.archivedByActorType,
archivedByAgentId: issueInboxArchives.archivedByAgentId,
archivedByRunId: issueInboxArchives.archivedByRunId,
})
.from(issueInboxArchives)
.where(
and(
eq(issueInboxArchives.companyId, companyId),
eq(issueInboxArchives.userId, userId),
inArray(issueInboxArchives.issueId, issueIds),
),
);
}
function activeInboxArchiveFields(
archive: InboxArchiveAttributionRow | undefined,
lastActivityAt: Date,
) {
if (!archive || archive.archivedAt.getTime() < lastActivityAt.getTime())
return {};
return {
archivedAt: archive.archivedAt,
archivedByActorType: archive.archivedByActorType,
archivedByAgentId: archive.archivedByAgentId,
archivedByRunId: archive.archivedByRunId,
};
}
function issueListOrderBy(
companyId: string,
{
hasSearch,
priorityOrder,
searchOrder,
sortField,
sortDir,
}: {
hasSearch: boolean;
priorityOrder: SQL;
searchOrder: SQL;
sortField?: IssueFilters["sortField"];
sortDir?: IssueFilters["sortDir"];
},
) {
if (sortField === "id") return [sortDir === "desc" ? desc(issues.id) : asc(issues.id)];
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
if (sortField === "updated") {
const activityOrder =
sortDir === "asc"
? asc(canonicalLastActivityAt)
: desc(canonicalLastActivityAt);
const updatedOrder =
sortDir === "asc" ? asc(issues.updatedAt) : desc(issues.updatedAt);
const idOrder = sortDir === "asc" ? asc(issues.id) : desc(issues.id);
return hasSearch
? [asc(searchOrder), activityOrder, updatedOrder, idOrder]
: [activityOrder, updatedOrder, idOrder];
}
return [
hasSearch ? asc(searchOrder) : asc(priorityOrder),
asc(priorityOrder),
desc(canonicalLastActivityAt),
desc(issues.updatedAt),
desc(issues.id),
];
}
async function labelMapForIssues(
dbOrTx: any,
issueIds: string[],
): Promise<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
if (issueIds.length === 0) return map;
for (const issueIdChunk of chunkList(
issueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
issueId: issueLabels.issueId,
label: labels,
})
.from(issueLabels)
.innerJoin(labels, eq(issueLabels.labelId, labels.id))
.where(inArray(issueLabels.issueId, issueIdChunk))
.orderBy(asc(labels.name), asc(labels.id));
for (const row of rows) {
const existing = map.get(row.issueId);
if (existing) existing.push(row.label);
else map.set(row.issueId, [row.label]);
}
}
return map;
}
async function withIssueLabels(
dbOrTx: any,
rows: IssueRow[],
): Promise<IssueWithLabels[]> {
if (rows.length === 0) return [];
const issueIds = rows.map((row) => row.id);
const [labelsByIssueId, watchdogByIssueId] = await Promise.all([
labelMapForIssues(dbOrTx, issueIds),
watchdogMapForIssues(dbOrTx, rows),
]);
return rows.map((row) => {
const issueLabels = labelsByIssueId.get(row.id) ?? [];
return {
...row,
labels: issueLabels,
labelIds: issueLabels.map((label) => label.id),
watchdog: watchdogByIssueId.get(row.id) ?? null,
};
});
}
async function watchdogMapForIssues(
dbOrTx: any,
rows: IssueRow[],
): Promise<Map<string, IssueWatchdogSummary>> {
const map = new Map<string, IssueWatchdogSummary>();
if (rows.length === 0) return map;
const byCompany = new Map<string, string[]>();
for (const row of rows) {
const ids = byCompany.get(row.companyId) ?? [];
ids.push(row.id);
byCompany.set(row.companyId, ids);
}
for (const [companyId, issueIds] of byCompany.entries()) {
for (const issueIdChunk of chunkList(
[...new Set(issueIds)],
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const watchdogRows = await dbOrTx
.select()
.from(issueWatchdogs)
.where(
and(
eq(issueWatchdogs.companyId, companyId),
inArray(issueWatchdogs.issueId, issueIdChunk),
eq(issueWatchdogs.status, "active"),
),
);
for (const row of watchdogRows) {
map.set(row.issueId, summarizeIssueWatchdog(row));
}
}
}
return map;
}
const ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = [
"queued",
"deferred_issue_execution",
];
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = [
"pending",
"revision_requested",
];
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND =
"harness_liveness_escalation";
const BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES = ["done", "cancelled"];
function lowTrustBoundaryIssueCondition(
companyId: string,
boundary: (LowTrustBoundary & { companyId: string }) | null | undefined,
) {
if (!boundary || boundary.companyId !== companyId) return null;
const clauses: SQL[] = [];
const issueIds = [...new Set(boundary.issueIds ?? [])];
const projectIds = [...new Set(boundary.projectIds ?? [])];
if (issueIds.length > 0) clauses.push(inArray(issues.id, issueIds));
if (projectIds.length > 0)
clauses.push(inArray(issues.projectId, projectIds));
if (boundary.rootIssueId) {
clauses.push(sql<boolean>`
${issues.id} IN (
WITH RECURSIVE descendants(id) AS (
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.id} = ${boundary.rootIssueId}
UNION
SELECT ${issues.id}
FROM ${issues}
JOIN descendants ON ${issues.parentId} = descendants.id
WHERE ${issues.companyId} = ${companyId}
)
SELECT id FROM descendants
)
`);
}
if (clauses.length === 0) return sql<boolean>`false`;
return or(...clauses);
}
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
export const BLOCKER_ATTENTION_MAX_DEPTH = 8;
export const BLOCKER_ATTENTION_MAX_NODES = 2000;
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set([
"active",
"idle",
"running",
"error",
]);
type IssueBlockerAttentionNode = {
id: string;
companyId: string;
parentId: string | null;
identifier: string | null;
title: string;
status: string;
executionRunId?: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
type IssueBlockerAttentionInputNode = Pick<
IssueBlockerAttentionNode,
| "id"
| "companyId"
| "parentId"
| "identifier"
| "title"
| "status"
| "assigneeAgentId"
| "assigneeUserId"
> & { executionRunId?: string | null };
type IssueBlockerAttentionEdge = {
issueId: string;
blockerIssueId: string;
};
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
issueId: string | null;
blockerIssueId: string;
};
type IssueBlockerAttentionActivePathRow = {
issueId: string | null;
};
type IssueBlockerAttentionAgentRow = {
id: string;
companyId: string;
status: string;
};
async function activeRunMapForIssues(
dbOrTx: any,
issueRows: IssueWithLabels[],
): Promise<Map<string, IssueActiveRunRow>> {
const map = new Map<string, IssueActiveRunRow>();
const runIds = issueRows
.map((row) => row.executionRunId)
.filter((id): id is string => id != null);
if (runIds.length === 0) return map;
for (const runIdChunk of chunkList(
[...new Set(runIds)],
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
id: heartbeatRuns.id,
status: heartbeatRuns.status,
agentId: heartbeatRuns.agentId,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(
and(
inArray(heartbeatRuns.id, runIdChunk),
inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES),
),
);
for (const row of rows) {
map.set(row.id, row);
}
}
for (const companyId of new Set(issueRows.map(row => row.companyId))) {
const scopedIds = issueRows.filter(row => row.companyId === companyId).flatMap(row => row.executionRunId && map.has(row.executionRunId) ? [row.executionRunId] : []);
const projections = await executionProjectionsForRuns(dbOrTx, companyId, scopedIds);
for (const [runId, execution] of projections) {
const row = map.get(runId);
if (row) row.execution = execution;
}
}
return map;
}
async function liveDescendantCountMapForIssues(
dbOrTx: any,
companyId: string,
issueIds: string[],
): Promise<Map<string, number>> {
const uniqueIssueIds = [...new Set(issueIds)];
const map = new Map<string, number>();
if (uniqueIssueIds.length === 0) return map;
for (const issueIdChunk of chunkList(
uniqueIssueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const targetRows = issueIdChunk.map((issueId) => sql`(${issueId}::uuid)`);
const rows = await dbOrTx.execute(sql<{
issueId: string;
liveDescendantCount: number;
}>`
WITH RECURSIVE
target_issues(issue_id) AS (
VALUES ${sql.join(targetRows, sql`, `)}
),
live_issues(live_issue_id, parent_id) AS (
SELECT DISTINCT live_issue.id, live_issue.parent_id
FROM issues live_issue
JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id
WHERE live_issue.company_id = ${companyId}
AND live_issue.hidden_at IS NULL
AND live_issue.harness_kind IS NULL
AND live_run.company_id = ${companyId}
AND live_run.status IN ('queued', 'running')
UNION
SELECT DISTINCT live_issue.id, live_issue.parent_id
FROM heartbeat_runs live_run
JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId')
WHERE live_issue.company_id = ${companyId}
AND live_issue.hidden_at IS NULL
AND live_issue.harness_kind IS NULL
AND live_run.company_id = ${companyId}
AND live_run.status IN ('queued', 'running')
),
live_ancestors(live_issue_id, ancestor_id, next_parent_id, visited_issue_ids) AS (
SELECT live_issues.live_issue_id, parent.id, parent.parent_id, ARRAY[live_issues.live_issue_id, parent.id]
FROM live_issues
JOIN issues parent ON parent.id = live_issues.parent_id
WHERE parent.company_id = ${companyId}
AND parent.hidden_at IS NULL
AND parent.harness_kind IS NULL
UNION ALL
SELECT
live_ancestors.live_issue_id,
parent.id,
parent.parent_id,
live_ancestors.visited_issue_ids || parent.id
FROM live_ancestors
JOIN issues parent ON parent.id = live_ancestors.next_parent_id
WHERE parent.company_id = ${companyId}
AND parent.hidden_at IS NULL
AND parent.harness_kind IS NULL
AND NOT parent.id = ANY(live_ancestors.visited_issue_ids)
)
SELECT
live_ancestors.ancestor_id::text AS "issueId",
count(DISTINCT live_ancestors.live_issue_id)::int AS "liveDescendantCount"
FROM live_ancestors
JOIN target_issues ON target_issues.issue_id = live_ancestors.ancestor_id
WHERE live_ancestors.ancestor_id <> live_ancestors.live_issue_id
GROUP BY live_ancestors.ancestor_id
`);
const resultRows = Array.isArray(rows)
? rows
: Array.from(rows as Iterable<unknown>);
for (const row of resultRows) {
if (typeof row !== "object" || row === null) continue;
const issueId = (row as { issueId?: unknown }).issueId;
const liveDescendantCount = (row as { liveDescendantCount?: unknown })
.liveDescendantCount;
if (typeof issueId !== "string") continue;
const count =
typeof liveDescendantCount === "number"
? liveDescendantCount
: Number(liveDescendantCount);
if (Number.isFinite(count)) map.set(issueId, count);
}
}
return map;
}
function createIssueBlockerAttention(
input: Partial<IssueBlockerAttention> = {},
): IssueBlockerAttention {
return {
state: input.state ?? "none",
reason: input.reason ?? null,
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
coveredBlockerCount: input.coveredBlockerCount ?? 0,
stalledBlockerCount: input.stalledBlockerCount ?? 0,
attentionBlockerCount: input.attentionBlockerCount ?? 0,
pendingFinalizeBlockerIssueIds: input.pendingFinalizeBlockerIssueIds ?? [],
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier:
input.sampleStalledBlockerIdentifier ?? null,
blockingTreeLive: input.blockingTreeLive ?? false,
directBlockerIssueId: input.directBlockerIssueId ?? null,
terminalBlockerIssueId: input.terminalBlockerIssueId ?? null,
terminalBlocker: input.terminalBlocker ?? null,
};
}
function blockerSampleIdentifier(
node: IssueBlockerAttentionNode | null | undefined,
) {
return node?.identifier ?? node?.id ?? null;
}
function appendBlockerAttentionEdges(
edgesByIssueId: Map<string, IssueBlockerAttentionEdge[]>,
rows: IssueBlockerAttentionEdge[],
) {
for (const row of rows) {
const existing = edgesByIssueId.get(row.issueId) ?? [];
if (!existing.some((edge) => edge.blockerIssueId === row.blockerIssueId)) {
existing.push(row);
edgesByIssueId.set(row.issueId, existing);
}
}
}
type IssueRelationSummaryRow = {
relatedId: string;
identifier: string | null;
title: string;
status: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
function summarizeIssueRelationRow(
row: IssueRelationSummaryRow,
): IssueRelationIssueSummary {
return {
id: row.relatedId,
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
};
}
async function terminalExplicitBlockersByRoot(
companyId: string,
roots: IssueRelationIssueSummary[],
dbOrTx: DbReader,
): Promise<Map<string, IssueRelationIssueSummary[]>> {
const rootIds = [...new Set(roots.map((root) => root.id))];
const terminalByRoot = new Map<string, IssueRelationIssueSummary[]>();
if (rootIds.length === 0) return terminalByRoot;
const nodesById = new Map<string, IssueRelationIssueSummary>();
const edgesByIssueId = new Map<string, string[]>();
for (const root of roots) nodesById.set(root.id, root);
let frontier = rootIds;
for (
let depth = 0;
frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH;
depth += 1
) {
const nextFrontier = new Set<string>();
for (const chunk of chunkList(
[...new Set(frontier)],
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
currentIssueId: issueRelations.relatedIssueId,
relatedId: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, chunk),
eq(issues.companyId, companyId),
ne(issues.status, "done"),
),
);
for (const row of rows) {
const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? [];
if (!existingEdges.includes(row.relatedId)) {
existingEdges.push(row.relatedId);
edgesByIssueId.set(row.currentIssueId, existingEdges);
}
if (!nodesById.has(row.relatedId)) {
nodesById.set(row.relatedId, summarizeIssueRelationRow(row));
nextFrontier.add(row.relatedId);
}
}
}
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break;
frontier = [...nextFrontier];
}
const collectTerminal = (
issueId: string,
seen: Set<string>,
): IssueRelationIssueSummary[] => {
if (seen.has(issueId)) return [];
const node = nodesById.get(issueId);
if (!node || node.status === "done") return [];
const nextSeen = new Set(seen);
nextSeen.add(issueId);
const downstreamIds = edgesByIssueId.get(issueId) ?? [];
if (downstreamIds.length === 0) return [node];
return downstreamIds.flatMap((downstreamId) =>
collectTerminal(downstreamId, nextSeen),
);
};
for (const rootId of rootIds) {
const deduped = new Map<string, IssueRelationIssueSummary>();
for (const blocker of collectTerminal(rootId, new Set())) {
if (blocker.id !== rootId) deduped.set(blocker.id, blocker);
}
if (deduped.size > 0) {
terminalByRoot.set(
rootId,
[...deduped.values()].sort((a, b) => a.title.localeCompare(b.title)),
);
}
}
return terminalByRoot;
}
async function listIssueBlockerAttentionMap(
dbOrTx: any,
companyId: string,
issueRows: IssueBlockerAttentionInputNode[],
): Promise<Map<string, IssueBlockerAttention>> {
const roots = issueRows.filter(
(row) => row.companyId === companyId && row.status === "blocked",
);
const attentionMap = new Map<string, IssueBlockerAttention>();
for (const row of issueRows) {
if (row.status !== "blocked") {
attentionMap.set(row.id, createIssueBlockerAttention());
}
}
if (roots.length === 0) return attentionMap;
const nodesById = new Map<string, IssueBlockerAttentionNode>();
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
for (const root of roots) nodesById.set(root.id, { ...root });
let frontier = roots.map((root) => root.id);
let truncated = false;
const pendingFinalizeBlockerIssueIds = new Set<string>();
for (
let depth = 0;
frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH;
depth += 1
) {
const nextFrontier = new Set<string>();
for (const chunk of chunkList(
[...new Set(frontier)],
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const readinessByIssueId = await listIssueDependencyReadinessMap(
dbOrTx,
companyId,
chunk,
);
for (const readiness of readinessByIssueId.values()) {
for (const blockerIssueId of readiness.pendingFinalizeBlockerIssueIds) {
pendingFinalizeBlockerIssueIds.add(blockerIssueId);
}
}
const explicitBlockerRowsPromise: Promise<
IssueBlockerAttentionQueryRow[]
> = dbOrTx
.select({
issueId: issueRelations.relatedIssueId,
blockerIssueId: issues.id,
id: issues.id,
companyId: issues.companyId,
parentId: issues.parentId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
executionRunId: issues.executionRunId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, chunk),
eq(issues.companyId, companyId),
),
);
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
.select({
issueId: issues.parentId,
blockerIssueId: issues.id,
id: issues.id,
companyId: issues.companyId,
parentId: issues.parentId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
executionRunId: issues.executionRunId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.parentId, chunk),
notInArray(
issues.status,
BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES,
),
),
);
const [explicitBlockerRows, childRows] = await Promise.all([
explicitBlockerRowsPromise,
childRowsPromise,
]);
const unresolvedExplicitBlockerRows = explicitBlockerRows.filter(
(row) =>
row.status !== "done" ||
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId),
);
appendBlockerAttentionEdges(edgesByIssueId, [
...unresolvedExplicitBlockerRows
.filter(
(row): row is IssueBlockerAttentionQueryRow & { issueId: string } =>
row.issueId !== null,
)
.map((row) => ({
issueId: row.issueId,
blockerIssueId: row.blockerIssueId,
})),
...childRows
.filter(
(row): row is IssueBlockerAttentionQueryRow & { issueId: string } =>
row.issueId !== null,
)
.map((row) => ({
issueId: row.issueId,
blockerIssueId: row.blockerIssueId,
})),
]);
for (const row of [...unresolvedExplicitBlockerRows, ...childRows]) {
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
nodesById.set(row.blockerIssueId, {
id: row.blockerIssueId,
companyId: row.companyId,
parentId: row.parentId,
identifier: row.identifier,
title: row.title,
status: row.status,
executionRunId: row.executionRunId,
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
});
nextFrontier.add(row.blockerIssueId);
}
}
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) {
truncated = true;
break;
}
frontier = [...nextFrontier];
}
if (frontier.length > 0) truncated = true;
const nodeIds = [...nodesById.keys()];
const activeIssueIds = new Set<string>();
const agentIds = new Set<string>();
const issueIdByExecutionRunId = new Map<string, string>();
for (const node of nodesById.values()) {
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
if (node.executionRunId)
issueIdByExecutionRunId.set(node.executionRunId, node.id);
}
for (const chunk of chunkList(
[...issueIdByExecutionRunId.keys()],
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const runRows: Array<{ id: string }> = await dbOrTx
.select({
id: heartbeatRuns.id,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES),
inArray(heartbeatRuns.id, chunk),
),
);
for (const row of runRows) {
const issueId = issueIdByExecutionRunId.get(row.id);
if (issueId) activeIssueIds.add(issueId);
}
}
for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const wakeRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> =
dbOrTx
.select({
issueId: sql<
string | null
>`${agentWakeupRequests.payload} ->> 'issueId'`,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
inArray(
agentWakeupRequests.status,
BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES,
),
sql`${agentWakeupRequests.runId} is null`,
inArray(
sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`,
chunk,
),
),
);
const wakeRows = await wakeRowsPromise;
for (const row of wakeRows) {
if (row.issueId) activeIssueIds.add(row.issueId);
}
}
const explicitWaitCandidateIds = [...nodesById.values()]
.filter((node) => node.status !== "done")
.map((node) => node.id);
const explicitWaitingIssueIds = new Set<string>();
if (explicitWaitCandidateIds.length > 0) {
for (const chunk of chunkList(
explicitWaitCandidateIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const interactionRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
inArray(
issueThreadInteractions.status,
BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES,
),
inArray(issueThreadInteractions.issueId, chunk),
),
);
for (const row of interactionRows)
explicitWaitingIssueIds.add(row.issueId);
const approvalRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueApprovals.issueId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, companyId),
inArray(
approvals.status,
BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES,
),
inArray(issueApprovals.issueId, chunk),
),
);
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
}
// Recovery rows are intentionally company-wide: a liveness escalation for
// the same leaf blocker represents an active waiting path even when that
// blocker is reached through another blocked graph.
const recoveryRows: Array<{ id: string; originId: string | null }> =
await dbOrTx
.select({ id: issues.id, originId: issues.originId })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
visibleIssueCondition(),
notInArray(
issues.status,
BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES,
),
),
);
for (const row of recoveryRows) {
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
if (!parsed || parsed.companyId !== companyId) continue;
explicitWaitingIssueIds.add(row.id);
explicitWaitingIssueIds.add(parsed.issueId);
explicitWaitingIssueIds.add(parsed.leafIssueId);
}
const recoveryActionRows: Array<{
id: string;
sourceIssueId: string;
status: string;
ownerType: string;
ownerAgentId: string | null;
ownerUserId: string | null;
}> = await dbOrTx
.select({
id: issueRecoveryActions.id,
sourceIssueId: issueRecoveryActions.sourceIssueId,
status: issueRecoveryActions.status,
ownerType: issueRecoveryActions.ownerType,
ownerAgentId: issueRecoveryActions.ownerAgentId,
ownerUserId: issueRecoveryActions.ownerUserId,
})
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(issueRecoveryActions.sourceIssueId, explicitWaitCandidateIds),
),
);
const recoveryActionIds = recoveryActionRows.map((row) => row.id);
const liveRecoveryActionIds = new Set<string>();
for (const chunk of chunkList(
recoveryActionIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const [runRows, wakeRows] = await Promise.all([
dbOrTx
.select({
recoveryActionId: sql<
string | null
>`${heartbeatRuns.contextSnapshot} ->> 'recoveryActionId'`,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(
heartbeatRuns.status,
BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES,
),
inArray(
sql<string>`${heartbeatRuns.contextSnapshot} ->> 'recoveryActionId'`,
chunk,
),
),
),
dbOrTx
.select({
recoveryActionId: sql<
string | null
>`${agentWakeupRequests.payload} ->> 'recoveryActionId'`,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
inArray(
agentWakeupRequests.status,
BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES,
),
inArray(
sql<string>`${agentWakeupRequests.payload} ->> 'recoveryActionId'`,
chunk,
),
),
),
]);
for (const row of [...runRows, ...wakeRows]) {
if (row.recoveryActionId)
liveRecoveryActionIds.add(row.recoveryActionId);
}
}
for (const row of recoveryActionRows) {
const healthy =
(row.status === "escalated" && row.ownerType === "board") ||
Boolean(row.ownerUserId) ||
(Boolean(row.ownerAgentId) && liveRecoveryActionIds.has(row.id));
if (healthy) explicitWaitingIssueIds.add(row.sourceIssueId);
}
}
const agentRows: IssueBlockerAttentionAgentRow[] =
agentIds.size > 0
? await dbOrTx
.select({
id: agents.id,
companyId: agents.companyId,
status: agents.status,
})
.from(agents)
.where(
and(
eq(agents.companyId, companyId),
inArray(agents.id, [...agentIds]),
),
)
: [];
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
type PathClassification = {
covered: boolean;
stalled: boolean;
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
terminalBlockerIssueId?: string | null;
};
const classifyPath = (
nodeId: string,
seen: Set<string>,
): PathClassification => {
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
if (truncated || seen.has(nodeId)) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: sample,
sampleStalledBlockerIdentifier: null,
};
}
const node = nodesById.get(nodeId);
if (!node || node.companyId !== companyId) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: nodeId,
sampleStalledBlockerIdentifier: null,
};
}
const nodeSample = blockerSampleIdentifier(node);
if (
node.status === "done" &&
!pendingFinalizeBlockerIssueIds.has(node.id)
) {
return {
covered: true,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (explicitWaitingIssueIds.has(node.id)) {
return {
covered: true,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (node.assigneeUserId && node.status !== "cancelled") {
return {
covered: true,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (node.status === "in_review") {
const hasWaitingPath =
activeIssueIds.has(node.id) || Boolean(node.assigneeUserId);
if (hasWaitingPath) {
return {
covered: true,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
return {
covered: false,
stalled: true,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: nodeSample,
terminalBlockerIssueId: node.id,
};
}
if (activeIssueIds.has(node.id)) {
return {
covered: true,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (node.status === "cancelled") {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
terminalBlockerIssueId: node.id,
};
}
if (node.status === "backlog" && node.assigneeAgentId) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
terminalBlockerIssueId: node.id,
};
}
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return (
blocker?.status !== "done" ||
pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
);
});
if (downstream.length > 0) {
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
const classified = downstream.map((edge) =>
classifyPath(edge.blockerIssueId, nextSeen),
);
const stalledChild = classified.find(
(result) => result.stalled || result.sampleStalledBlockerIdentifier,
);
const sampleStalled =
stalledChild?.sampleStalledBlockerIdentifier ?? null;
const hardAttention =
classified.find(
(result) =>
!result.covered && !result.stalled && result.terminalBlockerIssueId,
) ?? classified.find((result) => !result.covered && !result.stalled);
if (hardAttention) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
terminalBlockerIssueId: hardAttention.terminalBlockerIssueId ?? null,
};
}
const stalledEntry = classified.find((result) => result.stalled);
if (stalledEntry) {
return {
covered: false,
stalled: true,
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
terminalBlockerIssueId: stalledEntry.terminalBlockerIssueId ?? null,
};
}
return {
covered: true,
stalled: false,
sampleBlockerIdentifier:
classified[0]?.sampleBlockerIdentifier ?? nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (node.assigneeAgentId) {
const assignee = agentsById.get(node.assigneeAgentId);
if (
!assignee ||
assignee.companyId !== companyId ||
!BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)
) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
terminalBlockerIssueId: node.id,
};
}
}
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: nodeSample,
sampleStalledBlockerIdentifier: null,
terminalBlockerIssueId: node.id,
};
};
const pathHasLiveWork = (nodeId: string, seen: Set<string>): boolean => {
if (seen.has(nodeId)) return false;
const node = nodesById.get(nodeId);
if (!node || node.companyId !== companyId) return false;
if (
node.status === "in_progress" ||
activeIssueIds.has(node.id) ||
explicitWaitingIssueIds.has(node.id) ||
Boolean(node.assigneeUserId)
)
return true;
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
return (edgesByIssueId.get(node.id) ?? []).some((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
if (
blocker?.status === "done" &&
!pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
)
return false;
return pathHasLiveWork(edge.blockerIssueId, nextSeen);
});
};
const issueIdForSample = (sample: string | null | undefined) => {
if (!sample) return null;
for (const node of nodesById.values()) {
if (node.id === sample || node.identifier === sample) return node.id;
}
return null;
};
for (const root of roots) {
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return (
blocker?.status !== "done" ||
pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
);
});
if (topLevelEdges.length === 0) {
attentionMap.set(
root.id,
createIssueBlockerAttention({
state: "needs_attention",
reason: "attention_required",
terminalBlockerIssueId: root.id,
}),
);
continue;
}
const classified = topLevelEdges.map((edge) => ({
edge,
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
}));
const coveredBlockerCount = classified.filter(
(entry) => entry.result.covered,
).length;
const stalledBlockerCount = classified.filter(
(entry) => entry.result.stalled,
).length;
const attentionBlockerCount =
classified.length - coveredBlockerCount - stalledBlockerCount;
const hardAttentionEntry =
classified.find(
(entry) =>
!entry.result.covered &&
!entry.result.stalled &&
entry.result.terminalBlockerIssueId,
) ??
classified.find(
(entry) => !entry.result.covered && !entry.result.stalled,
);
const stalledEntry =
classified.find(
(entry) => entry.result.stalled && entry.result.terminalBlockerIssueId,
) ?? classified.find((entry) => entry.result.stalled);
const sampleEntry =
hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null;
const sampleNode = sampleEntry
? nodesById.get(sampleEntry.edge.blockerIssueId)
: null;
const sampleStalledFromChain = classified
.map((entry) => entry.result.sampleStalledBlockerIdentifier)
.find((value) => value);
const sampledTerminalIdentifier = sampleEntry?.result.stalled
? (sampleEntry.result.sampleStalledBlockerIdentifier ??
sampleEntry.result.sampleBlockerIdentifier)
: (sampleEntry?.result.sampleBlockerIdentifier ??
blockerSampleIdentifier(sampleNode));
const terminalBlockerIssueId =
sampleEntry?.result.terminalBlockerIssueId ??
issueIdForSample(sampledTerminalIdentifier);
const terminalBlockerNode = terminalBlockerIssueId
? (nodesById.get(terminalBlockerIssueId) ?? null)
: null;
let state: IssueBlockerAttention["state"];
let reason: IssueBlockerAttention["reason"];
if (attentionBlockerCount > 0) {
state = "needs_attention";
reason = "attention_required";
} else if (stalledBlockerCount > 0) {
state = "stalled";
reason = "stalled_review";
} else {
state = "covered";
reason = topLevelEdges.every(
(edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id,
)
? "active_child"
: "active_dependency";
}
attentionMap.set(
root.id,
createIssueBlockerAttention({
state,
reason,
unresolvedBlockerCount: topLevelEdges.length,
coveredBlockerCount,
stalledBlockerCount,
attentionBlockerCount,
pendingFinalizeBlockerIssueIds: topLevelEdges
.map((edge) => edge.blockerIssueId)
.filter((blockerIssueId) =>
pendingFinalizeBlockerIssueIds.has(blockerIssueId),
),
sampleBlockerIdentifier:
sampleEntry?.result.sampleBlockerIdentifier ??
blockerSampleIdentifier(sampleNode),
sampleStalledBlockerIdentifier:
stalledEntry?.result.sampleStalledBlockerIdentifier ??
sampleStalledFromChain ??
null,
blockingTreeLive: topLevelEdges.some((edge) =>
pathHasLiveWork(edge.blockerIssueId, new Set([root.id])),
),
directBlockerIssueId: sampleEntry?.edge.blockerIssueId ?? null,
terminalBlockerIssueId,
terminalBlocker: terminalBlockerNode
? {
id: terminalBlockerNode.id,
identifier: terminalBlockerNode.identifier,
title: terminalBlockerNode.title,
}
: null,
}),
);
}
return attentionMap;
}
type IssueReviewAttentionInput = Pick<IssueRow, "id" | "companyId" | "status">;
function reviewPathLabel(
kind: IssueReviewAttentionPath["kind"],
detail?: string | null,
) {
switch (kind) {
case "execution_participant":
return "Execution review participant";
case "interaction":
return detail
? `Pending ${detail.replaceAll("_", " ")}`
: "Pending issue interaction";
case "approval":
return "Linked approval";
case "monitor":
return "Scheduled review monitor";
case "human_reviewer":
return "Human reviewer";
case "active_run":
return "Active review run";
case "queued_wake":
return detail
? `Queued ${detail.replaceAll("_", " ")} wake`
: "Queued review wake";
case "recovery":
return "Open review recovery";
}
}
function reviewAttentionNone(): IssueReviewAttention {
return { state: "none", paths: [], reason: null };
}
async function listIssueReviewAttentionMap(
dbOrTx: any,
companyId: string,
issueRows: IssueReviewAttentionInput[],
): Promise<Map<string, IssueReviewAttention>> {
const result = new Map<string, IssueReviewAttention>();
for (const row of issueRows) result.set(row.id, reviewAttentionNone());
const reviewIds = issueRows
.filter((row) => row.companyId === companyId && row.status === "in_review")
.map((row) => row.id);
if (reviewIds.length === 0) return result;
const reviewIssues: IssueRow[] = [];
for (const chunk of chunkList(
reviewIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
reviewIssues.push(
...(await dbOrTx
.select()
.from(issues)
.where(
and(eq(issues.companyId, companyId), inArray(issues.id, chunk), nonIdleSlackIssueCondition()),
)),
);
}
if (reviewIssues.length === 0) return result;
const [
agentRows,
activeRunRows,
wakeRows,
interactionRows,
approvalRows,
recoveryActionRows,
recoveryIssueRows,
] = await Promise.all([
dbOrTx
.select({
id: agents.id,
companyId: agents.companyId,
name: agents.name,
role: agents.role,
title: agents.title,
status: agents.status,
reportsTo: agents.reportsTo,
})
.from(agents)
.where(eq(agents.companyId, companyId)),
dbOrTx
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
issueId: sql<
string | null
>`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, ["queued", "running"]),
inArray(
sql<string>`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`,
reviewIds,
),
),
),
dbOrTx
.select({
id: agentWakeupRequests.id,
companyId: agentWakeupRequests.companyId,
issueId: sql<string | null>`coalesce(
${agentWakeupRequests.payload} ->> 'issueId',
${agentWakeupRequests.payload} ->> 'taskId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
)`,
agentId: agentWakeupRequests.agentId,
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
createdAt: agentWakeupRequests.requestedAt,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
inArray(agentWakeupRequests.status, [
"queued",
"deferred_issue_execution",
"claimed",
]),
inArray(
sql<string>`coalesce(
${agentWakeupRequests.payload} ->> 'issueId',
${agentWakeupRequests.payload} ->> 'taskId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
)`,
reviewIds,
),
),
),
dbOrTx
.select({
id: issueThreadInteractions.id,
companyId: issueThreadInteractions.companyId,
issueId: issueThreadInteractions.issueId,
status: issueThreadInteractions.status,
kind: issueThreadInteractions.kind,
createdByAgentId: issueThreadInteractions.createdByAgentId,
sourceRunId: issueThreadInteractions.sourceRunId,
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
effectiveResolverPolicy:
issueThreadInteractions.effectiveResolverPolicy,
resolverPolicyProvenance:
issueThreadInteractions.resolverPolicyProvenance,
createdAt: issueThreadInteractions.createdAt,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
eq(issueThreadInteractions.status, "pending"),
inArray(issueThreadInteractions.issueId, reviewIds),
),
),
dbOrTx
.select({
id: approvals.id,
companyId: issueApprovals.companyId,
issueId: issueApprovals.issueId,
status: approvals.status,
createdAt: approvals.createdAt,
})
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, companyId),
eq(approvals.companyId, companyId),
inArray(approvals.status, ["pending", "revision_requested"]),
inArray(issueApprovals.issueId, reviewIds),
),
),
dbOrTx
.select({
id: issueRecoveryActions.id,
companyId: issueRecoveryActions.companyId,
issueId: issueRecoveryActions.sourceIssueId,
status: issueRecoveryActions.status,
createdAt: issueRecoveryActions.createdAt,
})
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(issueRecoveryActions.sourceIssueId, reviewIds),
),
),
dbOrTx
.select({
id: issues.id,
companyId: issues.companyId,
originKind: issues.originKind,
originId: issues.originId,
status: issues.status,
createdAt: issues.createdAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.originKind, [
RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
]),
visibleIssueCondition(),
notInArray(issues.status, ["done", "cancelled"]),
),
),
]);
const recoveryPaths = [
...(recoveryActionRows as Array<{
id: string;
companyId: string;
issueId: string;
status: string;
createdAt: Date;
}>),
];
for (const recovery of recoveryIssueRows as Array<{
id: string;
companyId: string;
originKind: string;
originId: string | null;
status: string;
createdAt: Date;
}>) {
if (
recovery.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery &&
recovery.originId &&
reviewIds.includes(recovery.originId)
) {
recoveryPaths.push({ ...recovery, issueId: recovery.originId });
continue;
}
const parsed = parseIssueGraphLivenessIncidentKey(recovery.originId);
if (parsed?.companyId === companyId && reviewIds.includes(parsed.issueId)) {
recoveryPaths.push({ ...recovery, issueId: parsed.issueId });
}
}
const livenessInput: IssueGraphLivenessInput = {
issues: reviewIssues.map((issue) => ({
id: issue.id,
companyId: issue.companyId,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
projectId: issue.projectId,
goalId: issue.goalId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
createdByAgentId: issue.createdByAgentId,
createdByUserId: issue.createdByUserId,
conversationAgentId: issue.conversationAgentId,
conversationUserId: issue.conversationUserId,
conversationState: issue.conversationState,
executionPolicy: issue.executionPolicy,
executionState: issue.executionState,
monitorNextCheckAt: issue.monitorNextCheckAt,
monitorAttemptCount: issue.monitorAttemptCount,
})),
relations: [],
agents: agentRows,
activeRuns: activeRunRows,
queuedWakeRequests: wakeRows,
pendingInteractions: interactionRows,
pendingApprovals: approvalRows,
openRecoveryIssues: recoveryPaths,
now: new Date(),
};
const findingsByIssueId = new Map(
classifyIssueGraphLiveness(livenessInput).map((finding) => [
finding.issueId,
finding,
]),
);
const agentNameById = new Map(
(agentRows as Array<{ id: string; name: string }>).map((agent) => [
agent.id,
agent.name,
]),
);
const userIds = new Set<string>();
for (const issue of reviewIssues) {
if (issue.assigneeUserId) userIds.add(issue.assigneeUserId);
const participant = parseObject(issue.executionState).currentParticipant;
if (
participant &&
typeof participant === "object" &&
!Array.isArray(participant)
) {
const userId = (participant as Record<string, unknown>).userId;
if (typeof userId === "string") userIds.add(userId);
}
}
const userRows =
userIds.size > 0
? await dbOrTx
.select({ id: authUsers.id, name: authUsers.name })
.from(authUsers)
.where(inArray(authUsers.id, [...userIds]))
: [];
const userNameById = new Map(
(userRows as Array<{ id: string; name: string }>).map((user) => [
user.id,
user.name,
]),
);
const interactionKindById = new Map(
(interactionRows as Array<{ id: string; kind: string }>).map((row) => [
row.id,
row.kind,
]),
);
const interactionAudienceById = new Map(
(
interactionRows as Array<{
id: string;
createdByAgentId: string | null;
sourceRunId: string | null;
addresseeAgentId: string | null;
effectiveResolverPolicy: string;
resolverPolicyProvenance: string | null;
}>
).map((row) => [row.id, row]),
);
const wakeReasonById = new Map(
(wakeRows as Array<{ id: string; reason: string | null }>).map((row) => [
row.id,
row.reason,
]),
);
for (const issue of reviewIssues) {
const pathFacts = classifyIssueReviewPaths(
livenessInput,
livenessInput.issues.find((entry) => entry.id === issue.id)!,
);
const paths: IssueReviewAttentionPath[] = pathFacts.map((path) => {
const interactionAudience =
path.kind === "interaction" && path.ref
? (interactionAudienceById.get(path.ref) ?? null)
: null;
const candidateAgentId =
interactionAudience?.addresseeAgentId ?? issue.assigneeAgentId;
const interactionResponderAgentId =
interactionAudience &&
candidateAgentId &&
issueThreadInteractionAttentionAgentAllowed({
agentId: candidateAgentId,
interaction: interactionAudience,
})
? candidateAgentId
: null;
return {
kind: path.kind,
label: reviewPathLabel(
path.kind,
path.kind === "interaction" && path.ref
? (interactionKindById.get(path.ref) ?? null)
: path.kind === "queued_wake" && path.ref
? (wakeReasonById.get(path.ref) ?? null)
: null,
),
responder: path.agentId
? (agentNameById.get(path.agentId) ?? path.agentId)
: path.userId
? (userNameById.get(path.userId) ?? path.userId)
: path.kind === "interaction" && interactionResponderAgentId
? (agentNameById.get(interactionResponderAgentId) ??
interactionResponderAgentId)
: path.kind === "interaction" || path.kind === "approval"
? "Board"
: null,
since: path.since
? (path.since instanceof Date
? path.since
: new Date(path.since)
).toISOString()
: issue.updatedAt.toISOString(),
ref: path.ref,
};
});
if (paths.length > 0) {
result.set(issue.id, {
state: "covered",
paths,
reason:
paths.length === 1
? "Review has a maintained action path."
: `Review has ${paths.length} maintained action paths.`,
});
continue;
}
const finding = findingsByIssueId.get(issue.id);
result.set(issue.id, {
state: "stalled",
paths: [],
reason:
finding?.reason ??
"Issue is in review without a maintained action path.",
});
}
return result;
}
const issueListSelect = {
externalConversationState: externalConversationStateSql(),
conversationAgentId: issues.conversationAgentId,
conversationUserId: issues.conversationUserId,
conversationState: issues.conversationState,
conversationSessionGeneration: issues.conversationSessionGeneration,
conversationBoundaryCommentId: issues.conversationBoundaryCommentId,
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
goalId: issues.goalId,
parentId: issues.parentId,
title: issues.title,
titleNeedsGeneration: issues.titleNeedsGeneration,
description: sql<string | null>`
CASE
WHEN ${issues.description} IS NULL THEN NULL
ELSE encode(
substring(
convert_to(${issues.description}, current_setting('server_encoding'))
FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_BYTES}
),
'base64'
)
END
`,
descriptionTruncated: sql<boolean>`
CASE
WHEN ${issues.description} IS NULL THEN false
ELSE length(${issues.description}) > ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}
END
`,
status: issues.status,
statusVersion: issues.statusVersion,
lastStatusDecisionId: issues.lastStatusDecisionId,
workMode: issues.workMode,
harnessKind: issues.harnessKind,
priority: issues.priority,
reviewPolicy: issues.reviewPolicy,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
responsibleUserId: issues.responsibleUserId,
issueNumber: issues.issueNumber,
identifier: issues.identifier,
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
originIdentityContextId: issues.originIdentityContextId,
continuationIdentityContextId: issues.continuationIdentityContextId,
originFingerprint: issues.originFingerprint,
requestDepth: issues.requestDepth,
billingCode: issues.billingCode,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: sql<null>`null`,
executionState: sql<null>`null`,
monitorNextCheckAt: issues.monitorNextCheckAt,
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
monitorAttemptCount: issues.monitorAttemptCount,
monitorNotes: issues.monitorNotes,
monitorScheduledBy: issues.monitorScheduledBy,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: sql<null>`null`,
sourceTrust: issues.sourceTrust,
unblockDescriptor: issues.unblockDescriptor,
blockedTransitionAt: issues.blockedTransitionAt,
blockedOwnerNotifiedAt: issues.blockedOwnerNotifiedAt,
startedAt: issues.startedAt,
completedAt: issues.completedAt,
cancelledAt: issues.cancelledAt,
hiddenAt: issues.hiddenAt,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
};
function withActiveRuns(
issueRows: IssueWithLabels[],
runMap: Map<string, IssueActiveRunRow>,
): IssueWithLabelsAndRun[] {
return issueRows.map((row) => ({
...row,
activeRun: row.executionRunId
? (runMap.get(row.executionRunId) ?? null)
: null,
}));
}
async function userCommentStatsForIssues(
dbOrTx: any,
companyId: string,
userId: string,
issueIds: string[],
): Promise<IssueUserCommentStats[]> {
const stats: IssueUserCommentStats[] = [];
for (const issueIdChunk of chunkList(
issueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
issueId: issueComments.issueId,
myLastCommentAt: sql<Date | null>`
MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END)
`,
lastExternalCommentAt: sql<Date | null>`
MAX(
CASE
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId}
THEN ${issueComments.createdAt}
END
)
`,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIdChunk),
),
)
.groupBy(issueComments.issueId);
stats.push(...rows);
}
return stats;
}
async function userReadStatsForIssues(
dbOrTx: any,
companyId: string,
userId: string,
issueIds: string[],
): Promise<IssueReadStat[]> {
const stats: IssueReadStat[] = [];
for (const issueIdChunk of chunkList(
issueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
issueId: issueReadStates.issueId,
myLastReadAt: issueReadStates.lastReadAt,
})
.from(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.userId, userId),
inArray(issueReadStates.issueId, issueIdChunk),
),
);
stats.push(...rows);
}
return stats;
}
async function lastActivityStatsForIssues(
dbOrTx: any,
companyId: string,
issueIds: string[],
): Promise<IssueLastActivityStat[]> {
const byIssueId = new Map<string, IssueLastActivityStat>();
for (const issueIdChunk of chunkList(
issueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const [commentRows, logRows] = await Promise.all([
dbOrTx
.select({
issueId: issueComments.issueId,
latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIdChunk),
),
)
.groupBy(issueComments.issueId),
dbOrTx
.select({
issueId: activityLog.entityId,
latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIdChunk),
sql`${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
),
sql`, `,
)})`,
),
)
.groupBy(activityLog.entityId),
]);
for (const row of commentRows) {
byIssueId.set(row.issueId, {
issueId: row.issueId,
latestCommentAt: row.latestCommentAt,
latestLogAt: null,
});
}
for (const row of logRows) {
const existing = byIssueId.get(row.issueId);
if (existing) existing.latestLogAt = row.latestLogAt;
else {
byIssueId.set(row.issueId, {
issueId: row.issueId,
latestCommentAt: null,
latestLogAt: row.latestLogAt,
});
}
}
}
return [...byIssueId.values()];
}
async function blockedByMapForIssues(
dbOrTx: any,
companyId: string,
issueIds: string[],
): Promise<Map<string, IssueRelationIssueSummary[]>> {
const map = new Map<string, IssueRelationIssueSummary[]>();
const uniqueIssueIds = [...new Set(issueIds)];
if (uniqueIssueIds.length === 0) return map;
for (const issueId of uniqueIssueIds) {
map.set(issueId, []);
}
for (const issueIdChunk of chunkList(
uniqueIssueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
currentIssueId: issueRelations.relatedIssueId,
relatedId: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, issueIdChunk),
),
);
for (const row of rows) {
const blockedBy = map.get(row.currentIssueId);
if (!blockedBy) continue;
blockedBy.push({
id: row.relatedId,
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
});
}
}
for (const blockedBy of map.values()) {
blockedBy.sort((a, b) => a.title.localeCompare(b.title));
}
return map;
}
const BLOCKED_INBOX_TERMINAL_STATUSES = ["done", "cancelled"] as const;
const BLOCKED_INBOX_ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
const BLOCKED_INBOX_ACTIVE_WAKE_STATUSES =
SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES;
const BLOCKED_INBOX_PENDING_INTERACTION_STATUSES = ["pending"] as const;
const BLOCKED_INBOX_PENDING_APPROVAL_STATUSES = [
"pending",
"revision_requested",
] as const;
const BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS = [
"harness_liveness_escalation",
"stranded_issue_recovery",
] as const;
const BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
"issue.successful_run_handoff_escalated",
] as const;
type BlockedInboxIssueRow = IssueRow & {
labels?: IssueLabelRow[];
labelIds?: string[];
};
type BlockedInboxInteractionRow = {
id: string;
issueId: string;
kind: string;
createdAt: Date;
};
type BlockedInboxApprovalRow = {
approvalId: string;
issueId: string;
createdAt: Date;
};
function issueRef(
row:
| Pick<
IssueRow,
| "id"
| "identifier"
| "title"
| "status"
| "priority"
| "assigneeAgentId"
| "assigneeUserId"
>
| null
| undefined,
): IssueBlockedInboxIssueRef | null {
if (!row) return null;
return {
id: row.id,
identifier: row.identifier,
title: row.title,
status: row.status as IssueBlockedInboxIssueRef["status"],
priority: row.priority as IssueBlockedInboxIssueRef["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
};
}
function hasPlanDocumentCondition(
companyId: string,
hasPlanDocument: boolean,
): SQL {
const existsPlanDocument = sql<boolean>`
EXISTS (
SELECT 1
FROM ${issueDocuments}
WHERE ${issueDocuments.companyId} = ${companyId}
AND ${issueDocuments.issueId} = ${issues.id}
AND ${issueDocuments.key} = 'plan'
)
`;
return hasPlanDocument
? existsPlanDocument
: sql<boolean>`NOT ${existsPlanDocument}`;
}
function isoDate(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function attentionBase(input: {
state: IssueBlockedInboxAttention["state"];
reason: IssueBlockedInboxAttention["reason"];
severity: IssueBlockedInboxAttention["severity"];
stoppedSinceAt: Date | string | null | undefined;
owner: IssueBlockedInboxAttention["owner"];
action: IssueBlockedInboxAttention["action"];
sourceIssue: IssueBlockedInboxIssueRef | null;
leafIssue?: IssueBlockedInboxIssueRef | null;
recoveryIssue?: IssueBlockedInboxIssueRef | null;
approvalId?: string | null;
interactionId?: string | null;
sampleIssueIdentifier?: string | null;
externalDetailsRedacted?: boolean;
}): IssueBlockedInboxAttention {
return {
kind: "blocked",
state: input.state,
reason: input.reason,
severity: input.severity,
stoppedSinceAt: isoDate(input.stoppedSinceAt),
owner: input.owner,
action: input.action,
sourceIssue: input.sourceIssue,
leafIssue: input.leafIssue ?? null,
recoveryIssue: input.recoveryIssue ?? null,
approvalId: input.approvalId ?? null,
interactionId: input.interactionId ?? null,
sampleIssueIdentifier:
input.sampleIssueIdentifier ??
input.leafIssue?.identifier ??
input.recoveryIssue?.identifier ??
input.sourceIssue?.identifier ??
null,
redaction: {
externalDetailsRedacted: input.externalDetailsRedacted ?? false,
secretFieldsOmitted: true,
},
};
}
function readSuccessfulRunHandoffFromActivity(row: {
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
readStringFromRecord(details, "detectedProgressSummary") ??
readStringFromRecord(details, "detected_progress_summary") ??
null;
return {
state,
required: state === "required",
hasLiveContinuation: false,
sourceRunId:
readStringFromRecord(details, "sourceRunId") ??
readStringFromRecord(details, "source_run_id") ??
readStringFromRecord(details, "resumeFromRunId") ??
row.runId ??
null,
correctiveRunId:
readStringFromRecord(details, "correctiveRunId") ??
readStringFromRecord(details, "corrective_run_id") ??
(state !== "required" ? row.runId : null),
assigneeAgentId:
readStringFromRecord(details, "assigneeAgentId") ??
readStringFromRecord(details, "agentId") ??
row.agentId ??
null,
detectedProgressSummary: detectedProgressSummary
? redactSensitiveText(detectedProgressSummary)
: null,
createdAt: row.createdAt,
};
}
async function listSuccessfulRunHandoffMapForIssues(
dbOrTx: any,
companyId: string,
issueIds: string[],
options?: { hydrateLiveness?: boolean },
): Promise<Map<string, SuccessfulRunHandoffState>> {
const uniqueIssueIds = [...new Set(issueIds)];
const states = new Map<string, SuccessfulRunHandoffState>();
if (uniqueIssueIds.length === 0) return states;
for (const issueIdChunk of chunkList(
uniqueIssueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
entityId: activityLog.entityId,
action: activityLog.action,
agentId: activityLog.agentId,
runId: activityLog.runId,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIdChunk),
inArray(activityLog.action, [
...BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS,
]),
),
)
.orderBy(
activityLog.entityId,
desc(activityLog.createdAt),
desc(activityLog.id),
);
for (const row of rows as Array<{
entityId: string;
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}>) {
if (states.has(row.entityId)) continue;
const state = readSuccessfulRunHandoffFromActivity(row);
if (state) states.set(row.entityId, state);
}
}
return options?.hydrateLiveness === false
? states
: hydrateSuccessfulRunHandoffLiveness(dbOrTx, companyId, states);
}
function externalWaitFromDescription(
description: string | null,
): { owner: string; action: string } | null {
if (!description) return null;
const owner = description
.match(/^\s*external owner\s*:\s*(.+)$/im)?.[1]
?.trim();
const action = description
.match(/^\s*external action\s*:\s*(.+)$/im)?.[1]
?.trim();
if (!owner || !action) return null;
return {
owner: owner.slice(0, 120),
action: action.slice(0, 240),
};
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function redactExternalWaitDescription(
description: string | null | undefined,
external: { owner: string; action: string } | null,
) {
if (!description) return null;
let redacted = description
.split(/\r?\n/)
.filter((line) => !/^\s*external\s+(?:owner|action)\s*:/i.test(line))
.join("\n");
for (const value of [external?.owner, external?.action]) {
if (!value) continue;
redacted = redacted.replace(
new RegExp(escapeRegExp(value), "gi"),
"[redacted external wait detail]",
);
}
redacted = redacted.replace(/\n{3,}/g, "\n\n").trim();
return redacted.length > 0 ? redacted : null;
}
function blockedInboxResponseDescription(
attention: IssueBlockedInboxAttention,
row: BlockedInboxIssueRow,
) {
if (!attention.redaction.externalDetailsRedacted) return row.description;
return redactExternalWaitDescription(
row.description,
externalWaitFromDescription(row.description),
);
}
function blockedInboxSearchText(
attention: IssueBlockedInboxAttention,
row: BlockedInboxIssueRow,
) {
return [
row.identifier,
row.title,
blockedInboxResponseDescription(attention, row),
attention.sourceIssue?.identifier,
attention.sourceIssue?.title,
attention.leafIssue?.identifier,
attention.leafIssue?.title,
attention.recoveryIssue?.identifier,
attention.recoveryIssue?.title,
attention.action.label,
attention.action.detail,
]
.filter(
(value): value is string => typeof value === "string" && value.length > 0,
)
.join(" ")
.toLowerCase();
}
function blockedInboxSeverityRank(
severity: IssueBlockedInboxAttention["severity"],
) {
switch (severity) {
case "critical":
return 0;
case "high":
return 1;
case "medium":
return 2;
case "low":
return 3;
}
}
function issuePriorityRank(priority: string) {
switch (priority) {
case "critical":
return 0;
case "high":
return 1;
case "medium":
return 2;
case "low":
return 3;
default:
return 4;
}
}
function compareBlockedInboxRows(
left: BlockedInboxIssueRow & {
blockedInboxAttention: IssueBlockedInboxAttention;
lastActivityAt?: Date | null;
},
right: BlockedInboxIssueRow & {
blockedInboxAttention: IssueBlockedInboxAttention;
lastActivityAt?: Date | null;
},
) {
const leftAttention = left.blockedInboxAttention;
const rightAttention = right.blockedInboxAttention;
const severity =
blockedInboxSeverityRank(leftAttention.severity) -
blockedInboxSeverityRank(rightAttention.severity);
if (severity !== 0) return severity;
const leftStopped = leftAttention.stoppedSinceAt
? new Date(leftAttention.stoppedSinceAt).getTime()
: Number.POSITIVE_INFINITY;
const rightStopped = rightAttention.stoppedSinceAt
? new Date(rightAttention.stoppedSinceAt).getTime()
: Number.POSITIVE_INFINITY;
if (leftStopped !== rightStopped) return leftStopped - rightStopped;
const priority =
issuePriorityRank(left.priority) - issuePriorityRank(right.priority);
if (priority !== 0) return priority;
const leftActivity = left.lastActivityAt
? new Date(left.lastActivityAt).getTime()
: new Date(left.updatedAt).getTime();
const rightActivity = right.lastActivityAt
? new Date(right.lastActivityAt).getTime()
: new Date(right.updatedAt).getTime();
if (leftActivity !== rightActivity) return rightActivity - leftActivity;
return right.id.localeCompare(left.id);
}
async function listIssueBlockedInboxAttentionMap(
dbOrTx: any,
companyId: string,
issueRows: BlockedInboxIssueRow[],
): Promise<Map<string, IssueBlockedInboxAttention>> {
const rowIssueIds = [...new Set(issueRows.map((row) => row.id))];
const result = new Map<string, IssueBlockedInboxAttention>();
if (rowIssueIds.length === 0) return result;
const [graphIssueRows, graphRelationRows, companyAgentRows] =
await Promise.all([
dbOrTx
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
visibleIssueCondition(),
ne(issues.status, "done"),
),
),
dbOrTx
.select({
companyId: issueRelations.companyId,
blockerIssueId: issueRelations.issueId,
blockedIssueId: issueRelations.relatedIssueId,
})
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
),
),
dbOrTx
.select({
id: agents.id,
companyId: agents.companyId,
name: agents.name,
role: agents.role,
title: agents.title,
status: agents.status,
reportsTo: agents.reportsTo,
})
.from(agents)
.where(eq(agents.companyId, companyId)),
]);
const graphIssues = graphIssueRows as IssueRow[];
const graphRelations = graphRelationRows as Array<{
companyId: string;
blockerIssueId: string;
blockedIssueId: string;
}>;
const companyAgents = companyAgentRows as Array<{
id: string;
companyId: string;
name: string;
role: string;
title: string | null;
status: string;
reportsTo: string | null;
}>;
const graphIssueIds = graphIssues.map((issue) => issue.id);
const issuesById = new Map<string, IssueRow>(
graphIssues.map((issue) => [issue.id, issue]),
);
const [
activeRunRows,
wakeRows,
scheduledRetryRows,
interactionRows,
approvalRows,
handoffMap,
] = await Promise.all([
graphIssueIds.length === 0
? Promise.resolve([])
: dbOrTx
.select({
companyId: heartbeatRuns.companyId,
issueId: sql<string | null>`coalesce(
${heartbeatRuns.contextSnapshot} ->> 'issueId',
${heartbeatRuns.contextSnapshot} ->> 'taskId'
)`,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, [
...BLOCKED_INBOX_ACTIVE_RUN_STATUSES,
]),
inArray(
sql<string>`coalesce(
${heartbeatRuns.contextSnapshot} ->> 'issueId',
${heartbeatRuns.contextSnapshot} ->> 'taskId'
)`,
graphIssueIds,
),
),
),
graphIssueIds.length === 0
? Promise.resolve([])
: dbOrTx
.select({
companyId: agentWakeupRequests.companyId,
issueId: sql<string | null>`coalesce(
${agentWakeupRequests.payload} ->> 'issueId',
${agentWakeupRequests.payload} ->> 'taskId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
)`,
agentId: agentWakeupRequests.agentId,
status: agentWakeupRequests.status,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
inArray(agentWakeupRequests.status, [
...BLOCKED_INBOX_ACTIVE_WAKE_STATUSES,
]),
inArray(
sql<string>`coalesce(
${agentWakeupRequests.payload} ->> 'issueId',
${agentWakeupRequests.payload} ->> 'taskId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
)`,
graphIssueIds,
),
),
),
graphIssueIds.length === 0
? Promise.resolve([])
: dbOrTx
.select({
companyId: heartbeatRuns.companyId,
issueId: sql<string | null>`coalesce(
${heartbeatRuns.contextSnapshot} ->> 'issueId',
${heartbeatRuns.contextSnapshot} ->> 'taskId'
)`,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.status, "scheduled_retry"),
inArray(
sql<string>`coalesce(
${heartbeatRuns.contextSnapshot} ->> 'issueId',
${heartbeatRuns.contextSnapshot} ->> 'taskId'
)`,
graphIssueIds,
),
),
),
graphIssueIds.length === 0
? Promise.resolve([])
: dbOrTx
.select({
id: issueThreadInteractions.id,
issueId: issueThreadInteractions.issueId,
kind: issueThreadInteractions.kind,
createdAt: issueThreadInteractions.createdAt,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
inArray(issueThreadInteractions.status, [
...BLOCKED_INBOX_PENDING_INTERACTION_STATUSES,
]),
inArray(issueThreadInteractions.issueId, graphIssueIds),
),
),
graphIssueIds.length === 0
? Promise.resolve([])
: dbOrTx
.select({
approvalId: approvals.id,
issueId: issueApprovals.issueId,
createdAt: approvals.createdAt,
})
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, companyId),
eq(approvals.companyId, companyId),
inArray(approvals.status, [
...BLOCKED_INBOX_PENDING_APPROVAL_STATUSES,
]),
inArray(issueApprovals.issueId, graphIssueIds),
),
),
listSuccessfulRunHandoffMapForIssues(dbOrTx, companyId, rowIssueIds, {
hydrateLiveness: false,
}),
]);
const pendingInteractions = (
interactionRows as BlockedInboxInteractionRow[]
).map((row) => ({
companyId,
issueId: row.issueId,
status: "pending",
}));
const pendingApprovals = (approvalRows as BlockedInboxApprovalRow[]).map(
(row) => ({
companyId,
issueId: row.issueId,
status: "pending",
}),
);
const openRecoveryIssues = graphIssues
.filter((issue) =>
BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(
issue.originKind as (typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS)[number],
),
)
.filter(
(issue) =>
!BLOCKED_INBOX_TERMINAL_STATUSES.includes(
issue.status as (typeof BLOCKED_INBOX_TERMINAL_STATUSES)[number],
),
)
.flatMap((issue) => {
const entries = [{ companyId, issueId: issue.id, status: issue.status }];
if (issue.originKind === "harness_liveness_escalation") {
const parsed = parseIssueGraphLivenessIncidentKey(issue.originId);
if (parsed?.companyId === companyId) {
entries.push({
companyId,
issueId: parsed.issueId,
status: issue.status,
});
entries.push({
companyId,
issueId: parsed.leafIssueId,
status: issue.status,
});
}
} else if (
issue.originKind === "stranded_issue_recovery" &&
issue.originId
) {
entries.push({
companyId,
issueId: issue.originId,
status: issue.status,
});
}
return entries;
});
const findings = classifyIssueGraphLiveness({
issues: graphIssues.map((issue) => ({
id: issue.id,
companyId: issue.companyId,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
projectId: issue.projectId,
goalId: issue.goalId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
createdByAgentId: issue.createdByAgentId,
createdByUserId: issue.createdByUserId,
conversationAgentId: issue.conversationAgentId,
conversationUserId: issue.conversationUserId,
conversationState: issue.conversationState,
executionPolicy: issue.executionPolicy,
executionState: issue.executionState,
monitorNextCheckAt: issue.monitorNextCheckAt,
monitorAttemptCount: issue.monitorAttemptCount,
})),
relations: graphRelations,
agents: companyAgents,
activeRuns: (
activeRunRows as Array<{
companyId: string;
issueId: string | null;
agentId: string | null;
status: string;
}>
).flatMap((row) =>
row.issueId
? [
{
companyId: row.companyId,
issueId: row.issueId,
agentId: row.agentId,
status: row.status,
},
]
: [],
),
queuedWakeRequests: [
...(wakeRows as Array<{
companyId: string;
issueId: string | null;
agentId: string | null;
status: string;
}>),
...(scheduledRetryRows as Array<{
companyId: string;
issueId: string | null;
agentId: string | null;
status: string;
}>),
].flatMap((row) =>
row.issueId
? [
{
companyId: row.companyId,
issueId: row.issueId,
agentId: row.agentId,
status: row.status,
},
]
: [],
),
pendingInteractions,
pendingApprovals,
openRecoveryIssues,
now: new Date(),
});
const findingByIssueId = new Map<string, IssueLivenessFinding>();
for (const finding of findings) {
if (!findingByIssueId.has(finding.issueId))
findingByIssueId.set(finding.issueId, finding);
}
const interactionByIssueId = new Map<string, BlockedInboxInteractionRow>();
for (const row of interactionRows as BlockedInboxInteractionRow[]) {
if (!interactionByIssueId.has(row.issueId))
interactionByIssueId.set(row.issueId, row);
}
const approvalByIssueId = new Map<string, BlockedInboxApprovalRow>();
for (const row of approvalRows as BlockedInboxApprovalRow[]) {
if (!approvalByIssueId.has(row.issueId))
approvalByIssueId.set(row.issueId, row);
}
const liveHandoffRunIssueIds = new Set(
[
...(activeRunRows as Array<{ issueId: string | null }>),
...(scheduledRetryRows as Array<{ issueId: string | null }>),
].flatMap((row) => (row.issueId ? [row.issueId] : [])),
);
const liveHandoffWakeIssueIds = new Set(
(wakeRows as Array<{ issueId: string | null }>).flatMap((row) =>
row.issueId ? [row.issueId] : [],
),
);
for (const row of issueRows) {
if (
row.companyId !== companyId ||
BLOCKED_INBOX_TERMINAL_STATUSES.includes(
row.status as (typeof BLOCKED_INBOX_TERMINAL_STATUSES)[number],
) ||
row.hiddenAt
) {
continue;
}
const source = issueRef(row);
const handoff = handoffMap.get(row.id);
const hasLiveHandoffContinuation = Boolean(
(handoff?.state === "required" || handoff?.state === "escalated") &&
(liveHandoffRunIssueIds.has(row.id) ||
liveHandoffWakeIssueIds.has(row.id)),
);
if (
handoff &&
!hasLiveHandoffContinuation &&
(handoff.required || handoff.state === "escalated")
) {
result.set(
row.id,
attentionBase({
state: "missing_disposition",
reason: "missing_successful_run_disposition",
severity: "high",
stoppedSinceAt: handoff.createdAt ?? row.updatedAt,
owner: {
type: row.assigneeAgentId
? "agent"
: row.assigneeUserId
? "user"
: "unknown",
agentId: row.assigneeAgentId,
userId: row.assigneeUserId,
label: null,
},
action: {
label: "Choose disposition",
detail:
"Choose exactly one final disposition: done, cancelled, review/input, blocked with owner, delegated follow-up, or queued continuation.",
},
sourceIssue: source,
}),
);
continue;
}
if (
BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(
row.originKind as (typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS)[number],
)
) {
let sourceIssue: IssueBlockedInboxIssueRef | null = null;
let leafIssue: IssueBlockedInboxIssueRef | null = null;
if (row.originKind === "harness_liveness_escalation") {
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
if (parsed?.companyId === companyId) {
sourceIssue = issueRef(issuesById.get(parsed.issueId));
leafIssue = issueRef(issuesById.get(parsed.leafIssueId));
}
} else if (row.originKind === "stranded_issue_recovery" && row.originId) {
sourceIssue = issueRef(issuesById.get(row.originId));
}
result.set(
row.id,
attentionBase({
state: "recovery_open",
reason: "open_recovery_issue",
severity: "high",
stoppedSinceAt: row.createdAt,
owner: {
type: row.assigneeAgentId
? "agent"
: row.assigneeUserId
? "user"
: "unknown",
agentId: row.assigneeAgentId,
userId: row.assigneeUserId,
label: null,
},
action: {
label: "Resolve recovery",
detail:
"Restore a live path for the source work or record why this recovery issue is a false positive.",
},
sourceIssue: sourceIssue ?? source,
leafIssue,
recoveryIssue: source,
}),
);
continue;
}
const interaction = interactionByIssueId.get(row.id);
if (interaction) {
const isUserQuestion =
interaction.kind === "ask_user_questions" &&
Boolean(row.assigneeUserId);
result.set(
row.id,
attentionBase({
state: "awaiting_decision",
reason: isUserQuestion
? "pending_user_decision"
: "pending_board_decision",
severity: "medium",
stoppedSinceAt: interaction.createdAt,
owner: isUserQuestion
? {
type: "user",
agentId: null,
userId: row.assigneeUserId,
label: null,
}
: { type: "board", agentId: null, userId: null, label: "Board" },
action: {
label: isUserQuestion ? "Answer question" : "Answer confirmation",
detail:
"Respond to the pending issue-thread interaction so the assignee has a live next action.",
},
sourceIssue: source,
interactionId: interaction.id,
}),
);
continue;
}
const approval = approvalByIssueId.get(row.id);
if (approval) {
result.set(
row.id,
attentionBase({
state: "awaiting_decision",
reason: "pending_board_decision",
severity: "medium",
stoppedSinceAt: approval.createdAt,
owner: { type: "board", agentId: null, userId: null, label: "Board" },
action: {
label: "Decide approval",
detail:
"Approve, reject, or request revision on the linked approval.",
},
sourceIssue: source,
approvalId: approval.approvalId,
}),
);
continue;
}
const finding = findingByIssueId.get(row.id);
if (finding) {
const leaf =
finding.dependencyPath.length > 1
? issuesById.get(
finding.dependencyPath[finding.dependencyPath.length - 1]!
.issueId,
)
: issuesById.get(finding.recoveryIssueId);
const ownerAgentId =
finding.state === "blocked_by_unassigned_issue"
? null
: (finding.recommendedOwnerAgentId ??
row.assigneeAgentId ??
leaf?.assigneeAgentId ??
null);
result.set(
row.id,
attentionBase({
state: "needs_attention",
reason: finding.state as IssueBlockedInboxAttention["reason"],
severity:
finding.state === "blocked_by_assigned_backlog_issue" ||
finding.state === "in_review_without_action_path"
? "high"
: finding.severity === "critical"
? "critical"
: "high",
stoppedSinceAt: leaf?.updatedAt ?? row.updatedAt,
owner: {
type: ownerAgentId
? "agent"
: leaf?.assigneeUserId
? "user"
: "unknown",
agentId: ownerAgentId,
userId: leaf?.assigneeUserId ?? null,
label: null,
},
action: {
label: (() => {
switch (finding.state) {
case "blocked_by_unassigned_issue":
return "Assign blocker";
case "blocked_by_assigned_backlog_issue":
return "Resume parked blocker";
case "blocked_by_uninvokable_assignee":
return "Assign active owner";
case "blocked_by_cancelled_issue":
return "Replace blocker";
case "invalid_review_participant":
return "Repair review participant";
case "in_review_without_action_path":
return "Choose review path";
}
})(),
detail: finding.recommendedAction,
},
sourceIssue: source,
leafIssue: issueRef(leaf),
recoveryIssue: issueRef(issuesById.get(finding.recoveryIssueId)),
sampleIssueIdentifier: leaf?.identifier ?? finding.identifier,
}),
);
continue;
}
const hasMonitor = Boolean(
row.monitorNextCheckAt && row.monitorNextCheckAt.getTime() > Date.now(),
);
const external =
row.status === "blocked" && !hasMonitor
? externalWaitFromDescription(row.description)
: null;
if (external) {
result.set(
row.id,
attentionBase({
state: "external_wait",
reason: "external_owner_action",
severity: "medium",
stoppedSinceAt: row.updatedAt,
owner: { type: "external", agentId: null, userId: null, label: null },
action: {
label: "External owner action",
detail: null,
},
sourceIssue: source,
externalDetailsRedacted: true,
}),
);
continue;
}
const blockerAttention = await listIssueBlockerAttentionMap(
dbOrTx,
companyId,
[row],
);
const blockerState = blockerAttention.get(row.id);
if (
row.status === "blocked" &&
(blockerState?.state === "needs_attention" ||
blockerState?.state === "stalled")
) {
result.set(
row.id,
attentionBase({
state: "needs_attention",
reason: "blocked_chain_stalled",
severity: "high",
stoppedSinceAt: row.updatedAt,
owner: { type: "unknown", agentId: null, userId: null, label: null },
action: {
label: "Inspect blocker chain",
detail:
"Inspect the stalled blocker or review leaf and make the next owner/action explicit.",
},
sourceIssue: source,
sampleIssueIdentifier:
blockerState.sampleStalledBlockerIdentifier ??
blockerState.sampleBlockerIdentifier,
}),
);
}
}
return result;
}
function parseIssueAssigneeAgentFilter(
assigneeAgentId: IssueFilters["assigneeAgentId"],
): string | null | undefined {
const normalizedRaw =
typeof assigneeAgentId === "string"
? assigneeAgentId.trim()
: assigneeAgentId;
const normalized = normalizedRaw === "" ? undefined : normalizedRaw;
if (typeof normalized !== "string") return normalized;
return normalized.toLowerCase() === "null" ? null : normalized;
}
function assertValidAssigneeAgentFilter(
assigneeAgentFilter: string | null | undefined,
) {
if (
typeof assigneeAgentFilter === "string" &&
!isUuidLike(assigneeAgentFilter)
) {
throw unprocessable("assigneeAgentId must be a UUID or 'null'");
}
}
async function blockedInboxIssueConditions(
dbOrTx: any,
companyId: string,
filters?: IssueFilters,
) {
const conditions = [
eq(issues.companyId, companyId),
visibleIssueCondition(),
notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]),
];
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
const inboxArchivedByUserId =
filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
const contextUserId =
unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
if (filters?.createdFromIssueId) {
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
}
if (filters?.descendantOf) {
conditions.push(sql<boolean>`
${issues.id} IN (
WITH RECURSIVE descendants(id) AS (
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.parentId} = ${filters.descendantOf}
UNION
SELECT ${issues.id}
FROM ${issues}
JOIN descendants ON ${issues.parentId} = descendants.id
WHERE ${issues.companyId} = ${companyId}
)
SELECT id FROM descendants
)
`);
}
const lowTrustCondition = lowTrustBoundaryIssueCondition(
companyId,
filters?.lowTrustBoundary,
);
if (lowTrustCondition) conditions.push(lowTrustCondition);
const statuses = parseStatusFilter(filters?.status);
if (statuses.length > 0) {
conditions.push(
statuses.length === 1
? eq(issues.status, statuses[0]!)
: inArray(issues.status, statuses),
);
}
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
filters?.assigneeAgentId,
);
assertValidAssigneeAgentFilter(assigneeAgentFilter);
if (assigneeAgentFilter === null) {
conditions.push(isNull(issues.assigneeAgentId));
} else if (assigneeAgentFilter) {
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
}
if (filters?.participantAgentId)
conditions.push(
participatedByAgentCondition(companyId, filters.participantAgentId),
);
if (filters?.assigneeUserId)
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
if (touchedByUserId)
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
if (inboxArchivedByUserId)
conditions.push(
inboxVisibleForUserCondition(companyId, inboxArchivedByUserId),
);
if (unreadForUserId)
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
if (filters?.projectId)
conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.workspaceId) {
conditions.push(
or(
eq(issues.executionWorkspaceId, filters.workspaceId),
eq(issues.projectWorkspaceId, filters.workspaceId),
)!,
);
}
if (filters?.executionWorkspaceId)
conditions.push(
eq(issues.executionWorkspaceId, filters.executionWorkspaceId),
);
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind)
conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originKindPrefix)
conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
if (filters?.hasPlanDocument !== undefined) {
conditions.push(
hasPlanDocumentCondition(companyId, filters.hasPlanDocument),
);
}
if (!shouldIncludePluginOperationIssues(filters))
conditions.push(nonPluginOperationIssueCondition());
if (filters?.labelId) {
const labeledIssueIds = await dbOrTx
.select({ issueId: issueLabels.issueId })
.from(issueLabels)
.where(
and(
eq(issueLabels.companyId, companyId),
eq(issueLabels.labelId, filters.labelId),
),
);
if (labeledIssueIds.length === 0)
return { conditions: [sql<boolean>`false`], contextUserId };
conditions.push(
inArray(
issues.id,
labeledIssueIds.map((row: { issueId: string }) => row.issueId),
),
);
}
if (
filters?.excludeRoutineExecutions &&
!filters?.originKind &&
!filters?.originId
) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
return { conditions, contextUserId };
}
async function listBlockedInboxIssues(
dbOrTx: any,
companyId: string,
filters?: IssueFilters,
): Promise<
Array<
IssueWithLabelsAndRun & {
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
reviewAttention?: IssueReviewAttention;
blockedInboxAttention: IssueBlockedInboxAttention;
liveDescendantCount?: number;
lastActivityAt: Date;
myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null;
isUnreadForMe?: boolean;
}
>
> {
const { conditions, contextUserId } = await blockedInboxIssueConditions(
dbOrTx,
companyId,
filters,
);
const rows = (
await dbOrTx
.select(issueListSelect)
.from(issues)
.where(and(...conditions))
.orderBy(
desc(issueCanonicalLastActivityAtExpr(companyId)),
desc(issues.updatedAt),
desc(issues.id),
)
).map((row: any) => ({
...row,
description: decodeDatabaseTextPreview(
row.description,
ISSUE_LIST_DESCRIPTION_MAX_CHARS,
),
}));
const withLabels = await withIssueLabels(dbOrTx, rows);
const withRuns = withActiveRuns(
withLabels,
await activeRunMapForIssues(dbOrTx, withLabels),
);
if (withRuns.length === 0) return [];
const issueIds = withRuns.map((row) => row.id);
const includeLiveDescendantSummary =
filters?.includeLiveDescendantSummary === true;
const [
statsRows,
readRows,
lastActivityRows,
blockedByMap,
blockerAttentionByIssueId,
reviewAttentionByIssueId,
blockedInboxAttentionByIssueId,
liveDescendantCountByIssueId,
] = await Promise.all([
contextUserId
? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds)
: Promise.resolve([]),
contextUserId
? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds)
: Promise.resolve([]),
lastActivityStatsForIssues(dbOrTx, companyId, issueIds),
blockedByMapForIssues(dbOrTx, companyId, issueIds),
listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns),
listIssueReviewAttentionMap(dbOrTx, companyId, withRuns),
listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
const rawSearchInput = filters?.q?.trim() ?? "";
const rawSearch = rawSearchInput.toLowerCase();
const commentSearchMatchIssueIds = new Set<string>();
if (rawSearchInput) {
const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`;
for (const issueIdChunk of chunkList(
issueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({ issueId: issueComments.issueId })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIdChunk),
isNull(issueComments.deletedAt),
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
),
);
for (const row of rows as Array<{ issueId: string }>)
commentSearchMatchIssueIds.add(row.issueId);
}
}
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const readByIssueId = new Map(
readRows.map((row) => [row.issueId, row.myLastReadAt]),
);
const lastActivityByIssueId = new Map(
lastActivityRows.map((row) => [row.issueId, row]),
);
const enriched = withRuns
.flatMap((row) => {
const blockedInboxAttention = blockedInboxAttentionByIssueId.get(row.id);
if (!blockedInboxAttention) return [];
if (
rawSearch &&
!blockedInboxSearchText(blockedInboxAttention, row).includes(
rawSearch,
) &&
!commentSearchMatchIssueIds.has(row.id)
)
return [];
const activity = lastActivityByIssueId.get(row.id);
const lastActivityAt =
latestIssueActivityAt(
row.updatedAt,
activity?.latestCommentAt ?? null,
activity?.latestLogAt ?? null,
) ?? row.updatedAt;
return [
{
...row,
description: blockedInboxResponseDescription(
blockedInboxAttention,
row,
),
blockedBy: blockedByMap.get(row.id) ?? [],
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id)
? { blockerAttention: blockerAttentionByIssueId.get(row.id) }
: {}),
reviewAttention:
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
blockedInboxAttention,
...(includeLiveDescendantSummary
? {
liveDescendantCount:
liveDescendantCountByIssueId.get(row.id) ?? 0,
}
: {}),
...(contextUserId
? deriveIssueUserContext(row, contextUserId, {
myLastCommentAt:
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
lastExternalCommentAt:
statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
})
: {}),
},
];
})
.sort(compareBlockedInboxRows);
const offset =
typeof filters?.offset === "number" && Number.isFinite(filters.offset)
? Math.max(0, Math.floor(filters.offset))
: 0;
const limit =
typeof filters?.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.floor(filters.limit))
: undefined;
return limit === undefined
? enriched.slice(offset)
: enriched.slice(offset, offset + limit);
}
async function countBlockedInboxIssues(
dbOrTx: any,
companyId: string,
filters?: IssueFilters,
): Promise<number> {
const { conditions } = await blockedInboxIssueConditions(
dbOrTx,
companyId,
filters,
);
const rows = (await dbOrTx
.select()
.from(issues)
.where(and(...conditions))) as IssueRow[];
if (rows.length === 0) return 0;
const blockedInboxAttentionByIssueId =
await listIssueBlockedInboxAttentionMap(dbOrTx, companyId, rows);
const rawSearchInput = filters?.q?.trim() ?? "";
const rawSearch = rawSearchInput.toLowerCase();
const commentSearchMatchIssueIds = new Set<string>();
if (rawSearchInput) {
const issueIds = rows.map((row) => row.id);
const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`;
for (const issueIdChunk of chunkList(
issueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const commentRows = await dbOrTx
.select({ issueId: issueComments.issueId })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIdChunk),
isNull(issueComments.deletedAt),
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
),
);
for (const row of commentRows as Array<{ issueId: string }>)
commentSearchMatchIssueIds.add(row.issueId);
}
}
return rows.reduce((count: number, row: IssueRow) => {
const attention = blockedInboxAttentionByIssueId.get(row.id);
if (!attention) return count;
if (
rawSearch &&
!blockedInboxSearchText(attention, row).includes(rawSearch) &&
!commentSearchMatchIssueIds.has(row.id)
)
return count;
return count + 1;
}, 0);
}
export async function readIssueCommentRunLogText(run: {
runId?: string | null;
logStore: string | null;
logRef: string | null;
logBytes: number | null;
}) {
if (run.logStore !== "local_file" || !run.logRef) return "";
// A timed-out finalization leaves size unknown even when earlier entries
// exist. Read those logs within the same byte budget as a known-size log.
if (run.logBytes !== null && (!Number.isFinite(run.logBytes) || run.logBytes <= 0)) return "";
const logRef = run.logRef;
const store = getRunLogStore();
let offset = 0;
let content = "";
let nextOffset: number | undefined = 0;
const controller = new AbortController();
let readTimer: NodeJS.Timeout | undefined;
const readChunks = async () => {
while (nextOffset !== undefined) {
controller.signal.throwIfAborted();
const remainingBytes =
ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES -
Buffer.byteLength(content, "utf8");
if (remainingBytes <= 0) break;
const chunk = await store.read(
{ store: "local_file", logRef },
{
offset,
limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes),
signal: controller.signal,
},
);
controller.signal.throwIfAborted();
content += chunk.content;
nextOffset = chunk.nextOffset;
offset = chunk.nextOffset ?? 0;
}
};
try {
await Promise.race([
readChunks(),
new Promise<never>((_resolve, reject) => {
readTimer = setTimeout(() => {
const reason = new DOMException("Attribution log read timed out", "TimeoutError");
// Cancellation closes storage work where supported, but filesystem
// I/O can delay stream destruction. Keep the response deadline too.
reject(reason);
controller.abort(reason);
}, ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS);
readTimer.unref?.();
}),
]);
} catch (err) {
// Attribution enriches already-authorized comments. Missing, failed, or
// stalled storage must not prevent listing them; keep any evidence read.
// Do not log raw provider errors, which can contain credentialed URLs.
logger.warn(
{ runId: run.runId ?? undefined, logRef, status: err instanceof HttpError ? err.status : undefined },
"could not read heartbeat run log while deriving optional issue comment metadata",
);
} finally {
clearTimeout(readTimer);
}
return content;
}
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const treeControlSvc = issueTreeControlService(db);
function provisionalTitleFromDescription(description: string) {
const simpleTitle = description.trim().replace(/\s+/g, " ").slice(0, 120);
try {
type MarkdownNode = {
type: string;
alt?: string | null;
children?: MarkdownNode[];
position?: { start: { offset?: number }; end: { offset?: number } };
};
const imageRanges: Array<{ start: number; end: number }> = [];
const imageAlts: string[] = [];
const visit = (node: MarkdownNode) => {
if (node.type === "image" || node.type === "imageReference") {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) imageRanges.push({ start, end });
if (node.alt?.trim()) imageAlts.push(node.alt.trim());
}
node.children?.forEach(visit);
};
visit(parseMarkdown(description) as MarkdownNode);
const withoutImages = imageRanges
.sort((a, b) => b.start - a.start)
.reduce((text, range) => `${text.slice(0, range.start)} ${text.slice(range.end)}`, description);
const plainText = markdownToPlainText(withoutImages).trim().replace(/\s+/g, " ");
const fallback = imageRanges.length > 0
? imageAlts.join(" ") || "Image"
: simpleTitle;
return (plainText || fallback).slice(0, 120);
} catch {
return simpleTitle;
}
}
function normalizeCreateIssueTitle(title: string) {
return title.trim().replace(/\s+/g, " ").toLowerCase();
}
async function getIssueByUuid(id: string) {
const row = await retryIdempotentDatabaseOperation(() => db
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null));
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
async function getIssueByIdentifier(identifier: string) {
const row = await retryIdempotentDatabaseOperation(() => db
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null));
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
async function projectHistoricalRunComments<
T extends { body: string; createdByRunId: string | null },
>(comments: T[]): Promise<T[]> {
const runIds = [
...new Set(
comments.flatMap((comment) =>
comment.createdByRunId && comment.body === LEGACY_WITHHELD_RUN_COMMENT
? [comment.createdByRunId]
: [],
),
),
];
if (runIds.length === 0) return comments;
const runResults = await db
.select({ id: heartbeatRuns.id, resultJson: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(inArray(heartbeatRuns.id, runIds));
const resultByRunId = new Map(
runResults.map((run) => [run.id, parseObject(run.resultJson)]),
);
return comments.map((comment) => {
if (!comment.createdByRunId) return comment;
const body = projectHistoricalHeartbeatRunComment(
comment.body,
resultByRunId.get(comment.createdByRunId),
);
return body === comment.body ? comment : { ...comment, body };
});
}
async function getCurrentScheduledRetriesForIssues(
issueIds: string[],
companyId: string,
dbOrTx: DbReader = db,
): Promise<Map<string, IssueScheduledRetryRow>> {
const uniqueIssueIds = [...new Set(issueIds)];
if (uniqueIssueIds.length === 0) return new Map();
const contextIssueId = sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
const rows = await dbOrTx
.select({
issueId: contextIssueId,
runId: heartbeatRuns.id,
status: heartbeatRuns.status,
agentId: heartbeatRuns.agentId,
agentName: agents.name,
retryOfRunId: heartbeatRuns.retryOfRunId,
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
error: heartbeatRuns.error,
errorCode: heartbeatRuns.errorCode,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, [
"scheduled_retry",
"queued",
"running",
]),
isNotNull(heartbeatRuns.scheduledRetryReason),
inArray(contextIssueId, uniqueIssueIds),
),
)
.orderBy(
sql`case ${heartbeatRuns.status}
when 'running' then 0
when 'queued' then 1
else 2
end`,
asc(heartbeatRuns.scheduledRetryAt),
asc(heartbeatRuns.createdAt),
asc(heartbeatRuns.id),
);
const currentByIssueId = new Map<string, IssueScheduledRetryRow>();
for (const row of rows) {
if (currentByIssueId.has(row.issueId)) continue;
const status = row.status;
if (
status !== "scheduled_retry" &&
status !== "queued" &&
status !== "running"
)
continue;
currentByIssueId.set(row.issueId, { ...row, status });
}
return currentByIssueId;
}
async function getCurrentScheduledRetryForIssue(
issueId: string,
companyId: string,
): Promise<IssueScheduledRetryRow | null> {
const currentByIssueId = await getCurrentScheduledRetriesForIssues(
[issueId],
companyId,
);
return currentByIssueId.get(issueId) ?? null;
}
function deriveIssueCommentAuthorType(comment: {
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
}): IssueCommentAuthorType {
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
if (explicit.success) return explicit.data;
if (comment.authorAgentId) return "agent";
if (comment.authorUserId) return "user";
return "system";
}
function assertIssueCommentAuthorTypeAllowed(
actor: { agentId?: string | null; userId?: string | null },
authorType: IssueCommentAuthorType,
) {
if (actor.agentId && authorType !== "agent") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (actor.userId && authorType !== "user") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (!actor.agentId && !actor.userId && authorType !== "system") {
throw unprocessable(
"System comments cannot use user or agent authorType without an author id",
);
}
}
function redactIssueComment<
T extends {
body: string;
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
presentation?: unknown;
metadata?: unknown;
deletedAt?: Date | string | null;
deletedByType?: "agent" | "user" | null;
deletedByAgentId?: string | null;
deletedByUserId?: string | null;
deletedByRunId?: string | null;
},
>(
comment: T,
censorUsernameInLogs: boolean,
): T & {
authorType: IssueCommentAuthorType;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
} {
const deletedAt = comment.deletedAt ?? null;
if (deletedAt) {
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
body: "",
presentation: null,
metadata: null,
deletedAt,
deletedByType: comment.deletedByType ?? null,
deletedByAgentId: comment.deletedByAgentId ?? null,
deletedByUserId: comment.deletedByUserId ?? null,
deletedByRunId: comment.deletedByRunId ?? null,
};
}
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
body: redactCurrentUserText(comment.body, {
enabled: censorUsernameInLogs,
}),
presentation: issueCommentPresentationSchema
.nullable()
.catch(null)
.parse(comment.presentation ?? null),
metadata: issueCommentMetadataSchema
.nullable()
.catch(null)
.parse(comment.metadata ?? null),
};
}
// Persist a resolved attribution so subsequent reads stop re-scanning run
// logs (and old "Board" threads stay fixed durably). Best-effort: a write
// failure must never break the read path. The `IS NULL` guard keeps this
// idempotent and avoids clobbering a value another reader just stored.
async function persistDerivedIssueCommentAttribution(
derivedByCommentId: ReadonlyMap<string, DerivedIssueCommentAttribution>,
) {
if (derivedByCommentId.size === 0) return;
// One bulk `UPDATE ... FROM (VALUES ...)` so the read path is never blocked
// on N sequential round-trips for a large legacy thread. The `IS NULL` guard
// keeps this idempotent and avoids clobbering a value another reader just
// stored. Best-effort: a write failure must never break the read path.
const rows = [...derivedByCommentId].map(
([commentId, derived]) =>
sql`(${commentId}::uuid, ${derived.derivedAuthorAgentId}::uuid, ${derived.derivedCreatedByRunId}::uuid, ${derived.derivedAuthorSource}::text)`,
);
try {
await db.execute(sql`
UPDATE ${issueComments} AS c
SET derived_author_agent_id = v.agent_id,
derived_created_by_run_id = v.run_id,
derived_author_source = v.source
FROM (VALUES ${sql.join(rows, sql`, `)}) AS v(comment_id, agent_id, run_id, source)
WHERE c.id = v.comment_id AND c.derived_author_agent_id IS NULL
`);
} catch (err) {
logger.warn(
{ err, commentIds: [...derivedByCommentId.keys()] },
"failed to persist derived issue-comment attribution",
);
}
}
async function enrichCommentsWithDerivedAgentAttribution<
T extends {
id: string;
companyId: string;
issueId: string;
authorAgentId?: string | null;
authorUserId?: string | null;
createdByRunId?: string | null;
derivedAuthorAgentId?: string | null;
createdAt: Date | string;
},
>(comments: readonly T[]) {
// Candidates: a non-human author, no stored agent, and not already resolved
// by a previous read / the backfill migration.
const preliminary = comments.filter(
(comment) =>
!comment.authorAgentId &&
!!comment.authorUserId &&
!comment.derivedAuthorAgentId,
);
if (preliminary.length === 0) return comments;
const companyId = comments[0]?.companyId ?? null;
const issueId = comments[0]?.issueId ?? null;
if (!companyId || !issueId) return comments;
// Guard: never reattribute a comment whose author maps to a genuine user
// profile. Only the non-human sentinels agents post under (e.g.
// `local-board`) are eligible — even though `local-board` is itself a row in
// the `user` table, so a plain "exists in user table" check would wrongly
// exclude every mis-attributed agent comment.
const nonSentinelAuthorUserIds = [
...new Set(
preliminary
.map((comment) => comment.authorUserId)
.filter(
(id): id is string =>
!!id && !NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(id),
),
),
];
const genuineUserIds = nonSentinelAuthorUserIds.length
? new Set(
(
await db
.select({ id: authUsers.id })
.from(authUsers)
.where(inArray(authUsers.id, nonSentinelAuthorUserIds))
).map((row) => row.id),
)
: new Set<string>();
// `preliminary` already guarantees a truthy `authorUserId`, so only the two
// "not a genuine user" arms are live: the explicit non-human sentinel, or an
// author id absent from the `user` table.
const candidates = preliminary.filter(
(comment) =>
NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(comment.authorUserId!) ||
!genuineUserIds.has(comment.authorUserId!),
);
if (candidates.length === 0) return comments;
const minCommentCreatedAtMs = candidates.reduce<number | null>(
(min, comment) => {
const timestamp = toTimestampMs(comment.createdAt);
if (timestamp === null) return min;
return min === null ? timestamp : Math.min(min, timestamp);
},
null,
);
const maxCommentCreatedAtMs = candidates.reduce<number | null>(
(max, comment) => {
const timestamp = toTimestampMs(comment.createdAt);
if (timestamp === null) return max;
return max === null ? timestamp : Math.max(max, timestamp);
},
null,
);
if (minCommentCreatedAtMs === null || maxCommentCreatedAtMs === null)
return comments;
const minCommentCreatedAt = new Date(minCommentCreatedAtMs).toISOString();
const maxCommentCreatedAt = new Date(
maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS,
).toISOString();
// The runs the comments' own `createdByRunId` point at — fetched
// unconditionally so the lossless run-id tier resolves even when a run is
// not otherwise associated with the issue.
const ownRunIds = [
...new Set(
candidates
.map((comment) => comment.createdByRunId)
.filter((id): id is string => !!id),
),
];
const runs = await db
.select({
runId: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
createdAt: heartbeatRuns.createdAt,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
logStore: heartbeatRuns.logStore,
logRef: heartbeatRuns.logRef,
logBytes: heartbeatRuns.logBytes,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
or(
and(
or(
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`exists (
select 1
from ${activityLog}
where ${activityLog.companyId} = ${companyId}
and ${activityLog.entityType} = 'issue'
and ${activityLog.entityId} = ${issueId}
and ${activityLog.runId} = ${heartbeatRuns.id}
)`,
),
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`,
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`,
),
ownRunIds.length > 0
? inArray(heartbeatRuns.id, ownRunIds)
: sql`false`,
),
),
)
.orderBy(desc(heartbeatRuns.createdAt));
if (runs.length === 0) return comments;
// Pass 1: resolve the run-id tier, which never reads log bodies. Most
// comments resolve here, so we avoid object-storage reads entirely.
const runsWithoutLogs = runs.map((run) => ({ ...run, logContent: "" }));
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>(
deriveIssueCommentRunLogAttribution(candidates, runsWithoutLogs),
);
// Pass 2: for comments still unresolved after the run-id tier, read the logs
// of any run whose window overlaps such a comment, to look for the explicit
// `comment id:` post marker. The marker is a lossless signal regardless of
// how many runs overlap, so we do not short-circuit on the single-run case.
const unresolved = candidates.filter(
(comment) => !derivedByCommentId.has(comment.id),
);
if (unresolved.length > 0) {
const runIdsToRead = new Set<string>();
for (const run of runs) {
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
if (runStartMs === null || runEndMs === null) continue;
for (const comment of unresolved) {
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
if (commentCreatedAtMs === null) continue;
if (
commentCreatedAtMs >= runStartMs &&
commentCreatedAtMs <=
runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS
) {
runIdsToRead.add(run.runId);
break;
}
}
}
if (runIdsToRead.size > 0) {
const runsToRead = runs.filter((run) => runIdsToRead.has(run.runId));
const logByRunId = new Map<string, string>();
for (
let index = 0;
index < runsToRead.length;
index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS
) {
const batch = runsToRead.slice(
index,
index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS,
);
await Promise.all(
batch.map(async (run) => {
logByRunId.set(run.runId, await readIssueCommentRunLogText(run));
}),
);
}
const runsWithLogs = runs.map((run) => ({
...run,
logContent: logByRunId.get(run.runId) ?? "",
}));
for (const [commentId, derived] of deriveIssueCommentRunLogAttribution(
unresolved,
runsWithLogs,
)) {
derivedByCommentId.set(commentId, derived);
}
}
}
if (derivedByCommentId.size === 0) return comments;
await persistDerivedIssueCommentAttribution(derivedByCommentId);
return comments.map((comment) => {
const derived = derivedByCommentId.get(comment.id);
return derived ? { ...comment, ...derived } : comment;
});
}
async function isTreeHoldInteractionCheckoutAllowed(
companyId: string,
checkoutRunId: string | null,
_gate: ActiveIssueTreePauseHoldGate,
) {
if (!checkoutRunId) return false;
const run = await db
.select({
id: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, checkoutRunId),
eq(heartbeatRuns.companyId, companyId),
),
)
.then((rows) => rows[0] ?? null);
const issueId = readStringFromRecord(run?.contextSnapshot, "issueId");
if (!run || !issueId) return false;
return isVerifiedIssueTreeControlInteractionWake(db, {
companyId,
issueId,
agentId: run.agentId,
runId: run.id,
wakeupRequestId: run.wakeupRequestId,
contextSnapshot: run.contextSnapshot as
Record<string, unknown> | null | undefined,
});
}
async function assertAssignableUser(companyId: string, userId: string) {
const membership = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows[0] ?? null);
if (!membership) {
throw notFound("Assignee user not found");
}
}
async function assertValidProjectWorkspace(
companyId: string,
projectId: string | null | undefined,
projectWorkspaceId: string,
dbOrTx: DbReader = db,
) {
const workspace = await dbOrTx
.select({
id: projectWorkspaces.id,
companyId: projectWorkspaces.companyId,
projectId: projectWorkspaces.projectId,
})
.from(projectWorkspaces)
.where(eq(projectWorkspaces.id, projectWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace) throw notFound("Project workspace not found");
if (workspace.companyId !== companyId)
throw unprocessable("Project workspace must belong to same company");
if (projectId && workspace.projectId !== projectId) {
throw unprocessable(
"Project workspace must belong to the selected project",
);
}
return workspace;
}
async function assertValidExecutionWorkspace(
companyId: string,
projectId: string | null | undefined,
executionWorkspaceId: string,
dbOrTx: DbReader = db,
) {
const workspace = await dbOrTx
.select({
id: executionWorkspaces.id,
companyId: executionWorkspaces.companyId,
projectId: executionWorkspaces.projectId,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace) throw notFound("Execution workspace not found");
if (workspace.companyId !== companyId)
throw unprocessable("Execution workspace must belong to same company");
if (projectId && workspace.projectId !== projectId) {
throw unprocessable(
"Execution workspace must belong to the selected project",
);
}
return workspace;
}
async function assertValidLabelIds(
companyId: string,
labelIds: string[],
dbOrTx: any = db,
) {
if (labelIds.length === 0) return;
const existing = await dbOrTx
.select({ id: labels.id })
.from(labels)
.where(
and(eq(labels.companyId, companyId), inArray(labels.id, labelIds)),
);
if (existing.length !== new Set(labelIds).size) {
throw unprocessable("One or more labels are invalid for this company");
}
}
async function syncIssueLabels(
issueId: string,
companyId: string,
labelIds: string[],
dbOrTx: any = db,
) {
const deduped = [...new Set(labelIds)];
await assertValidLabelIds(companyId, deduped, dbOrTx);
await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId));
if (deduped.length === 0) return;
await dbOrTx.insert(issueLabels).values(
deduped.map((labelId) => ({
issueId,
labelId,
companyId,
})),
);
}
async function getIssueRelationSummaryMap(
companyId: string,
issueIds: string[],
dbOrTx: DbReader = db,
): Promise<Map<string, IssueRelationSummaryMap>> {
const uniqueIssueIds = [...new Set(issueIds)];
const empty = new Map<string, IssueRelationSummaryMap>();
for (const issueId of uniqueIssueIds) {
empty.set(issueId, { blockedBy: [], blocks: [] });
}
if (uniqueIssueIds.length === 0) return empty;
const [blockedByRows, blockingRows] = await Promise.all([
dbOrTx
.select({
currentIssueId: issueRelations.relatedIssueId,
relatedId: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, uniqueIssueIds),
),
),
dbOrTx
.select({
currentIssueId: issueRelations.issueId,
relatedId: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.issueId, uniqueIssueIds),
),
),
]);
for (const row of blockedByRows) {
empty
.get(row.currentIssueId)
?.blockedBy.push(summarizeIssueRelationRow(row));
}
for (const row of blockingRows) {
empty
.get(row.currentIssueId)
?.blocks.push(summarizeIssueRelationRow(row));
}
const terminalByRoot = await terminalExplicitBlockersByRoot(
companyId,
[...empty.values()].flatMap((relations) => relations.blockedBy),
dbOrTx,
);
for (const relations of empty.values()) {
relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title));
for (const blocker of relations.blockedBy) {
const terminalBlockers = terminalByRoot.get(blocker.id);
if (terminalBlockers && terminalBlockers.length > 0) {
blocker.terminalBlockers = terminalBlockers;
}
}
relations.blocks.sort((a, b) => a.title.localeCompare(b.title));
}
const relationSummaries: IssueRelationIssueSummary[] = [];
const collectRelationSummary = (summary: IssueRelationIssueSummary) => {
relationSummaries.push(summary);
for (const terminal of summary.terminalBlockers ?? [])
collectRelationSummary(terminal);
};
for (const relations of empty.values()) {
for (const blocker of relations.blockedBy)
collectRelationSummary(blocker);
for (const blocking of relations.blocks) collectRelationSummary(blocking);
}
const scheduledRetryByIssueId = await getCurrentScheduledRetriesForIssues(
relationSummaries.map((summary) => summary.id),
companyId,
dbOrTx,
);
for (const summary of relationSummaries) {
summary.scheduledRetry = scheduledRetryByIssueId.get(summary.id) ?? null;
}
return empty;
}
async function withIssueRelationSummaries<T extends { id: string }>(
companyId: string,
rows: T[],
dbOrTx: DbReader = db,
): Promise<Array<T & IssueRelationSummaryMap>> {
if (rows.length === 0) return [];
const relationMap = await getIssueRelationSummaryMap(
companyId,
rows.map((row) => row.id),
dbOrTx,
);
return rows.map((row) => ({
...row,
...(relationMap.get(row.id) ?? { blockedBy: [], blocks: [] }),
}));
}
async function assertNoBlockingCycles(
companyId: string,
issueId: string,
blockerIssueIds: string[],
dbOrTx: DbReader = db,
) {
if (blockerIssueIds.length === 0) return;
const rows = await dbOrTx
.select({
blockerIssueId: issueRelations.issueId,
blockedIssueId: issueRelations.relatedIssueId,
})
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
),
);
const adjacency = new Map<string, string[]>();
for (const row of rows) {
const list = adjacency.get(row.blockerIssueId) ?? [];
list.push(row.blockedIssueId);
adjacency.set(row.blockerIssueId, list);
}
for (const blockerIssueId of blockerIssueIds) {
const queue = [...(adjacency.get(issueId) ?? [])];
const visited = new Set<string>([issueId]);
while (queue.length > 0) {
const current = queue.shift()!;
if (current === blockerIssueId) {
throw unprocessable("Blocking relations cannot contain cycles");
}
if (visited.has(current)) continue;
visited.add(current);
queue.push(...(adjacency.get(current) ?? []));
}
}
}
async function syncBlockedByIssueIds(
issueId: string,
companyId: string,
blockedByIssueIds: string[],
actor: { agentId?: string | null; userId?: string | null } = {},
dbOrTx: any = db,
) {
const deduped = [...new Set(blockedByIssueIds)];
if (deduped.some((candidate) => candidate === issueId)) {
throw unprocessable("Issue cannot be blocked by itself");
}
if (deduped.length > 0) {
const lockedIssueIds = [issueId, ...deduped].sort();
await dbOrTx.execute(
sql`SELECT ${issues.id} FROM ${issues}
WHERE ${and(eq(issues.companyId, companyId), inArray(issues.id, lockedIssueIds))}
ORDER BY ${issues.id}
FOR UPDATE`,
);
const relatedIssues = await dbOrTx
.select({ id: issues.id })
.from(issues)
.where(
and(eq(issues.companyId, companyId), inArray(issues.id, deduped)),
);
if (relatedIssues.length !== deduped.length) {
throw unprocessable(
"Blocked-by issues must belong to the same company",
);
}
await assertNoBlockingCycles(companyId, issueId, deduped, dbOrTx);
}
await dbOrTx
.delete(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.relatedIssueId, issueId),
eq(issueRelations.type, "blocks"),
),
);
if (deduped.length === 0) return;
await dbOrTx.insert(issueRelations).values(
deduped.map((blockerIssueId) => ({
companyId,
issueId: blockerIssueId,
relatedIssueId: issueId,
type: "blocks",
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
})),
);
}
async function isTerminalOrMissingHeartbeatRun(
runId: string,
dbOrTx: DbReader = db,
) {
return heartbeatRunIsTerminalOrMissing(dbOrTx, runId);
}
async function adoptStaleCheckoutRun(input: {
issueId: string;
actorAgentId: string;
actorRunId: string;
expectedCheckoutRunId: string;
}) {
return db.transaction(async (tx) => {
const lockedIssue = await tx
.select({
id: issues.id,
companyId: issues.companyId,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, input.issueId))
.for("update")
.then((rows) => rows[0] ?? null);
if (!lockedIssue) {
return { adopted: null, latest: null };
}
if (
lockedIssue.status !== "in_progress" ||
lockedIssue.assigneeAgentId !== input.actorAgentId ||
lockedIssue.checkoutRunId !== input.expectedCheckoutRunId
) {
return { adopted: null, latest: lockedIssue };
}
await Promise.all([
tx.execute(
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.expectedCheckoutRunId} for update`,
),
tx.execute(
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
),
]);
const [existingRun, actorRun] = await Promise.all([
tx
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.expectedCheckoutRunId))
.then((rows) => rows[0] ?? null),
tx
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.actorRunId))
.then((rows) => rows[0] ?? null),
]);
const stale =
!existingRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingRun.status);
if (isExplicitContinuationRetryClaim(lockedIssue, existingRun)) {
return { adopted: null, latest: lockedIssue };
}
if (lockedIssue.executionRunId && lockedIssue.executionRunId !== input.expectedCheckoutRunId) {
const executionRun = await tx.select().from(heartbeatRuns)
.where(eq(heartbeatRuns.id, lockedIssue.executionRunId)).for("update")
.then(rows => rows[0] ?? null);
if (isExplicitContinuationRetryClaim(lockedIssue, executionRun)) {
return { adopted: null, latest: lockedIssue };
}
}
const actorLive =
actorRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status);
if (!stale || !actorLive) {
return { adopted: null, latest: lockedIssue };
}
const now = new Date();
const adopted = await tx
.update(issues)
.set({
checkoutRunId: input.actorRunId,
executionRunId: input.actorRunId,
executionLockedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, input.issueId),
eq(issues.status, "in_progress"),
eq(issues.assigneeAgentId, input.actorAgentId),
eq(issues.checkoutRunId, input.expectedCheckoutRunId),
),
)
.returning({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.then((rows) => rows[0] ?? null);
if (adopted) {
return { adopted, latest: adopted };
}
const latest = await tx
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, input.issueId))
.then((rows) => rows[0] ?? null);
return { adopted: null, latest };
});
}
async function adoptUnownedCheckoutRun(input: {
issueId: string;
actorAgentId: string;
actorRunId: string;
}) {
return db.transaction(async (tx) => {
await tx.execute(
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
);
const actorRun = await tx
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.actorRunId))
.then((rows) => rows[0] ?? null);
if (!actorRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status))
return null;
const now = new Date();
const adopted = await tx
.update(issues)
.set({
checkoutRunId: input.actorRunId,
executionRunId: input.actorRunId,
executionLockedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, input.issueId),
eq(issues.status, "in_progress"),
eq(issues.assigneeAgentId, input.actorAgentId),
isNull(issues.checkoutRunId),
or(
isNull(issues.executionRunId),
eq(issues.executionRunId, input.actorRunId),
),
),
)
.returning({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.then((rows) => rows[0] ?? null);
return adopted;
});
}
async function clearExecutionRunIfTerminal(
issueId: string,
): Promise<boolean> {
return db.transaction(async (tx) => {
await tx.execute(
sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`,
);
const issue = await tx
.select({ id: issues.id, companyId: issues.companyId, executionRunId: issues.executionRunId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue?.executionRunId) return false;
await tx.execute(
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`,
);
const run = await tx
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, issue.executionRunId))
.then((rows) => rows[0] ?? null);
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
if (isExplicitContinuationRetryClaim(issue, run)) return false;
const updated = await tx
.update(issues)
.set({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(issues.id, issueId),
eq(issues.executionRunId, issue.executionRunId),
),
)
.returning({ id: issues.id })
.then((rows) => rows[0] ?? null);
return Boolean(updated);
});
}
// Symmetric to clearExecutionRunIfTerminal. Clears checkoutRunId (and the
// bundled execution lock cols) when the row's checkoutRunId points at a
// heartbeat run that is terminal or no longer exists. No assignee/status
// precondition. Explicit retry claims remain owned by queue-first settlement.
async function clearCheckoutRunIfTerminal(issueId: string): Promise<boolean> {
return db.transaction(async (tx) => {
await tx.execute(
sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`,
);
const issue = await tx
.select({
id: issues.id,
companyId: issues.companyId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue?.checkoutRunId) return false;
await tx.execute(
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.checkoutRunId} for update`,
);
const run = await tx
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, issue.checkoutRunId))
.then((rows) => rows[0] ?? null);
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
if (isExplicitContinuationRetryClaim(issue, run)) return false;
if (
issue.executionRunId &&
issue.executionRunId !== issue.checkoutRunId
) {
await tx.execute(
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`,
);
const executionRun = await tx
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, issue.executionRunId))
.then((rows) => rows[0] ?? null);
if (
executionRun &&
!TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status)
)
return false;
if (isExplicitContinuationRetryClaim(issue, executionRun)) return false;
}
const updated = await tx
.update(issues)
.set({
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(issues.id, issueId),
eq(issues.checkoutRunId, issue.checkoutRunId),
issue.executionRunId
? eq(issues.executionRunId, issue.executionRunId)
: isNull(issues.executionRunId),
),
)
.returning({ id: issues.id })
.then((rows) => rows[0] ?? null);
return Boolean(updated);
});
}
async function addStopRelayCommentIfNeeded(
child: typeof issues.$inferSelect,
dbOrTx: any = db,
) {
if (
!child.parentId ||
(child.status !== "blocked" && child.status !== "cancelled")
)
return null;
const relayKey = `issue-stop-relay:${child.id}:${child.status}`;
await dbOrTx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${relayKey}, 0))`,
);
const childIdentifier = child.identifier?.trim() || child.id;
const childPrefix = childIdentifier.split("-")[0] || "PAP";
const body = `System relay: [${childIdentifier}](/${childPrefix}/issues/${childIdentifier}) transitioned to \`${child.status}\`.`;
const existingRelay = await dbOrTx
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, child.companyId),
eq(issueComments.issueId, child.parentId),
eq(issueComments.authorType, "system"),
eq(issueComments.body, body),
),
)
.limit(1)
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
if (existingRelay) return null;
const parent = await dbOrTx
.select({
id: issues.id,
companyId: issues.companyId,
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
})
.from(issues)
.where(
and(
eq(issues.id, child.parentId),
eq(issues.companyId, child.companyId),
),
)
.then(
(
rows: Array<{
id: string;
companyId: string;
assigneeAgentId: string | null;
status: string;
}>,
) => rows[0] ?? null,
);
if (!parent) return null;
const [comment] = await dbOrTx
.insert(issueComments)
.values({
companyId: child.companyId,
issueId: parent.id,
authorType: "system",
body,
})
.returning();
await dbOrTx
.update(issues)
.set({ updatedAt: new Date() })
.where(eq(issues.id, parent.id));
return { comment, parent };
}
async function archiveInbox(
companyId: string,
issueId: string,
userId: string,
archivedAt: Date = new Date(),
attribution?: {
archivedByActorType: "user" | "agent";
archivedByAgentId?: string | null;
archivedByRunId?: string | null;
},
dbOrTx: any = db,
) {
const now = new Date();
const [row] = await dbOrTx
.insert(issueInboxArchives)
.values({
companyId,
issueId,
userId,
archivedByActorType: attribution?.archivedByActorType ?? "user",
archivedByAgentId: attribution?.archivedByAgentId ?? null,
archivedByRunId: attribution?.archivedByRunId ?? null,
archivedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
issueInboxArchives.companyId,
issueInboxArchives.issueId,
issueInboxArchives.userId,
],
set: {
archivedAt,
archivedByActorType: attribution?.archivedByActorType ?? "user",
archivedByAgentId: attribution?.archivedByAgentId ?? null,
archivedByRunId: attribution?.archivedByRunId ?? null,
updatedAt: now,
},
})
.returning();
return row;
}
const service = {
clearExecutionRunIfTerminal,
clearCheckoutRunIfTerminal,
addStopRelayCommentIfNeeded,
list: async (companyId: string, filters?: IssueFilters) => {
if (filters?.sortField === "id" && filters.attention) {
throw unprocessable("ID ordering is not supported for blocked attention lists");
}
if (filters?.afterId !== undefined && (
!isUuidLike(filters.afterId) || filters.sortField !== "id" ||
filters.sortDir !== "asc" || (filters.offset ?? 0) !== 0
)) {
throw unprocessable("afterId requires a UUID, ascending ID order and no offset");
}
if (filters?.attention === "blocked") {
return listBlockedInboxIssues(db, companyId, {
...filters,
includeBlockedBy: true,
includeBlockedInboxAttention: true,
});
}
const conditions = [
eq(issues.companyId, companyId),
visibleIssueCondition(),
];
if (!filters?.q?.trim()) {
conditions.push(isNull(issues.conversationAgentId));
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
conditions.push(nonIdleSlackIssueCondition());
}
}
if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId));
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
filters?.assigneeAgentId,
);
assertValidAssigneeAgentFilter(assigneeAgentFilter);
const limit =
typeof filters?.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.floor(filters.limit))
: undefined;
const offset =
typeof filters?.offset === "number" && Number.isFinite(filters.offset)
? Math.max(0, Math.floor(filters.offset))
: 0;
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
const inboxArchivedByUserId =
filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
const contextUserId =
unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
const includeBlockedBy = filters?.includeBlockedBy === true;
const includeBlockedInboxAttention =
filters?.includeBlockedInboxAttention === true;
const includeLiveDescendantSummary =
filters?.includeLiveDescendantSummary === true;
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const taskSearch = parseTaskSearch(rawSearch);
if (filters?.createdFromIssueId) {
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
}
if (filters?.descendantOf) {
conditions.push(sql<boolean>`
${issues.id} IN (
WITH RECURSIVE descendants(id) AS (
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.parentId} = ${filters.descendantOf}
UNION
SELECT ${issues.id}
FROM ${issues}
JOIN descendants ON ${issues.parentId} = descendants.id
WHERE ${issues.companyId} = ${companyId}
)
SELECT id FROM descendants
)
`);
}
const lowTrustCondition = lowTrustBoundaryIssueCondition(
companyId,
filters?.lowTrustBoundary,
);
if (lowTrustCondition) conditions.push(lowTrustCondition);
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1) {
conditions.push(eq(issues.status, statuses[0]));
} else if (statuses.length > 1) {
conditions.push(inArray(issues.status, statuses));
}
if (assigneeAgentFilter === null) {
conditions.push(isNull(issues.assigneeAgentId));
} else if (assigneeAgentFilter) {
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
}
if (filters?.participantAgentId) {
conditions.push(
participatedByAgentCondition(companyId, filters.participantAgentId),
);
}
if (filters?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
}
if (touchedByUserId) {
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
}
if (inboxArchivedByUserId) {
conditions.push(
inboxVisibleForUserCondition(companyId, inboxArchivedByUserId),
);
}
if (unreadForUserId) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
if (filters?.projectId)
conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.workspaceId) {
conditions.push(
or(
eq(issues.executionWorkspaceId, filters.workspaceId),
eq(issues.projectWorkspaceId, filters.workspaceId),
)!,
);
}
if (filters?.executionWorkspaceId) {
conditions.push(
eq(issues.executionWorkspaceId, filters.executionWorkspaceId),
);
}
if (filters?.parentId)
conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind)
conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originKindPrefix)
conditions.push(
like(issues.originKind, `${filters.originKindPrefix}%`),
);
if (filters?.originId)
conditions.push(eq(issues.originId, filters.originId));
if (filters?.hasPlanDocument !== undefined) {
conditions.push(
hasPlanDocumentCondition(companyId, filters.hasPlanDocument),
);
}
if (!shouldIncludePluginOperationIssues(filters)) {
conditions.push(nonPluginOperationIssueCondition());
}
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
.from(issueLabels)
.where(
and(
eq(issueLabels.companyId, companyId),
eq(issueLabels.labelId, filters.labelId),
),
);
if (labeledIssueIds.length === 0) return [];
conditions.push(
inArray(
issues.id,
labeledIssueIds.map((row) => row.issueId),
),
);
}
if (filters?.updatedSince) {
const since = new Date(filters.updatedSince);
if (Number.isFinite(since.getTime())) {
conditions.push(gt(issues.updatedAt, since));
}
}
if (
filters?.excludeRoutineExecutions &&
!filters?.originKind &&
!filters?.originId
) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const searchOrder = sql<number>`-task_search.score`;
const issueSource = db.select(issueListSelect).from(issues);
const searchedSource = hasSearch
? issueSource.innerJoin(sql`(
${taskSearchCtes(companyId, taskSearch, true, and(...conditions))}
SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m
) task_search`, sql`task_search.id = ${issues.id}`)
: issueSource;
const baseQuery = searchedSource
.where(and(...conditions))
.orderBy(
...issueListOrderBy(companyId, {
hasSearch,
priorityOrder,
searchOrder,
sortField: filters?.sortField,
sortDir: filters?.sortDir,
}),
);
const pageQuery =
offset > 0
? limit === undefined
? baseQuery.offset(offset)
: baseQuery.limit(limit).offset(offset)
: limit === undefined
? baseQuery
: baseQuery.limit(limit);
const rows = (await pageQuery).map((row) => ({
...row,
description: decodeDatabaseTextPreview(
row.description,
ISSUE_LIST_DESCRIPTION_MAX_CHARS,
),
}));
const withLabels = await withIssueLabels(db, rows);
const runMap = await activeRunMapForIssues(db, withLabels);
const withRuns = withActiveRuns(withLabels, runMap);
if (withRuns.length === 0) {
return withRuns;
}
const issueIds = withRuns.map((row) => row.id);
const [
statsRows,
readRows,
lastActivityRows,
archiveRows,
blockedByMap,
liveDescendantCountByIssueId,
] = await Promise.all([
contextUserId
? userCommentStatsForIssues(db, companyId, contextUserId, issueIds)
: Promise.resolve([]),
contextUserId
? userReadStatsForIssues(db, companyId, contextUserId, issueIds)
: Promise.resolve([]),
lastActivityStatsForIssues(db, companyId, issueIds),
contextUserId
? inboxArchiveRowsForIssues(db, companyId, contextUserId, issueIds)
: Promise.resolve([]),
includeBlockedBy
? blockedByMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, IssueRelationIssueSummary[]>()),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
const statsByIssueId = new Map(
statsRows.map((row) => [row.issueId, row]),
);
const lastActivityByIssueId = new Map(
lastActivityRows.map((row) => [row.issueId, row]),
);
const archiveByIssueId = new Map(
archiveRows.map((row) => [row.issueId, row]),
);
const [
blockerAttentionByIssueId,
reviewAttentionByIssueId,
blockedInboxAttentionByIssueId,
] = await Promise.all([
listIssueBlockerAttentionMap(db, companyId, withRuns),
listIssueReviewAttentionMap(db, companyId, withRuns),
includeBlockedInboxAttention
? listIssueBlockedInboxAttentionMap(db, companyId, withRuns)
: Promise.resolve(new Map<string, IssueBlockedInboxAttention>()),
]);
if (!contextUserId) {
return withRuns.map((row) => {
const activity = lastActivityByIssueId.get(row.id);
const lastActivityAt =
latestIssueActivityAt(
row.updatedAt,
activity?.latestCommentAt ?? null,
activity?.latestLogAt ?? null,
) ?? row.updatedAt;
return {
...row,
...(includeBlockedBy
? { blockedBy: blockedByMap.get(row.id) ?? [] }
: {}),
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id)
? { blockerAttention: blockerAttentionByIssueId.get(row.id) }
: {}),
reviewAttention:
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
...(includeBlockedInboxAttention
? {
blockedInboxAttention:
blockedInboxAttentionByIssueId.get(row.id) ?? null,
}
: {}),
...(includeLiveDescendantSummary
? {
liveDescendantCount:
liveDescendantCountByIssueId.get(row.id) ?? 0,
}
: {}),
};
});
}
const readByIssueId = new Map(
readRows.map((row) => [row.issueId, row.myLastReadAt]),
);
return withRuns.map((row) => {
const activity = lastActivityByIssueId.get(row.id);
const lastActivityAt =
latestIssueActivityAt(
row.updatedAt,
activity?.latestCommentAt ?? null,
activity?.latestLogAt ?? null,
) ?? row.updatedAt;
return {
...row,
...activeInboxArchiveFields(
archiveByIssueId.get(row.id),
lastActivityAt,
),
...(includeBlockedBy
? { blockedBy: blockedByMap.get(row.id) ?? [] }
: {}),
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id)
? { blockerAttention: blockerAttentionByIssueId.get(row.id) }
: {}),
reviewAttention:
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
...(includeBlockedInboxAttention
? {
blockedInboxAttention:
blockedInboxAttentionByIssueId.get(row.id) ?? null,
}
: {}),
...(includeLiveDescendantSummary
? {
liveDescendantCount:
liveDescendantCountByIssueId.get(row.id) ?? 0,
}
: {}),
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt:
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
lastExternalCommentAt:
statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
}),
};
});
},
count: async (companyId: string, filters?: IssueFilters) => {
if (filters?.attention === "blocked") {
return countBlockedInboxIssues(db, companyId, filters);
}
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
if (!filters?.q?.trim()) {
conditions.push(isNull(issues.conversationAgentId));
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
conditions.push(nonIdleSlackIssueCondition());
}
}
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1)
conditions.push(eq(issues.status, statuses[0]!));
else if (statuses.length > 1)
conditions.push(inArray(issues.status, statuses));
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
filters?.assigneeAgentId,
);
assertValidAssigneeAgentFilter(assigneeAgentFilter);
if (assigneeAgentFilter === null) {
conditions.push(isNull(issues.assigneeAgentId));
} else if (assigneeAgentFilter) {
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
}
if (filters?.assigneeUserId)
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
if (filters?.projectId)
conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.workspaceId) {
conditions.push(
or(
eq(issues.executionWorkspaceId, filters.workspaceId),
eq(issues.projectWorkspaceId, filters.workspaceId),
)!,
);
}
if (filters?.executionWorkspaceId)
conditions.push(
eq(issues.executionWorkspaceId, filters.executionWorkspaceId),
);
if (filters?.parentId)
conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind)
conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originKindPrefix)
conditions.push(
like(issues.originKind, `${filters.originKindPrefix}%`),
);
if (filters?.originId)
conditions.push(eq(issues.originId, filters.originId));
if (filters?.hasPlanDocument !== undefined) {
conditions.push(
hasPlanDocumentCondition(companyId, filters.hasPlanDocument),
);
}
if (!shouldIncludePluginOperationIssues(filters))
conditions.push(nonPluginOperationIssueCondition());
const [row] = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(and(...conditions));
return Number(row?.count ?? 0);
},
countUnreadTouchedByUser: async (
companyId: string,
userId: string,
status?: string | readonly string[],
) => {
const conditions = [
eq(issues.companyId, companyId),
visibleIssueCondition(),
nonPluginOperationIssueCondition(),
unreadForUserCondition(companyId, userId),
];
const statuses = parseStatusFilter(status);
if (statuses.length === 1) {
conditions.push(eq(issues.status, statuses[0]));
} else if (statuses.length > 1) {
conditions.push(inArray(issues.status, statuses));
}
const [row] = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(and(...conditions));
return Number(row?.count ?? 0);
},
markRead: async (
companyId: string,
issueId: string,
userId: string,
readAt: Date = new Date(),
) => {
const now = new Date();
const [row] = await db
.insert(issueReadStates)
.values({
companyId,
issueId,
userId,
lastReadAt: readAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
issueReadStates.companyId,
issueReadStates.issueId,
issueReadStates.userId,
],
set: {
lastReadAt: readAt,
updatedAt: now,
},
})
.returning();
return row;
},
markUnread: async (companyId: string, issueId: string, userId: string) => {
const deleted = await db
.delete(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.issueId, issueId),
eq(issueReadStates.userId, userId),
),
)
.returning();
return deleted.length > 0;
},
archiveInbox,
/**
* Seed inbox archives for a batch of freshly imported issues so a company
* import does not flood the importing user's inbox. Imported issues are
* historical work, not new inbox items, but the inbox "mine" query surfaces
* every touched-and-not-archived issue; a 1000-task import would otherwise
* bury the inbox. Seeding a per-user inbox archive keeps them hidden via
* `inboxVisibleForUserCondition`, while genuine new activity on an imported
* issue still resurfaces it. This runs only on import (mirroring how
* `pauseAutomations` threads an import-only suppression) and never touches
* normal issue creation. Rows carry the same "user"-attributed shape a
* manual inbox archive writes, batched to stay under Postgres bind limits.
*/
archiveImportedInbox: async (
companyId: string,
issueIds: string[],
userId: string,
archivedAt: Date = new Date(),
): Promise<void> => {
if (issueIds.length === 0) return;
const now = new Date();
const rows = issueIds.map((issueId) => ({
companyId,
issueId,
userId,
archivedByActorType: "user" as const,
archivedByAgentId: null,
archivedByRunId: null,
archivedAt,
createdAt: now,
updatedAt: now,
}));
await insertRowsInChunks(db, issueInboxArchives, rows);
},
unarchiveInbox: async (
companyId: string,
issueId: string,
userId: string,
) => {
const [row] = await db
.delete(issueInboxArchives)
.where(
and(
eq(issueInboxArchives.companyId, companyId),
eq(issueInboxArchives.issueId, issueId),
eq(issueInboxArchives.userId, userId),
),
)
.returning();
return row ?? null;
},
getActiveInboxArchiveFields: async (
issue: Pick<IssueRow, "id" | "companyId" | "updatedAt">,
userId: string,
) => {
const [[activity], [archive]] = await Promise.all([
lastActivityStatsForIssues(db, issue.companyId, [issue.id]),
inboxArchiveRowsForIssues(db, issue.companyId, userId, [issue.id]),
]);
const lastActivityAt =
latestIssueActivityAt(
issue.updatedAt,
activity?.latestCommentAt ?? null,
activity?.latestLogAt ?? null,
) ?? issue.updatedAt;
return activeInboxArchiveFields(archive, lastActivityAt);
},
/**
* Walk the full parent chain from `parentIssueId` (inclusive) looking for
* a still-open ancestor created by `agentId`. Used to refuse agent
* delegation cycles: an agent assigning a new child to the agent that
* created an open ancestor is handing the same work back to its own
* delegator (A→B→A hot-potato). One recursive query covers the entire
* chain regardless of depth; `UNION` (not `UNION ALL`) deduplicates
* revisited rows, so a parent graph corrupted into a loop terminates
* instead of recursing forever.
*/
findOpenAncestorCreatedByAgent: async (
parentIssueId: string,
agentId: string,
): Promise<{
id: string;
identifier: string | null;
parentId: string | null;
createdByAgentId: string | null;
status: string;
} | null> => {
const rows = await db.execute(sql`
WITH RECURSIVE ancestors(id, parent_id) AS (
SELECT id, parent_id FROM issues WHERE id = ${parentIssueId}
UNION
SELECT parent.id, parent.parent_id
FROM issues parent
JOIN ancestors ON parent.id = ancestors.parent_id
)
SELECT i.id, i.identifier, i.parent_id, i.created_by_agent_id, i.status
FROM issues i
JOIN ancestors a ON a.id = i.id
WHERE i.created_by_agent_id = ${agentId}
AND i.status NOT IN ('done', 'cancelled')
LIMIT 1
`);
const first = (Array.isArray(rows) ? rows[0] : null) as Record<
string,
unknown
> | null;
if (!first) return null;
return {
id: String(first.id),
identifier:
typeof first.identifier === "string" ? first.identifier : null,
parentId: typeof first.parent_id === "string" ? first.parent_id : null,
createdByAgentId:
typeof first.created_by_agent_id === "string"
? first.created_by_agent_id
: null,
status: String(first.status),
};
},
getById: async (raw: string) => {
const id = raw.trim();
const identifier = normalizeIssueReferenceIdentifier(id);
if (identifier) {
return getIssueByIdentifier(identifier);
}
if (!isUuidLike(id)) {
return null;
}
return getIssueByUuid(id);
},
getByIdForUpdate: async (id: string, dbOrTx: any) => {
return dbOrTx
.select()
.from(issues)
.where(eq(issues.id, id))
.for("update")
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
},
getByIdentifier: async (identifier: string) => {
return getIssueByIdentifier(identifier);
},
getCurrentScheduledRetry: async (issueId: string) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
return getCurrentScheduledRetryForIssue(issue.id, issue.companyId);
},
getRelationSummaries: async (issueId: string) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
const relations = await getIssueRelationSummaryMap(
issue.companyId,
[issueId],
db,
);
return relations.get(issueId) ?? { blockedBy: [], blocks: [] };
},
getBlockerDiagnostics: async (
issueId: string,
maxBlockers = ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS,
) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
const cappedMax = Math.max(
0,
Math.min(maxBlockers, ISSUE_BLOCKER_DIAGNOSTICS_MAX_BLOCKERS),
);
const blockerRows = await db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
parentId: issues.parentId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, issue.companyId),
eq(issueRelations.type, "blocks"),
eq(issueRelations.relatedIssueId, issue.id),
eq(issues.companyId, issue.companyId),
),
)
.orderBy(asc(issues.title), asc(issues.id))
.limit(cappedMax + 1);
const readiness = await listIssueDependencyReadinessMap(
db,
issue.companyId,
[issue.id],
);
return {
blockers: blockerRows.slice(
0,
cappedMax,
) as IssueBlockerDiagnosticsIssueRow[],
readiness:
readiness.get(issue.id) ?? createIssueDependencyReadiness(issue.id),
truncated: blockerRows.length > cappedMax,
};
},
getWakeDiagnostics: async (
issueId: string,
opts?: {
maxWakeRequests?: number;
maxActivityRecords?: number;
lookbackDays?: number;
},
) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
const maxWakeRequests = Math.max(
0,
Math.min(
opts?.maxWakeRequests ?? ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
),
);
const maxActivityRecords = Math.max(
0,
Math.min(
opts?.maxActivityRecords ??
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
),
);
const lookbackDays = Math.max(
1,
Math.min(
opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
),
);
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
const wakeRows = await db
.select({
agentId: agentWakeupRequests.agentId,
source: agentWakeupRequests.source,
reason: agentWakeupRequests.reason,
status: agentWakeupRequests.status,
coalescedCount: agentWakeupRequests.coalescedCount,
runId: agentWakeupRequests.runId,
requestedAt: agentWakeupRequests.requestedAt,
claimedAt: agentWakeupRequests.claimedAt,
finishedAt: agentWakeupRequests.finishedAt,
error: agentWakeupRequests.error,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, issue.companyId),
gte(agentWakeupRequests.requestedAt, since),
wakeRequestTargetsIssue(issue.id),
),
)
.orderBy(
desc(agentWakeupRequests.requestedAt),
desc(agentWakeupRequests.createdAt),
)
.limit(maxWakeRequests + 1);
const activityRows = await db
.select({
action: activityLog.action,
entityType: activityLog.entityType,
entityId: activityLog.entityId,
agentId: activityLog.agentId,
runId: activityLog.runId,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, issue.companyId),
gte(activityLog.createdAt, since),
inArray(activityLog.action, [
...ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS,
]),
wakeDiagnosticActivityTargetsIssue(issue.id),
),
)
.orderBy(desc(activityLog.createdAt))
.limit(maxActivityRecords + 1);
return {
wakeRequests: wakeRows.slice(
0,
maxWakeRequests,
) as IssueWakeDiagnosticsWakeRequestRow[],
activityRecords: activityRows.slice(
0,
maxActivityRecords,
) as IssueWakeDiagnosticsActivityRow[],
truncatedWakeRequests: wakeRows.length > maxWakeRequests,
truncatedActivityRecords: activityRows.length > maxActivityRecords,
caps: {
maxWakeRequests: ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
maxActivityRecords: ISSUE_WAKE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS,
lookbackDays: ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
},
};
},
getSubtreeDiagnostics: async (
issueId: string,
opts?: {
maxDepth?: number;
maxNodes?: number;
maxBlockersPerNode?: number;
maxWakeRequestsPerNode?: number;
maxActivityRecordsPerNode?: number;
lookbackDays?: number;
},
) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
const maxDepth = Math.max(
0,
Math.min(
opts?.maxDepth ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_DEPTH,
),
);
const maxNodes = Math.max(
1,
Math.min(
opts?.maxNodes ?? ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_NODES,
),
);
const maxBlockersPerNode = Math.max(
0,
Math.min(
opts?.maxBlockersPerNode ??
ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_BLOCKERS_PER_NODE,
),
);
const maxWakeRequestsPerNode = Math.max(
0,
Math.min(
opts?.maxWakeRequestsPerNode ??
ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_WAKE_REQUESTS_PER_NODE,
),
);
const maxActivityRecordsPerNode = Math.max(
0,
Math.min(
opts?.maxActivityRecordsPerNode ??
ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE,
ISSUE_SUBTREE_DIAGNOSTICS_MAX_ACTIVITY_RECORDS_PER_NODE,
),
);
const lookbackDays = Math.max(
1,
Math.min(
opts?.lookbackDays ?? ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
ISSUE_WAKE_DIAGNOSTICS_LOOKBACK_DAYS,
),
);
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
const sinceIso = since.toISOString();
const rawSubtreeRows =
await db.execute(sql<IssueSubtreeDiagnosticsIssueRow>`
WITH RECURSIVE issue_tree AS (
SELECT
id,
company_id,
project_id,
parent_id,
identifier,
title,
status,
priority,
assignee_agent_id,
assignee_user_id,
created_at,
updated_at,
0 AS depth,
ARRAY[id] AS path
FROM issues
WHERE company_id = ${issue.companyId}
AND id = ${issue.id}
AND hidden_at IS NULL
AND harness_kind IS NULL
UNION ALL
SELECT
child.id,
child.company_id,
child.project_id,
child.parent_id,
child.identifier,
child.title,
child.status,
child.priority,
child.assignee_agent_id,
child.assignee_user_id,
child.created_at,
child.updated_at,
issue_tree.depth + 1,
issue_tree.path || child.id
FROM issues child
JOIN issue_tree ON child.parent_id = issue_tree.id
WHERE child.company_id = ${issue.companyId}
AND child.hidden_at IS NULL
AND child.harness_kind IS NULL
AND issue_tree.depth < ${maxDepth + 1}
AND NOT child.id = ANY(issue_tree.path)
)
SELECT
id,
company_id AS "companyId",
project_id AS "projectId",
parent_id AS "parentId",
identifier,
title,
status,
priority,
assignee_agent_id AS "assigneeAgentId",
assignee_user_id AS "assigneeUserId",
created_at AS "createdAt",
updated_at AS "updatedAt",
depth::int AS depth
FROM issue_tree
ORDER BY depth ASC, created_at ASC, id ASC
LIMIT ${maxNodes + 1}
`);
const subtreeRows = Array.from(rawSubtreeRows).map((row) => ({
...row,
depth: Number(row.depth),
}));
const rowsWithinDepth = subtreeRows.filter(
(row) => row.depth <= maxDepth,
);
const nodes = rowsWithinDepth.slice(
0,
maxNodes,
) as IssueSubtreeDiagnosticsIssueRow[];
const truncatedNodes = rowsWithinDepth.length > maxNodes;
const truncatedDepth =
truncatedNodes || subtreeRows.some((row) => row.depth > maxDepth);
const nodeIds = nodes.map((node) => node.id);
const readiness =
nodeIds.length > 0
? await listIssueDependencyReadinessMap(db, issue.companyId, nodeIds)
: new Map<string, IssueDependencyReadiness>();
const blockersByIssueId = new Map<
string,
IssueSubtreeDiagnosticsBlockerRow[]
>();
const wakeRequestsByIssueId = new Map<
string,
IssueSubtreeDiagnosticsWakeRequestRow[]
>();
const activityRecordsByIssueId = new Map<
string,
IssueSubtreeDiagnosticsActivityRow[]
>();
const truncatedBlockerIssueIds = new Set<string>();
const truncatedWakeIssueIds = new Set<string>();
const truncatedActivityIssueIds = new Set<string>();
if (nodeIds.length > 0) {
const nodeIdValues = sql.join(
nodeIds.map((id) => sql`${id}`),
sql`, `,
);
const rawBlockerRows = Array.from(
await db.execute(sql`
WITH blocker_rows AS (
SELECT
blocker.id,
blocker.company_id AS "companyId",
blocker.project_id AS "projectId",
blocker.parent_id AS "parentId",
blocker.identifier,
blocker.title,
blocker.status,
blocker.priority,
blocker.assignee_agent_id AS "assigneeAgentId",
blocker.assignee_user_id AS "assigneeUserId",
relation.related_issue_id AS "blockedIssueId",
relation.created_at AS "relationCreatedAt",
row_number() OVER (
PARTITION BY relation.related_issue_id
ORDER BY blocker.title ASC, blocker.id ASC
)::int AS "rowNumber"
FROM issue_relations relation
INNER JOIN issues blocker ON blocker.id = relation.issue_id
WHERE relation.company_id = ${issue.companyId}
AND relation.type = 'blocks'
AND blocker.company_id = ${issue.companyId}
AND blocker.hidden_at IS NULL
AND blocker.harness_kind IS NULL
AND relation.related_issue_id::text IN (${nodeIdValues})
)
SELECT *
FROM blocker_rows
WHERE "rowNumber" <= ${maxBlockersPerNode + 1}
ORDER BY "blockedIssueId" ASC, "rowNumber" ASC
`),
) as IssueSubtreeDiagnosticsBlockerResultRow[];
for (const row of rawBlockerRows) {
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
if (normalized.rowNumber > maxBlockersPerNode) {
truncatedBlockerIssueIds.add(normalized.blockedIssueId);
continue;
}
const rows = blockersByIssueId.get(normalized.blockedIssueId) ?? [];
rows.push(normalized);
blockersByIssueId.set(normalized.blockedIssueId, rows);
}
const wakeTargetIssueIdSql = sql<string>`
coalesce(
wake.payload ->> 'issueId',
wake.payload ->> 'taskId',
wake.payload -> '_paperclipWakeContext' ->> 'issueId',
wake.payload -> '_paperclipWakeContext' ->> 'taskId'
)
`;
const rawWakeRows = Array.from(
await db.execute(sql`
WITH wake_rows AS (
SELECT
${wakeTargetIssueIdSql} AS "issueId",
wake.agent_id AS "agentId",
wake.source,
wake.reason,
wake.status,
wake.coalesced_count AS "coalescedCount",
wake.run_id AS "runId",
wake.requested_at AS "requestedAt",
wake.claimed_at AS "claimedAt",
wake.finished_at AS "finishedAt",
wake.error,
row_number() OVER (
PARTITION BY ${wakeTargetIssueIdSql}
ORDER BY wake.requested_at DESC, wake.created_at DESC
)::int AS "rowNumber"
FROM agent_wakeup_requests wake
WHERE wake.company_id = ${issue.companyId}
AND wake.requested_at >= ${sinceIso}::timestamptz
AND ${wakeTargetIssueIdSql} IN (${nodeIdValues})
)
SELECT *
FROM wake_rows
WHERE "rowNumber" <= ${maxWakeRequestsPerNode + 1}
ORDER BY "issueId" ASC, "requestedAt" DESC
`),
) as IssueSubtreeDiagnosticsWakeRequestResultRow[];
for (const row of rawWakeRows) {
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
if (normalized.rowNumber > maxWakeRequestsPerNode) {
truncatedWakeIssueIds.add(normalized.issueId);
continue;
}
const rows = wakeRequestsByIssueId.get(normalized.issueId) ?? [];
rows.push(normalized);
wakeRequestsByIssueId.set(normalized.issueId, rows);
}
const activityTargetIssueIdSql = sql<string>`
coalesce(
CASE WHEN activity.entity_type = 'issue' THEN activity.entity_id ELSE NULL END,
activity.details ->> 'issueId',
activity.details ->> 'rootIssueId'
)
`;
const activityActionValues = sql.join(
ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
),
sql`, `,
);
const rawActivityRows = Array.from(
await db.execute(sql`
WITH activity_rows AS (
SELECT
${activityTargetIssueIdSql} AS "issueId",
activity.action,
activity.entity_type AS "entityType",
activity.entity_id AS "entityId",
activity.agent_id AS "agentId",
activity.run_id AS "runId",
activity.details,
activity.created_at AS "createdAt",
row_number() OVER (
PARTITION BY ${activityTargetIssueIdSql}
ORDER BY activity.created_at DESC, activity.id DESC
)::int AS "rowNumber"
FROM activity_log activity
WHERE activity.company_id = ${issue.companyId}
AND activity.created_at >= ${sinceIso}::timestamptz
AND activity.action IN (${activityActionValues})
AND ${activityTargetIssueIdSql} IN (${nodeIdValues})
)
SELECT *
FROM activity_rows
WHERE "rowNumber" <= ${maxActivityRecordsPerNode + 1}
ORDER BY "issueId" ASC, "createdAt" DESC
`),
) as IssueSubtreeDiagnosticsActivityResultRow[];
for (const row of rawActivityRows) {
const normalized = { ...row, rowNumber: Number(row.rowNumber) };
if (normalized.rowNumber > maxActivityRecordsPerNode) {
truncatedActivityIssueIds.add(normalized.issueId);
continue;
}
const rows = activityRecordsByIssueId.get(normalized.issueId) ?? [];
rows.push(normalized);
activityRecordsByIssueId.set(normalized.issueId, rows);
}
}
return {
nodes,
blockersByIssueId,
readinessByIssueId: readiness,
wakeRequestsByIssueId,
activityRecordsByIssueId,
truncatedNodes,
truncatedDepth,
truncatedBlockerIssueIds,
truncatedWakeIssueIds,
truncatedActivityIssueIds,
caps: {
maxDepth,
maxNodes,
maxBlockersPerNode,
maxWakeRequestsPerNode,
maxActivityRecordsPerNode,
lookbackDays,
},
};
},
getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => {
const issue = await dbOrTx
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then(
(rows: Array<{ id: string; companyId: string }>) => rows[0] ?? null,
);
if (!issue) throw notFound("Issue not found");
const readiness = await listIssueDependencyReadinessMap(
dbOrTx,
issue.companyId,
[issueId],
);
return readiness.get(issueId) ?? createIssueDependencyReadiness(issueId);
},
listDependencyReadiness: async (
companyId: string,
issueIds: string[],
dbOrTx: any = db,
) => {
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
},
listBlockerAttention: async (
companyId: string,
issueRows: IssueBlockerAttentionInputNode[],
dbOrTx: any = db,
) => {
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
},
listReviewAttention: async (
companyId: string,
issueRows: IssueReviewAttentionInput[],
dbOrTx: any = db,
) => {
return listIssueReviewAttentionMap(dbOrTx, companyId, issueRows);
},
listWakeableBlockedDependents: async (blockerIssueId: string) => {
const blockerIssue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, blockerIssueId))
.then((rows) => rows[0] ?? null);
if (!blockerIssue) return [];
const candidates = await db
.select({
id: issues.id,
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
blockedTransitionAt: issues.blockedTransitionAt,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id))
.where(
and(
eq(issueRelations.companyId, blockerIssue.companyId),
eq(issueRelations.type, "blocks"),
eq(issueRelations.issueId, blockerIssueId),
isNull(issues.conversationAgentId),
),
);
if (candidates.length === 0) return [];
const wakeableCandidates = candidates.filter(
(candidate) =>
candidate.assigneeAgentId &&
!["backlog", "done", "cancelled"].includes(candidate.status),
);
if (wakeableCandidates.length === 0) return [];
// Defer to the unified readiness check so that a dependent only fires when
// (a) every blocker is done AND (b) every done blocker's workspace has
// recorded a successful workspace_finalize. The finalize hook also calls
// this function on completion, so a wake initially gated by an in-flight
// sync-back will re-fire once the restore lands locally.
const readinessMap = await listIssueDependencyReadinessMap(
db,
blockerIssue.companyId,
wakeableCandidates.map((candidate) => candidate.id),
);
return wakeableCandidates
.map((candidate) => {
const readiness =
readinessMap.get(candidate.id) ??
createIssueDependencyReadiness(candidate.id);
return { candidate, readiness };
})
.filter(
({ readiness }) =>
readiness.isDependencyReady && readiness.blockerIssueIds.length > 0,
)
.map(({ candidate, readiness }) => ({
id: candidate.id,
assigneeAgentId: candidate.assigneeAgentId!,
blockerIssueIds: readiness.blockerIssueIds,
blockedTransitionAt: candidate.blockedTransitionAt,
}));
},
getWakeableParentAfterChildCompletion: async (
parentIssueId: string,
completedChildResult?: { issueId: string; summary: string | null } | null,
) => {
const parent = await db
.select({
id: issues.id,
conversationAgentId: issues.conversationAgentId,
originKind: issues.originKind,
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
companyId: issues.companyId,
})
.from(issues)
.where(eq(issues.id, parentIssueId))
.then((rows) => rows[0] ?? null);
if (!parent || parent.conversationAgentId || !parent.assigneeAgentId || ["backlog", "cancelled"].includes(parent.status) ||
(parent.status === "done" && parent.originKind !== "onboarding_first_task")) {
return null;
}
const children = await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
updatedAt: issues.updatedAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, parent.companyId),
eq(issues.parentId, parentIssueId),
),
)
.orderBy(asc(issues.issueNumber), asc(issues.createdAt));
if (children.length === 0) return null;
if (
!children.every(
(child) => child.status === "done" || child.status === "cancelled",
)
) {
return null;
}
const childIdsForSummaries = children
.slice(0, MAX_CHILD_COMPLETION_SUMMARIES)
.map((child) => child.id);
const commentRows =
childIdsForSummaries.length > 0
? await db
.select({
issueId: issueComments.issueId,
body: issueComments.body,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, parent.companyId),
inArray(issueComments.issueId, childIdsForSummaries),
isNull(issueComments.deletedAt),
),
)
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
: [];
const latestCommentByIssueId = new Map<string, string>();
for (const comment of commentRows) {
if (!latestCommentByIssueId.has(comment.issueId)) {
latestCommentByIssueId.set(comment.issueId, comment.body);
}
}
const childIssueSummaries: ChildIssueCompletionSummary[] = children
.slice(0, MAX_CHILD_COMPLETION_SUMMARIES)
.map((child) => ({
...child,
summary: truncateInlineSummary(
child.id === completedChildResult?.issueId
? (completedChildResult.summary ??
latestCommentByIssueId.get(child.id))
: latestCommentByIssueId.get(child.id),
),
}));
return {
onboardingCompletion: parent.originKind === "onboarding_first_task",
id: parent.id,
assigneeAgentId: parent.assigneeAgentId,
childIssueIds: children.map((child) => child.id),
childIssueSummaries,
childIssueSummaryTruncated:
children.length > childIssueSummaries.length,
};
},
createChild: async (parentIssueId: string, data: IssueChildCreateInput) => {
const parent = await db
.select()
.from(issues)
.where(eq(issues.id, parentIssueId))
.then((rows) => rows[0] ?? null);
if (!parent) throw notFound("Parent issue not found");
await assertExecutionTaskParent(db, parent.companyId, parent.id);
const idempotencyKey = data.idempotencyKey?.trim();
if (idempotencyKey) {
const existingChild = await db
.select({ issue: issues })
.from(issueCreateIdempotencyKeys)
.innerJoin(issues, eq(issueCreateIdempotencyKeys.issueId, issues.id))
.where(
and(
eq(issueCreateIdempotencyKeys.companyId, parent.companyId),
eq(issueCreateIdempotencyKeys.idempotencyKey, idempotencyKey),
),
)
.limit(1)
.then((rows) => rows[0]?.issue ?? null);
if (existingChild) {
if (existingChild.parentId !== parent.id) {
throw conflict(
"Child creation idempotency key belongs to another parent issue",
);
}
await data.assertCanReuseIssue?.(existingChild);
data.onDeduplicated?.("idempotency_key");
const [enriched] = await withIssueLabels(db, [existingChild]);
const [withRelations] = await withIssueRelationSummaries(
parent.companyId,
[enriched],
db,
);
return {
issue: withRelations,
parentBlockerAdded: false,
};
}
}
const [{ childCount }] = await db
.select({ childCount: sql<number>`count(*)::int` })
.from(issues)
.where(
and(
eq(issues.companyId, parent.companyId),
eq(issues.parentId, parent.id),
),
);
if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) {
throw unprocessable(
`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`,
);
}
const {
acceptanceCriteria,
blockParentUntilDone,
executionWorkspaceInheritanceMode = "linkage",
actorAgentId,
actorUserId,
...issueData
} = data;
const inheritStrategyOnly =
executionWorkspaceInheritanceMode === "strategy_only";
// A child may target another project. Parent workspace identity is only
// valid inside the parent's project, so do not forward it across that
// boundary; create() then resolves the target project's own workspaces.
const childProjectId = issueData.projectId ?? parent.projectId;
const childInheritsParentProject = childProjectId === parent.projectId;
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||
issueData.executionWorkspaceSettings !== undefined;
const inheritedPreRealizationWorkspaceSettings =
inheritStrategyOnly && !hasExplicitExecutionWorkspaceOverride
? buildPreRealizationExecutionWorkspaceSettings(
parent.executionWorkspaceSettings,
)
: null;
let child = await issueService(db).create(parent.companyId, {
...issueData,
parentId: parent.id,
projectId: childProjectId,
projectWorkspaceId:
issueData.projectWorkspaceId ??
(inheritStrategyOnly && childInheritsParentProject
? parent.projectWorkspaceId
: undefined),
goalId: issueData.goalId ?? parent.goalId,
actorResponsibleUserId: issueData.actorResponsibleUserId ?? null,
trustExplicitResponsibleUserId:
issueData.trustExplicitResponsibleUserId === true,
requestDepth: clampIssueRequestDepth(
Math.max(
clampIssueRequestDepth(parent.requestDepth) + 1,
issueData.requestDepth ?? 0,
),
),
description: appendAcceptanceCriteriaToDescription(
issueData.description,
acceptanceCriteria,
),
...(inheritedPreRealizationWorkspaceSettings
? {
executionWorkspaceSettings:
inheritedPreRealizationWorkspaceSettings,
}
: {}),
...(inheritStrategyOnly
? { skipExecutionWorkspaceInheritance: true }
: { inheritExecutionWorkspaceFromIssueId: parent.id }),
});
if (blockParentUntilDone) {
const existingBlockers = await db
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, parent.companyId),
eq(issueRelations.relatedIssueId, parent.id),
eq(issueRelations.type, "blocks"),
),
);
await syncBlockedByIssueIds(
parent.id,
parent.companyId,
[
...new Set([
...existingBlockers.map((row) => row.blockerIssueId),
child.id,
]),
],
{ agentId: actorAgentId ?? null, userId: actorUserId ?? null },
);
[child] = await withIssueRelationSummaries(
parent.companyId,
[child],
db,
);
}
return {
issue: child,
parentBlockerAdded: Boolean(blockParentUntilDone),
};
},
decomposeAcceptedPlan: async (
sourceIssueId: string,
data: AcceptedPlanDecompositionInput,
) => {
const sourceIssue = await db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
goalId: issues.goalId,
})
.from(issues)
.where(eq(issues.id, sourceIssueId))
.then((rows) => rows[0] ?? null);
if (!sourceIssue) throw notFound("Source issue not found");
const requestFingerprint =
createAcceptedPlanDecompositionRequestFingerprint({
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
children: data.children,
});
const initialClaim = await db.transaction(async (tx) => {
await tx.execute(
sql`select ${issues.id} from ${issues} where ${issues.id} = ${sourceIssue.id} for update`,
);
const belongsToPlanDocument = await tx
.select({ revisionId: documentRevisions.id })
.from(issueDocuments)
.innerJoin(
documentRevisions,
eq(issueDocuments.documentId, documentRevisions.documentId),
)
.where(
and(
eq(issueDocuments.companyId, sourceIssue.companyId),
eq(issueDocuments.issueId, sourceIssue.id),
eq(issueDocuments.key, "plan"),
eq(documentRevisions.id, data.acceptedPlanRevisionId),
),
)
.then((rows) => rows[0] ?? null);
if (!belongsToPlanDocument) {
throw unprocessable(
"acceptedPlanRevisionId must belong to the source issue's plan document",
);
}
const acceptedInteraction = await findAcceptedPlanDocumentInteraction(
tx,
{
companyId: sourceIssue.companyId,
sourceIssueId: sourceIssue.id,
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
},
);
if (!acceptedInteraction) {
throw unprocessable(
"acceptedPlanRevisionId must have an accepted plan confirmation",
);
}
const existing = await tx
.select()
.from(issuePlanDecompositions)
.where(
and(
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
eq(
issuePlanDecompositions.acceptedPlanRevisionId,
data.acceptedPlanRevisionId,
),
),
)
.then((rows) => rows[0] ?? null);
const now = new Date();
if (!existing) {
const [created] = await tx
.insert(issuePlanDecompositions)
.values({
companyId: sourceIssue.companyId,
sourceIssueId: sourceIssue.id,
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
acceptedInteractionId: acceptedInteraction.id,
status: "in_flight",
requestFingerprint,
requestedChildCount: data.children.length,
requestedChildren: data.children as unknown as Record<
string,
unknown
>[],
childIssueIds: [],
ownerAgentId: data.actorAgentId ?? null,
ownerUserId: data.actorUserId ?? null,
ownerRunId: data.actorRunId ?? null,
updatedAt: now,
})
.returning();
if (!created)
throw new Error(
"Failed to create accepted-plan decomposition claim",
);
return created;
}
if (existing.requestFingerprint !== requestFingerprint) {
throw conflict(
"Accepted-plan decomposition already exists for this revision with a different child set",
);
}
return existing;
});
let currentClaim = initialClaim;
const newlyCreatedIssues: Array<typeof issues.$inferSelect> = [];
while (true) {
const step = await db.transaction(async (tx) => {
await tx.execute(
sql`select ${issuePlanDecompositions.id}
from ${issuePlanDecompositions}
where ${issuePlanDecompositions.id} = ${currentClaim.id}
for update`,
);
const claim = await tx
.select()
.from(issuePlanDecompositions)
.where(eq(issuePlanDecompositions.id, currentClaim.id))
.then((rows) => rows[0] ?? null);
if (!claim)
throw notFound("Accepted-plan decomposition claim not found");
if (claim.requestFingerprint !== requestFingerprint) {
throw conflict(
"Accepted-plan decomposition already exists for this revision with a different child set",
);
}
const existingChildIssueIds = normalizeIssuePlanDecompositionChildIds(
claim.childIssueIds,
);
if (
claim.status === "completed" ||
existingChildIssueIds.length >= data.children.length
) {
const nextIds = existingChildIssueIds.slice(
0,
data.children.length,
);
if (
claim.status === "completed" &&
nextIds.length === data.children.length
) {
return {
claim,
createdIssue: null,
};
}
const completedAt = claim.completedAt ?? new Date();
const ownerPatch = await resolveAcceptedPlanClaimOwner({
dbOrTx: tx,
claim,
actorAgentId: data.actorAgentId,
actorUserId: data.actorUserId,
actorRunId: data.actorRunId,
});
const [completed] = await tx
.update(issuePlanDecompositions)
.set({
status: "completed",
childIssueIds: nextIds,
completedAt,
...ownerPatch,
updatedAt: completedAt,
})
.where(eq(issuePlanDecompositions.id, claim.id))
.returning();
if (!completed)
throw new Error(
"Failed to complete accepted-plan decomposition claim",
);
return {
claim: completed,
createdIssue: null,
};
}
const nextChildInput = data.children[existingChildIssueIds.length];
if (!nextChildInput) {
throw new Error(
"Accepted-plan decomposition child cursor moved past the requested children",
);
}
const createdChild = await issueService(
tx as unknown as Db,
).createChild(sourceIssue.id, {
...nextChildInput,
executionWorkspaceInheritanceMode: "strategy_only",
});
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
const now = new Date();
const nextStatus =
nextIds.length === data.children.length ? "completed" : "in_flight";
const ownerPatch = await resolveAcceptedPlanClaimOwner({
dbOrTx: tx,
claim,
actorAgentId: data.actorAgentId,
actorUserId: data.actorUserId,
actorRunId: data.actorRunId,
});
const [updatedClaim] = await tx
.update(issuePlanDecompositions)
.set({
status: nextStatus,
childIssueIds: nextIds,
completedAt: nextStatus === "completed" ? now : null,
...ownerPatch,
updatedAt: now,
})
.where(eq(issuePlanDecompositions.id, claim.id))
.returning();
if (!updatedClaim)
throw new Error(
"Failed to persist accepted-plan decomposition progress",
);
return {
claim: updatedClaim,
createdIssue: createdChild.issue,
};
});
currentClaim = step.claim;
if (step.createdIssue) {
newlyCreatedIssues.push(step.createdIssue);
}
if (step.claim.status === "completed") break;
}
const childIssueIds = normalizeIssuePlanDecompositionChildIds(
currentClaim.childIssueIds,
);
const childIssueRows =
childIssueIds.length > 0
? await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, sourceIssue.companyId),
inArray(issues.id, childIssueIds),
),
)
: [];
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
const orderedChildIssues = childIssueIds
.map((childIssueId) => childIssueMap.get(childIssueId))
.filter((row): row is typeof issues.$inferSelect => Boolean(row));
const decomposition = serializeAcceptedPlanDecomposition(currentClaim);
return {
decomposition,
childIssueIds: decomposition.childIssueIds,
childIssues: orderedChildIssues,
newlyCreatedIssues,
};
},
listAcceptedPlanDecompositions: async (sourceIssueId: string) => {
const sourceIssue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, sourceIssueId))
.then((rows) => rows[0] ?? null);
if (!sourceIssue) return [];
const rows = await db
.select({
decomposition: issuePlanDecompositions,
revisionNumber: documentRevisions.revisionNumber,
})
.from(issuePlanDecompositions)
.leftJoin(
documentRevisions,
eq(
documentRevisions.id,
issuePlanDecompositions.acceptedPlanRevisionId,
),
)
.where(
and(
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
),
)
.orderBy(desc(issuePlanDecompositions.createdAt));
if (rows.length === 0) return [];
const allChildIds = new Set<string>();
for (const row of rows) {
for (const childId of normalizeIssuePlanDecompositionChildIds(
row.decomposition.childIssueIds,
)) {
allChildIds.add(childId);
}
}
const childIssueRows =
allChildIds.size > 0
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issues)
.where(
and(
eq(issues.companyId, sourceIssue.companyId),
inArray(issues.id, Array.from(allChildIds)),
),
)
: [];
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
return rows.map((row) => {
const decomposition = serializeAcceptedPlanDecomposition(
row.decomposition,
);
const childIds = decomposition.childIssueIds;
return {
...decomposition,
acceptedPlanRevisionNumber: row.revisionNumber ?? null,
childIssues: childIds
.map((childId) => childIssueMap.get(childId) ?? null)
.filter(
(entry): entry is NonNullable<typeof entry> => entry !== null,
),
};
});
},
listConversations: async (companyId: string, userId: string) => db.select().from(issues).where(and(
eq(issues.companyId, companyId), eq(issues.conversationUserId, userId), isNotNull(issues.conversationAgentId),
)).orderBy(desc(issues.updatedAt), asc(issues.id)),
getConversation: async (companyId: string, agentId: string, userId: string) => db.select().from(issues).where(and(
eq(issues.companyId, companyId), eq(issues.conversationAgentId, agentId), eq(issues.conversationUserId, userId),
)).then((rows) => rows[0] ?? null),
create: async (
companyId: string,
data: IssueCreateInput,
dbOrTx: Db | DbTransaction = db,
) => {
const {
initialPlan,
labelIds: inputLabelIds,
blockedByIssueIds,
inheritExecutionWorkspaceFromIssueId,
skipExecutionWorkspaceInheritance,
watchdog,
watchdogActorRunId,
actorRunId,
actorResponsibleUserId,
trustExplicitResponsibleUserId,
idempotencyKey: rawIdempotencyKey,
allowDuplicate,
assertCanReuseIssue,
onDeduplicated,
...issueData
} = data;
const explicitTitle = issueData.title?.trim();
const provisionalTitle = issueData.description
? provisionalTitleFromDescription(issueData.description)
: undefined;
const resolvedTitle = explicitTitle || provisionalTitle;
if (!resolvedTitle) throw unprocessable("Provide a title or task description");
const titleNeedsGeneration = !explicitTitle;
// A prompt prefix is not a task identity: distinct requests can share it.
const deduplicateByTitle = allowDuplicate === false && !titleNeedsGeneration;
issueData.title = resolvedTitle;
const isolatedWorkspacesEnabled = (
await instanceSettings.getExperimental()
).enableIsolatedWorkspaces;
if (!isolatedWorkspacesEnabled) {
delete issueData.executionWorkspaceId;
delete issueData.executionWorkspacePreference;
delete issueData.executionWorkspaceSettings;
}
if (data.assigneeAgentId && data.assigneeUserId) {
throw unprocessable("Issue can only have one assignee");
}
if (data.assigneeAgentId) {
await assertAssignableAgent(db, companyId, data.assigneeAgentId, {
kind: "work",
});
}
if (data.assigneeUserId) {
await assertAssignableUser(companyId, data.assigneeUserId);
}
if (
data.status === "in_progress" &&
!data.assigneeAgentId &&
!data.assigneeUserId
) {
throw unprocessable("in_progress issues require an assignee");
}
const persist = async (tx: DbTransaction) => {
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
if (issueData.conversationAgentId && issueData.conversationUserId) {
const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`;
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${identity}, 0))`);
const [existing] = await tx.select().from(issues).where(and(eq(issues.companyId, companyId),
eq(issues.conversationAgentId, issueData.conversationAgentId), eq(issues.conversationUserId, issueData.conversationUserId)));
if (existing) {
const [enriched] = await withIssueLabels(tx, [existing]);
const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx);
return withRelations;
}
}
const idempotencyKey = rawIdempotencyKey?.trim() || null;
const normalizedTitle = normalizeCreateIssueTitle(resolvedTitle);
if (deduplicateByTitle) {
const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`,
);
}
if (idempotencyKey) {
const idempotencyGuardKey = `issue-create:idempotency:${companyId}:${idempotencyKey}`;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${idempotencyGuardKey}, 0))`,
);
}
let existingIssue: typeof issues.$inferSelect | undefined;
let deduplicationReason:
"idempotency_key" | "recent_open_title" | null = null;
if (idempotencyKey) {
const idempotencyKeyRetentionCutoff = new Date(
Date.now() - ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS,
);
await tx.execute(sql`
delete from ${issueCreateIdempotencyKeys}
where ${issueCreateIdempotencyKeys.id} in (
select ${issueCreateIdempotencyKeys.id}
from ${issueCreateIdempotencyKeys}
where ${issueCreateIdempotencyKeys.companyId} = ${companyId}
and ${issueCreateIdempotencyKeys.createdAt} < ${idempotencyKeyRetentionCutoff.toISOString()}::timestamptz
order by ${issueCreateIdempotencyKeys.createdAt} asc, ${issueCreateIdempotencyKeys.id} asc
limit ${ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE}
)
`);
[existingIssue] = await tx
.select()
.from(issueCreateIdempotencyKeys)
.innerJoin(
issues,
eq(issueCreateIdempotencyKeys.issueId, issues.id),
)
.where(
and(
eq(issueCreateIdempotencyKeys.companyId, companyId),
eq(issueCreateIdempotencyKeys.idempotencyKey, idempotencyKey),
),
)
.limit(1)
.then((rows) => rows.map((row) => row.issues));
if (existingIssue) deduplicationReason = "idempotency_key";
}
if (!existingIssue && deduplicateByTitle) {
[existingIssue] = await tx
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
issueData.parentId
? eq(issues.parentId, issueData.parentId)
: isNull(issues.parentId),
isNull(issues.hiddenAt),
notInArray(issues.status, ["done", "cancelled"]),
gte(
issues.createdAt,
new Date(Date.now() - 48 * 60 * 60 * 1000),
),
sql`lower(regexp_replace(btrim(${issues.title}), '\\s+', ' ', 'g')) = ${normalizedTitle}`,
),
)
.orderBy(asc(issues.createdAt), asc(issues.id))
.limit(1);
if (existingIssue) deduplicationReason = "recent_open_title";
}
if (existingIssue) {
// A duplicate may have a different scope or assignee than the proposed task.
await assertCanReuseIssue?.(existingIssue);
if (idempotencyKey) {
await tx
.insert(issueCreateIdempotencyKeys)
.values({ companyId, idempotencyKey, issueId: existingIssue.id })
.onConflictDoNothing();
}
if (deduplicationReason) onDeduplicated?.(deduplicationReason);
const [enriched] = await withIssueLabels(tx, [existingIssue]);
const [withRelations] = await withIssueRelationSummaries(
companyId,
[enriched],
tx,
);
return withRelations;
}
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
let executionWorkspaceId = issueData.executionWorkspaceId ?? null;
let executionWorkspacePreference =
issueData.executionWorkspacePreference ?? null;
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as
Record<string, unknown> | null | undefined) ?? null;
const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance
? null
: (inheritExecutionWorkspaceFromIssueId ??
issueData.parentId ??
null);
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||
issueData.executionWorkspaceSettings !== undefined;
if (workspaceInheritanceIssueId) {
const workspaceSource = await getWorkspaceInheritanceIssue(
tx,
companyId,
workspaceInheritanceIssueId,
);
if (issueData.projectId == null && workspaceSource.projectId) {
issueData.projectId = workspaceSource.projectId;
}
// Workspace linkage is only inheritable inside the source project. A
// cross-project child (for example, a Paperclip ID issue created from
// a Paperclip App parent) must fall through to its own project's
// default workspaces, otherwise the inherited ids fail the
// project-match assertions below and the create is impossible without
// the caller naming the target workspaces explicitly.
const inheritsSourceProject =
issueData.projectId == null ||
issueData.projectId === workspaceSource.projectId;
if (
inheritsSourceProject &&
projectWorkspaceId == null &&
workspaceSource.projectWorkspaceId
) {
projectWorkspaceId = workspaceSource.projectWorkspaceId;
}
if (
inheritsSourceProject &&
isolatedWorkspacesEnabled &&
!hasExplicitExecutionWorkspaceOverride &&
workspaceSource.executionWorkspaceId
) {
const sourceWorkspace = await tx
.select({
id: executionWorkspaces.id,
mode: executionWorkspaces.mode,
})
.from(executionWorkspaces)
.where(
eq(
executionWorkspaces.id,
workspaceSource.executionWorkspaceId,
),
)
.then((rows) => rows[0] ?? null);
if (sourceWorkspace) {
executionWorkspaceId = sourceWorkspace.id;
executionWorkspacePreference = "reuse_existing";
executionWorkspaceSettings = {
...((workspaceSource.executionWorkspaceSettings as
Record<string, unknown> | null | undefined) ?? {}),
mode: issueExecutionWorkspaceModeForPersistedWorkspace(
sourceWorkspace.mode,
),
};
}
}
}
if (issueData.projectId == null && projectWorkspaceId) {
const workspace = await assertValidProjectWorkspace(
companyId,
null,
projectWorkspaceId,
tx,
);
issueData.projectId = workspace.projectId;
}
if (issueData.projectId == null && executionWorkspaceId) {
const workspace = await assertValidExecutionWorkspace(
companyId,
null,
executionWorkspaceId,
tx,
);
issueData.projectId = workspace.projectId;
}
const projectGoalId = await getProjectDefaultGoalId(
tx,
companyId,
issueData.projectId,
);
// Cache the project policy lookup for this insert so the default
// workspace-settings block does not re-query the project row.
let projectPolicyCached: ReturnType<
typeof parseProjectExecutionWorkspacePolicy
> | null = null;
let projectPolicyLoaded = false;
const loadProjectPolicyOnce = async () => {
if (projectPolicyLoaded) return projectPolicyCached;
projectPolicyLoaded = true;
if (!issueData.projectId) return null;
const projectRow = await tx
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(
and(
eq(projects.id, issueData.projectId),
eq(projects.companyId, companyId),
),
)
.then((rows) => rows[0] ?? null);
projectPolicyCached = parseProjectExecutionWorkspacePolicy(
projectRow?.executionWorkspacePolicy,
);
return projectPolicyCached;
};
if (
executionWorkspaceSettings == null &&
executionWorkspaceId == null &&
issueData.projectId
) {
executionWorkspaceSettings =
defaultIssueExecutionWorkspaceSettingsForProject(
gateProjectExecutionWorkspacePolicy(
await loadProjectPolicyOnce(),
isolatedWorkspacesEnabled,
),
) as Record<string, unknown> | null;
}
if (!projectWorkspaceId && issueData.projectId) {
const project = await tx
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(
and(
eq(projects.id, issueData.projectId),
eq(projects.companyId, companyId),
),
)
.then((rows) => rows[0] ?? null);
const projectPolicy = parseProjectExecutionWorkspacePolicy(
project?.executionWorkspacePolicy,
);
projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
if (!projectWorkspaceId) {
projectWorkspaceId = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.projectId, issueData.projectId),
eq(projectWorkspaces.companyId, companyId),
),
)
.orderBy(
desc(projectWorkspaces.isPrimary),
asc(projectWorkspaces.createdAt),
asc(projectWorkspaces.id),
)
.then((rows) => rows[0]?.id ?? null);
}
}
if (projectWorkspaceId) {
await assertValidProjectWorkspace(
companyId,
issueData.projectId,
projectWorkspaceId,
tx,
);
}
if (executionWorkspaceId) {
await assertValidExecutionWorkspace(
companyId,
issueData.projectId,
executionWorkspaceId,
tx,
);
}
if (
isolatedWorkspacesEnabled &&
issueData.executionWorkspaceSettings !== undefined
) {
assertExplicitPinnedWorktreeIssueRunnable({
projectId: issueData.projectId ?? null,
projectWorkspaceId,
executionWorkspaceId,
executionWorkspacePreference,
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
});
}
// Self-correcting counter: use MAX(issue_number) + 1 if the counter
// has drifted below the actual max, preventing identifier collisions.
const [maxRow] = await tx
.select({
maxNum: sql<number>`coalesce(max(${issues.issueNumber}), 0)`,
})
.from(issues)
.where(eq(issues.companyId, companyId));
const currentMax = maxRow?.maxNum ?? 0;
const [company] = await tx
.update(companies)
.set({
issueCounter: sql`greatest(${companies.issueCounter}, ${currentMax}) + 1`,
})
.where(eq(companies.id, companyId))
.returning({
issueCounter: companies.issueCounter,
issuePrefix: companies.issuePrefix,
});
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(
tx,
companyId,
{
explicitResponsibleUserId: issueData.responsibleUserId ?? null,
createdByUserId: issueData.createdByUserId ?? null,
parentId: issueData.parentId ?? null,
originKind: issueData.originKind ?? "manual",
originRunId: issueData.originRunId ?? null,
actorRunId: actorRunId ?? null,
actorResponsibleUserId: actorResponsibleUserId ?? null,
trustExplicitResponsibleUserId:
trustExplicitResponsibleUserId === true,
},
);
const values = {
...issueData,
titleNeedsGeneration,
originRunId: issueData.originRunId ?? actorRunId ?? null,
responsibleUserId,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
projectGoalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(executionWorkspaceId ? { executionWorkspaceId } : {}),
...(executionWorkspacePreference
? { executionWorkspacePreference }
: {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
identifier,
} as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date();
}
if (values.status === "done") {
values.completedAt = new Date();
}
if (values.status === "cancelled") {
values.cancelledAt = new Date();
}
Object.assign(
values,
buildInitialIssueMonitorFields({
policy: normalizeIssueExecutionPolicy(
issueData.executionPolicy ?? null,
),
status: values.status ?? "backlog",
assigneeAgentId: values.assigneeAgentId ?? null,
assigneeUserId: values.assigneeUserId ?? null,
}),
);
const [issue] = await tx.insert(issues).values(values).returning();
await recordChatHandoff(tx, issue, actorRunId);
if (idempotencyKey) {
await tx.insert(issueCreateIdempotencyKeys).values({
companyId,
idempotencyKey,
issueId: issue.id,
});
}
if (watchdog) {
await upsertIssueWatchdogForIssue(tx, companyId, issue.id, {
agentId: watchdog.agentId,
instructions: watchdog.instructions,
actor: {
agentId: issueData.createdByAgentId ?? null,
userId: issueData.createdByUserId ?? null,
runId: watchdogActorRunId ?? null,
},
});
}
if (inputLabelIds) {
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
}
if (blockedByIssueIds !== undefined) {
await syncBlockedByIssueIds(
issue.id,
companyId,
blockedByIssueIds,
{
agentId: issueData.createdByAgentId ?? null,
userId: issueData.createdByUserId ?? null,
},
tx,
);
}
if (initialPlan?.trim()) {
await documentService(tx as unknown as Db).upsertIssueDocument({
issueId: issue.id, key: "plan", title: "Plan", format: "markdown", body: initialPlan,
createdByAgentId: issueData.createdByAgentId, createdByUserId: issueData.createdByUserId,
createdByRunId: actorRunId,
sourceTrust: issue.sourceTrust,
});
}
const [enriched] = await withIssueLabels(tx, [issue]);
const [withRelations] = await withIssueRelationSummaries(
companyId,
[enriched],
tx,
);
return withRelations;
};
if (dbOrTx === db) return db.transaction(persist);
return persist(dbOrTx as DbTransaction);
},
/**
* Batched issue insert for company import.
*
* Company import used to call {@link create} once per issue — each call a
* separate network round-trip that inserted the issue, then serialized the
* issue's comments/documents behind the returned id. This inserts a whole
* bundle of pre-resolved issues (ids already generated by the caller) in
* chunked multi-row statements, so a thousand-issue import issues a handful
* of statements instead of thousands.
*
* The per-issue derivations {@link create} performs are reproduced for the
* import subset: a single contiguous identifier range is allocated from the
* company counter, per-project goal/workspace/policy defaults are computed
* once and cached, assignable-agent and workspace validation run per
* distinct id, and monitor notes land un-armed on the row. Dedup,
* idempotency, watchdogs, workspace inheritance and blocked-by wiring — none
* of which import uses — are intentionally omitted.
*/
importIssues: async (
companyId: string,
rows: ImportIssueRow[],
): Promise<void> => {
if (rows.length === 0) return;
const isolatedWorkspacesEnabled = (
await instanceSettings.getExperimental()
).enableIsolatedWorkspaces;
await db.transaction(async (tx) => {
// Self-correcting counter: seed from max(issue_number) so a drifted
// company counter cannot mint colliding identifiers, then reserve the
// whole range in one bump instead of one-per-issue.
const [maxRow] = await tx
.select({
maxNum: sql<number>`coalesce(max(${issues.issueNumber}), 0)`,
})
.from(issues)
.where(eq(issues.companyId, companyId));
const currentMax = maxRow?.maxNum ?? 0;
const [company] = await tx
.select({
issueCounter: companies.issueCounter,
issuePrefix: companies.issuePrefix,
})
.from(companies)
.where(eq(companies.id, companyId));
if (!company) throw notFound("Target company not found");
const base = Math.max(company.issueCounter ?? 0, currentMax);
await tx
.update(companies)
.set({ issueCounter: base + rows.length })
.where(eq(companies.id, companyId));
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
const defaultGoalId = defaultCompanyGoal?.id ?? null;
// Project-scoped derivations depend only on the project, so resolve each
// distinct project once and reuse it across that project's issues.
const projectDerivedCache = new Map<
string,
{
goalId: string | null;
defaultProjectWorkspaceId: string | null;
defaultExecutionWorkspaceSettings: Record<string, unknown> | null;
}
>();
const loadProjectDerived = async (projectId: string) => {
const cached = projectDerivedCache.get(projectId);
if (cached) return cached;
const projectRow = await tx
.select({
goalId: projects.goalId,
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.companyId, companyId),
),
)
.then((r) => r[0] ?? null);
const policy = parseProjectExecutionWorkspacePolicy(
projectRow?.executionWorkspacePolicy,
);
let defaultProjectWorkspaceId =
policy?.defaultProjectWorkspaceId ?? null;
if (!defaultProjectWorkspaceId) {
defaultProjectWorkspaceId = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.projectId, projectId),
eq(projectWorkspaces.companyId, companyId),
),
)
.orderBy(
desc(projectWorkspaces.isPrimary),
asc(projectWorkspaces.createdAt),
asc(projectWorkspaces.id),
)
.then((r) => r[0]?.id ?? null);
}
const defaultExecutionWorkspaceSettings =
defaultIssueExecutionWorkspaceSettingsForProject(
gateProjectExecutionWorkspacePolicy(
policy,
isolatedWorkspacesEnabled,
),
) as Record<string, unknown> | null;
const derived = {
goalId: projectRow?.goalId ?? null,
defaultProjectWorkspaceId,
defaultExecutionWorkspaceSettings,
};
projectDerivedCache.set(projectId, derived);
return derived;
};
const validatedAgentIds = new Set<string>();
const validatedWorkspaceKeys = new Set<string>();
const issueRows: Array<Record<string, unknown>> = [];
const labelRows: Array<{
issueId: string;
labelId: string;
companyId: string;
}> = [];
let counter = base;
for (const row of rows) {
await assertExecutionTaskParent(tx as unknown as Db, companyId, row.parentId);
counter += 1;
const issueNumber = counter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
if (row.assigneeAgentId) {
if (!validatedAgentIds.has(row.assigneeAgentId)) {
await assertAssignableAgent(
tx as unknown as Db,
companyId,
row.assigneeAgentId,
{ kind: "work" },
);
validatedAgentIds.add(row.assigneeAgentId);
}
}
if (row.status === "in_progress" && !row.assigneeAgentId) {
throw unprocessable("in_progress issues require an assignee");
}
const projectId = row.projectId ?? null;
let projectWorkspaceId = row.projectWorkspaceId ?? null;
// Isolated-workspace fields are gated the same way create() gates them:
// when the experiment is off the imported settings are dropped.
let executionWorkspaceSettings = isolatedWorkspacesEnabled
? (row.executionWorkspaceSettings ?? null)
: null;
let projectGoalId: string | null = null;
if (projectId) {
const derived = await loadProjectDerived(projectId);
projectGoalId = derived.goalId;
if (!projectWorkspaceId)
projectWorkspaceId = derived.defaultProjectWorkspaceId;
if (executionWorkspaceSettings == null) {
executionWorkspaceSettings =
derived.defaultExecutionWorkspaceSettings;
}
}
if (projectWorkspaceId) {
const workspaceKey = `${projectId ?? ""}:${projectWorkspaceId}`;
if (!validatedWorkspaceKeys.has(workspaceKey)) {
await assertValidProjectWorkspace(
companyId,
projectId,
projectWorkspaceId,
tx,
);
validatedWorkspaceKeys.add(workspaceKey);
}
}
const goalId = resolveIssueGoalId({
projectId,
goalId: null,
projectGoalId,
defaultGoalId,
});
issueRows.push({
id: row.id,
companyId,
issueNumber,
identifier,
title: row.title,
description: row.description ?? null,
assigneeAgentId: row.assigneeAgentId ?? null,
status: row.status,
priority: row.priority,
billingCode: row.billingCode ?? null,
assigneeAdapterOverrides: row.assigneeAdapterOverrides ?? null,
projectId,
projectWorkspaceId,
executionWorkspaceSettings,
goalId,
responsibleUserId: null,
requestDepth: clampIssueRequestDepth(undefined),
originKind: "manual",
// The caller resolves parentId against ids in this same batch, so a
// parent always lands in the same insert (rows arrive parents-first).
parentId: row.parentId ?? null,
// Preserved bundle timestamps win; without them createdAt/updatedAt
// fall back to the insert time (the old defaultNow() behavior).
createdAt: row.createdAt ?? new Date(),
updatedAt: row.updatedAt ?? new Date(),
// Imported in-progress work did not start at import time; fabricating
// startedAt here would misrepresent its active episode.
// Only a bundle-carried startedAt is written.
startedAt: row.startedAt ?? null,
completedAt:
row.completedAt ?? (row.status === "done" ? new Date() : null),
cancelledAt:
row.cancelledAt ??
(row.status === "cancelled" ? new Date() : null),
monitorNotes: row.monitorNotes ?? null,
monitorScheduledBy: row.monitorScheduledBy ?? null,
});
for (const labelId of new Set(row.labelIds ?? [])) {
labelRows.push({ issueId: row.id, labelId, companyId });
}
}
await insertRowsInChunks(tx, issues, issueRows);
await insertRowsInChunks(tx, issueLabels, labelRows);
});
},
/**
* Batched comment insert for company import. Comment ids are pre-generated
* by the caller so attachments can reference them without a round-trip.
*/
addImportedComments: async (
rows: ImportIssueCommentRow[],
): Promise<void> => {
if (rows.length === 0) return;
const censorUsernameInLogs = (await instanceSettings.getGeneral())
.censorUsernameInLogs;
await db.transaction(async (tx) => {
const commentRows = rows.map((row) => {
const createdAt = row.createdAt ? new Date(row.createdAt) : null;
return {
id: row.id,
companyId: row.companyId,
issueId: row.issueId,
authorAgentId: row.authorAgentId ?? null,
authorUserId: row.authorUserId ?? null,
authorType: row.authorType,
createdByRunId: null,
body: redactCurrentUserText(row.body, {
enabled: censorUsernameInLogs,
}),
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
sourceTrust: null,
createdAt:
createdAt && !Number.isNaN(createdAt.getTime())
? createdAt
: new Date(),
};
});
await insertRowsInChunks(tx, issueComments, commentRows);
// Mirror addComment's recency bump, once per affected issue — but never
// backwards. An issue imported with a preserved updatedAt keeps it
// unless a newer imported comment outdates it; issues without preserved
// timestamps carry an insert-time updatedAt, so GREATEST reproduces the
// old "bump to now" behavior for them.
const bumpAtByIssueId = new Map<string, Date>();
for (const row of commentRows) {
const existing = bumpAtByIssueId.get(row.issueId);
if (!existing || row.createdAt > existing)
bumpAtByIssueId.set(row.issueId, row.createdAt);
}
const bumpEntries = [...bumpAtByIssueId.entries()];
for (
let start = 0;
start < bumpEntries.length;
start += DEFAULT_INSERT_CHUNK_ROWS
) {
const chunk = bumpEntries.slice(
start,
start + DEFAULT_INSERT_CHUNK_ROWS,
);
await tx.execute(sql`
update ${issues}
set updated_at = greatest(${issues.updatedAt}, bumps.bump_at)
from (values ${sql.join(
chunk.map(
([issueId, bumpAt]) =>
sql`(${issueId}::uuid, ${bumpAt.toISOString()}::timestamptz)`,
),
sql`, `,
)}) as bumps(issue_id, bump_at)
where ${issues.id} = bumps.issue_id
`);
}
});
},
/**
* Batched attachment insert for company import: each row mints an asset and
* links it to its issue (and optionally comment) in two chunked statements.
*/
addImportedAttachments: async (
rows: ImportIssueAttachmentRow[],
): Promise<void> => {
if (rows.length === 0) return;
await db.transaction(async (tx) => {
const assetRows: Array<Record<string, unknown>> = [];
const attachmentRows: Array<Record<string, unknown>> = [];
for (const row of rows) {
const assetId = randomUUID();
assetRows.push({
id: assetId,
companyId: row.companyId,
provider: row.provider,
objectKey: row.objectKey,
contentType: row.contentType,
byteSize: row.byteSize,
sha256: row.sha256,
originalFilename: row.originalFilename ?? null,
createdByAgentId: row.createdByAgentId ?? null,
createdByUserId: row.createdByUserId ?? null,
});
attachmentRows.push({
companyId: row.companyId,
issueId: row.issueId,
assetId,
issueCommentId: row.issueCommentId ?? null,
});
}
await insertRowsInChunks(tx, assets, assetRows);
await insertRowsInChunks(tx, issueAttachments, attachmentRows);
});
},
update: async (
id: string,
data: Partial<typeof issues.$inferInsert> & {
labelIds?: string[];
blockedByIssueIds?: string[];
actorAgentId?: string | null;
actorRunId?: string | null;
actorRunStopId?: string | null;
actorUserId?: string | null;
companyGuard?: string;
},
dbOrTx: any = db,
postCommitActivityPublications?: ActivityPublication[],
postCommitActions?: IssuePostCommitAction[],
options: { bindRuntimeSharedWorkspace?: boolean } = {},
) => {
const ownedActivityPublications: ActivityPublication[] = [];
const activityPublications =
postCommitActivityPublications ?? ownedActivityPublications;
const ownedPostCommitActions: IssuePostCommitAction[] = [];
const queuedPostCommitActions =
postCommitActions ?? ownedPostCommitActions;
// A caller that supplies `companyGuard` gets the company added to
// every read, lock, and write predicate below. A check before this
// call is not a boundary: `issues.company_id` can change between
// that check and this write, so the predicate must carry the
// company itself.
const idPredicate =
data.companyGuard !== undefined
? and(eq(issues.id, id), eq(issues.companyId, data.companyGuard))
: eq(issues.id, id);
const existing = await dbOrTx
.select()
.from(issues)
.where(idPredicate)
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!existing) return null;
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
await assertExecutionTaskParent(dbOrTx, existing.companyId, data.parentId);
}
if (existing.conversationAgentId) {
if ((data.assigneeAgentId !== undefined && data.assigneeAgentId !== existing.conversationAgentId)
|| data.assigneeUserId || data.conversationAgentId !== undefined || data.conversationUserId !== undefined
|| data.conversationState !== undefined || data.conversationSessionGeneration !== undefined
|| data.conversationBoundaryCommentId !== undefined || data.status === "done" || data.status === "cancelled") {
throw unprocessable("Conversation identity is fixed; finish the reply instead of completing or reassigning the conversation");
}
}
const {
labelIds: nextLabelIds,
blockedByIssueIds,
actorAgentId,
actorRunId,
actorRunStopId,
actorUserId,
companyGuard,
...issueData
} = data;
// An explicit edit claims the title, even if it keeps the same text.
if (issueData.title !== undefined) issueData.titleNeedsGeneration = false;
if (
issueData.assigneeAgentId !== undefined &&
issueData.assigneeAgentId !== existing.assigneeAgentId
) {
const externalBinding = await dbOrTx
.select({ id: chatConversations.id })
.from(chatConversations)
.where(
and(
eq(chatConversations.companyId, existing.companyId),
eq(chatConversations.issueId, existing.id),
),
)
.limit(1)
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
if (externalBinding) {
throw conflict(
"Agent assignment cannot change while this task is bound to an external channel",
{
code: "chat_binding_agent_locked",
conversationId: externalBinding.id,
},
);
}
}
const isolatedWorkspacesEnabled = (
await instanceSettings.getExperimental()
).enableIsolatedWorkspaces;
if (options.bindRuntimeSharedWorkspace) {
const workspaceId = issueData.executionWorkspaceId ?? existing.executionWorkspaceId;
if (!workspaceId) {
throw unprocessable("Runtime workspace binding requires an existing shared workspace");
}
const [workspace] = await dbOrTx
.select({ mode: executionWorkspaces.mode })
.from(executionWorkspaces)
.where(and(
eq(executionWorkspaces.id, workspaceId),
eq(executionWorkspaces.companyId, existing.companyId),
));
if (
workspace?.mode !== "shared_workspace" ||
(issueData.executionWorkspacePreference ?? existing.executionWorkspacePreference) !== "reuse_existing" ||
(issueData.executionWorkspaceSettings ?? existing.executionWorkspaceSettings)?.mode !== "shared_workspace"
) {
throw unprocessable("Runtime workspace binding requires an existing shared workspace");
}
}
// Warm sandbox continuity is runtime bookkeeping, independent of the
// opt-in UI for creating isolated worktrees. Public updates still obey
// the feature gate; only the internal shared-workspace binding bypasses it.
if (!isolatedWorkspacesEnabled && !options.bindRuntimeSharedWorkspace) {
delete issueData.executionWorkspaceId;
delete issueData.executionWorkspacePreference;
delete issueData.executionWorkspaceSettings;
}
if (issueData.status) {
assertTransition(existing.status, issueData.status);
}
const patch: Partial<typeof issues.$inferInsert> = {
...issueData,
updatedAt: new Date(),
};
if (existing.status !== "blocked" && issueData.status === "blocked") {
patch.blockedTransitionAt = patch.updatedAt;
patch.blockedOwnerNotifiedAt = null;
} else if (
existing.status === "blocked" &&
issueData.status &&
issueData.status !== "blocked"
) {
patch.unblockDescriptor = null;
patch.blockedTransitionAt = null;
patch.blockedOwnerNotifiedAt = null;
}
if (issueData.requestDepth !== undefined) {
patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth);
}
const nextAssigneeAgentId =
issueData.assigneeAgentId !== undefined
? issueData.assigneeAgentId
: existing.assigneeAgentId;
const nextAssigneeUserId =
issueData.assigneeUserId !== undefined
? issueData.assigneeUserId
: existing.assigneeUserId;
if (nextAssigneeAgentId && nextAssigneeUserId) {
throw unprocessable("Issue can only have one assignee");
}
if (
patch.status === "in_progress" &&
!nextAssigneeAgentId &&
!nextAssigneeUserId
) {
throw unprocessable("in_progress issues require an assignee");
}
if (patch.status === "in_progress") {
const dependencyReadiness =
blockedByIssueIds === undefined
? (
await listIssueDependencyReadinessMap(
dbOrTx,
existing.companyId,
[id],
)
).get(id)
: null;
const unresolvedBlockerIssueIds =
blockedByIssueIds !== undefined
? await listUnresolvedBlockerIssueIds(
dbOrTx,
existing.companyId,
blockedByIssueIds,
)
: (dependencyReadiness?.unresolvedBlockerIssueIds ?? []);
if (unresolvedBlockerIssueIds.length > 0) {
const unresolvedBlockers = await listUnresolvedBlockerDetails(
dbOrTx,
existing.companyId,
unresolvedBlockerIssueIds,
dependencyReadiness?.pendingFinalizeBlockerIssueIds,
);
throw unprocessable("Issue is blocked by unresolved blockers", {
unresolvedBlockerIssueIds,
unresolvedBlockers,
});
}
}
const shouldValidateNextAssignee =
Boolean(nextAssigneeAgentId) &&
(issueData.assigneeAgentId !== undefined ||
patch.status === "in_progress");
if (shouldValidateNextAssignee) {
await assertAssignableAgent(
dbOrTx as Db,
existing.companyId,
nextAssigneeAgentId,
{ kind: "work" },
);
}
if (issueData.assigneeUserId) {
await assertAssignableUser(
existing.companyId,
issueData.assigneeUserId,
);
}
let nextProjectId =
issueData.projectId !== undefined
? issueData.projectId
: existing.projectId;
const nextProjectWorkspaceId =
issueData.projectWorkspaceId !== undefined
? issueData.projectWorkspaceId
: existing.projectWorkspaceId;
const nextExecutionWorkspaceId =
issueData.executionWorkspaceId !== undefined
? issueData.executionWorkspaceId
: existing.executionWorkspaceId;
const nextExecutionWorkspacePreference =
issueData.executionWorkspacePreference !== undefined
? issueData.executionWorkspacePreference
: existing.executionWorkspacePreference;
const nextExecutionWorkspaceSettings =
issueData.executionWorkspaceSettings !== undefined
? parseIssueExecutionWorkspaceSettings(
issueData.executionWorkspaceSettings,
)
: parseIssueExecutionWorkspaceSettings(
existing.executionWorkspaceSettings,
);
if (issueData.executionWorkspaceSettings !== undefined) {
patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings
? { ...nextExecutionWorkspaceSettings }
: null;
}
let validatedProjectWorkspace: { projectId: string } | null = null;
let validatedExecutionWorkspace: { projectId: string } | null = null;
if (!nextProjectId && nextProjectWorkspaceId) {
const workspace = await assertValidProjectWorkspace(
existing.companyId,
null,
nextProjectWorkspaceId,
);
validatedProjectWorkspace = workspace;
nextProjectId = workspace.projectId;
patch.projectId = workspace.projectId;
}
if (!nextProjectId && nextExecutionWorkspaceId) {
const workspace = await assertValidExecutionWorkspace(
existing.companyId,
null,
nextExecutionWorkspaceId,
);
validatedExecutionWorkspace = workspace;
nextProjectId = workspace.projectId;
patch.projectId = workspace.projectId;
}
if (nextProjectWorkspaceId) {
if (!validatedProjectWorkspace) {
await assertValidProjectWorkspace(
existing.companyId,
nextProjectId,
nextProjectWorkspaceId,
);
}
}
if (nextExecutionWorkspaceId) {
if (!validatedExecutionWorkspace) {
await assertValidExecutionWorkspace(
existing.companyId,
nextProjectId,
nextExecutionWorkspaceId,
);
}
}
if (
isolatedWorkspacesEnabled &&
issueData.executionWorkspaceSettings !== undefined
) {
assertExplicitPinnedWorktreeIssueRunnable({
projectId: nextProjectId ?? null,
projectWorkspaceId: nextProjectWorkspaceId ?? null,
executionWorkspaceId: nextExecutionWorkspaceId ?? null,
executionWorkspacePreference:
nextExecutionWorkspacePreference ?? null,
executionWorkspaceSettings: issueData.executionWorkspaceSettings,
});
}
applyStatusSideEffects(issueData.status, patch);
if (issueData.status && issueData.status !== "done") {
patch.completedAt = null;
}
if (issueData.status && issueData.status !== "cancelled") {
patch.cancelledAt = null;
}
if (issueData.status && issueData.status !== "in_progress") {
patch.checkoutRunId = null;
patch.executionRunId = null;
patch.executionAgentNameKey = null;
patch.executionLockedAt = null;
}
if (
(issueData.assigneeAgentId !== undefined &&
issueData.assigneeAgentId !== existing.assigneeAgentId) ||
(issueData.assigneeUserId !== undefined &&
issueData.assigneeUserId !== existing.assigneeUserId)
) {
patch.checkoutRunId = null;
patch.executionRunId = null;
patch.executionAgentNameKey = null;
patch.executionLockedAt = null;
}
const runUpdate = async (tx: any) => {
// The receipt baseline must be read under the same row lock as the
// write. Otherwise a concurrent update can be mistaken for a change
// made by this request.
const receiptExisting = await tx
.select()
.from(issues)
.where(idPredicate)
.for("update")
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!receiptExisting) return null;
if (actorAgentId && actorRunId) {
// Recheck under a run lock: a request admitted before Stop must not
// commit a late Done after cancellation revoked its credentials.
await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, {
agentId: actorAgentId, runId: actorRunId, stopId: actorRunStopId,
});
}
if (actorAgentId && patch.status === "done") {
const [review] = await tx.select({ id: toolActionRequests.id }).from(toolActionRequests).where(and(eq(toolActionRequests.companyId, existing.companyId), eq(toolActionRequests.issueId, id), inArray(toolActionRequests.status, ["pending", "approved", "executing"]))).limit(1);
if (review) throw conflict("This task is waiting for a connection review. Finish unrelated work, then yield in_review without retrying the governed call.", { code: "tool_review_pending", actionRequestId: review.id });
}
const [previousLabelsByIssueId, previousRelationSummaries] = await Promise.all([
nextLabelIds !== undefined
? labelMapForIssues(tx, [id])
: Promise.resolve(new Map<string, IssueLabelRow[]>()),
blockedByIssueIds !== undefined
? getIssueRelationSummaryMap(existing.companyId, [id], tx)
: Promise.resolve(new Map<string, IssueRelationSummaryMap>()),
]);
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
getProjectDefaultGoalId(
tx,
existing.companyId,
issueData.projectId !== undefined
? issueData.projectId
: existing.projectId,
),
]);
patch.goalId = resolveNextIssueGoalId({
currentProjectId: existing.projectId,
currentGoalId: existing.goalId,
currentProjectGoalId,
projectId: issueData.projectId,
goalId: issueData.goalId,
projectGoalId: nextProjectGoalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
});
// Ownership changes invalidate observed handoff versions even if status
// stays the same, including an A -> B -> A assignment race.
if ((issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== receiptExisting.assigneeAgentId)
|| (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== receiptExisting.assigneeUserId)) {
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
// Invalidate human direction at the common assignment boundary, including
// plugin/service writes that do not go through HTTP run cancellation.
// Keep the requester attribution for audit; cancellation revokes its use.
await tx.update(agentWakeupRequests).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(and(eq(agentWakeupRequests.companyId, receiptExisting.companyId),
eq(agentWakeupRequests.requestedByActorType, "user"),
sql`coalesce(${agentWakeupRequests.payload}->>'issueId', ${agentWakeupRequests.payload}->>'taskId',
${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'issueId',
${agentWakeupRequests.payload}->'_paperclipWakeContext'->>'taskId') = ${id}`));
}
// Reasserting Blocked or changing its blockers is a fresh decision even
// when the status string stays the same. Invalidate recovery's prior
// status receipt without treating comment recency as blocking intent.
if (receiptExisting.status === "blocked" &&
(issueData.status === "blocked" ||
(issueData.status === undefined &&
(blockedByIssueIds !== undefined || issueData.unblockDescriptor !== undefined)))) {
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
}
const updated = await tx
.update(issues)
.set(patch)
.where(idPredicate)
.returning()
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!updated) return null;
await recordChatCompletion(tx, receiptExisting, updated);
// An operator explicitly choosing a disposition owns that decision,
// including choosing In Review while the conversation is Idle.
if (actorUserId && issueData.status !== undefined) {
await tx.update(chatConversations).set({ state: "active", updatedAt: new Date() })
.where(and(eq(chatConversations.companyId, updated.companyId), eq(chatConversations.issueId, updated.id),
eq(chatConversations.state, "waiting"), sql`exists (select 1 from chat_endpoints e
where e.id = ${chatConversations.endpointId} and e.company_id = ${chatConversations.companyId} and e.provider = 'slack')`));
}
if (updated.assigneeAgentId !== existing.assigneeAgentId || updated.assigneeUserId !== existing.assigneeUserId) {
const { issueThreadInteractionService } = await import("./issue-thread-interactions.js");
await issueThreadInteractionService(tx).expireConnectionIntentsForOwnershipChange(updated);
}
if (existing.status !== updated.status) {
if (
(existing.status === "done" || existing.status === "cancelled") &&
updated.status !== "done" &&
updated.status !== "cancelled"
) {
const terminalWorkspaces = await tx
.select({ id: executionWorkspaces.id })
.from(executionWorkspaces)
.where(
and(
eq(executionWorkspaces.companyId, updated.companyId),
eq(executionWorkspaces.sourceIssueId, updated.id),
eq(executionWorkspaces.status, "archived"),
like(executionWorkspaces.cleanupReason, "issue_terminal%"),
),
);
for (const workspace of terminalWorkspaces) {
await logActivity(tx as unknown as Db, {
companyId: updated.companyId,
actorType: actorAgentId
? "agent"
: actorUserId
? "user"
: "system",
actorId: actorAgentId ?? actorUserId ?? "issue_service",
agentId: actorAgentId ?? null,
action: "execution_workspace.source_issue_reopened",
entityType: "execution_workspace",
entityId: workspace.id,
details: {
sourceIssueId: updated.id,
previousIssueStatus: existing.status,
nextIssueStatus: updated.status,
workspaceAction: "left_archived",
},
});
}
}
if (updated.status === "done" || updated.status === "cancelled") {
await finalizeSummarySlotsForTerminalIssue(tx, updated);
// Every terminal transition funnels through here, including direct
// service callers (tree control, recovery, pipelines, status cards)
// that never touch the HTTP routes, so pending interaction cards
// cannot outlive their issue. Dynamic import breaks the module
// cycle (issue-thread-interactions.js imports issueService).
const { issueThreadInteractionService } =
await import("./issue-thread-interactions.js");
const expiredInteractions = await issueThreadInteractionService(
tx,
).expirePendingInteractionsForTerminalIssue(updated, {
agentId: actorAgentId ?? null,
userId: actorUserId ?? null,
});
const {
nativeQuestionCancellationIdentity,
requestNativeQuestionRunCancellation,
} = await import("./native-runtime/native-question-bridge.js");
for (const interaction of expiredInteractions) {
if (interaction.kind === "ask_user_questions") {
const nativeQuestion =
nativeQuestionCancellationIdentity(interaction);
if (nativeQuestion) {
if (dbOrTx !== db && !postCommitActions) {
throw new Error(
"Terminal native question updates in an external transaction require a post-commit action queue",
);
}
const runId = await requestNativeQuestionRunCancellation(
tx,
nativeQuestion,
{ kind: "issue_terminal", issueStatus: updated.status },
);
if (runId) {
queuedPostCommitActions.push({
type: "cancel_native_question_run",
runId,
issueId: updated.id,
issueStatus: updated.status,
});
}
}
}
await logActivity(tx as unknown as Db, {
companyId: updated.companyId,
actorType: actorAgentId
? "agent"
: actorUserId
? "user"
: "system",
actorId: actorAgentId ?? actorUserId ?? "issue_service",
agentId: actorAgentId ?? null,
action: "issue.thread_interaction_expired",
entityType: "issue",
entityId: updated.id,
details: {
identifier: updated.identifier ?? null,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
source: "issue.status_transition.issue_closed",
result: interaction.result ?? null,
},
});
}
}
// A status-card generation task that goes done/cancelled/blocked stops
// making progress; release the card's generation claim so the board tile
// stops spinning and offers "Run now" again (blocked = stuck on a human).
if (
updated.status === "done" ||
updated.status === "cancelled" ||
updated.status === "blocked"
) {
await finalizeStatusCardsForStalledGeneration(tx, updated);
}
}
if (nextLabelIds !== undefined) {
await syncIssueLabels(
updated.id,
existing.companyId,
nextLabelIds,
tx,
);
}
if (blockedByIssueIds !== undefined) {
await syncBlockedByIssueIds(
updated.id,
existing.companyId,
blockedByIssueIds,
{
agentId: actorAgentId ?? null,
userId: actorUserId ?? null,
},
tx,
);
}
if (
issueData.executionWorkspaceSettings !== undefined &&
nextExecutionWorkspaceId &&
nextExecutionWorkspacePreference === "reuse_existing"
) {
const workspace = await tx
.select({
id: executionWorkspaces.id,
metadata: executionWorkspaces.metadata,
})
.from(executionWorkspaces)
.where(
and(
eq(executionWorkspaces.id, nextExecutionWorkspaceId),
eq(executionWorkspaces.companyId, existing.companyId),
),
)
.then(
(rows: Array<{ id: string; metadata: unknown }>) =>
rows[0] ?? null,
);
if (workspace) {
await tx
.update(executionWorkspaces)
.set({
metadata: mergeExecutionWorkspaceConfig(
(workspace.metadata as Record<string, unknown> | null) ??
null,
buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
nextExecutionWorkspaceSettings,
),
),
updatedAt: new Date(),
})
.where(eq(executionWorkspaces.id, workspace.id));
}
}
const [enriched] = await withIssueLabels(tx, [updated]);
const nextBlockedByIssueIds =
blockedByIssueIds === undefined
? undefined
: [...new Set(blockedByIssueIds)].sort();
const changes = buildIssueChanges(
receiptExisting as unknown as Record<string, unknown>,
updated as unknown as Record<string, unknown>,
{
...(nextLabelIds !== undefined
? {
labelIds: {
from: (previousLabelsByIssueId.get(id) ?? []).map(
(label) => label.id,
),
to: enriched.labelIds,
},
}
: {}),
...(nextBlockedByIssueIds !== undefined
? {
blockedByIssueIds: {
from: (
previousRelationSummaries.get(id)?.blockedBy ?? []
).map((relation) => relation.id),
to: nextBlockedByIssueIds,
},
}
: {}),
},
);
if (
(issueData.status === "done" || issueData.status === "cancelled") &&
existing.status !== issueData.status &&
existing.originKind ===
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation
) {
const parsedIncident = parseIssueGraphLivenessIncidentKey(
existing.originId,
);
if (
parsedIncident?.issueId &&
parsedIncident.companyId === existing.companyId
) {
await tx
.delete(issueRelations)
.where(
and(
eq(issueRelations.companyId, existing.companyId),
eq(issueRelations.issueId, existing.id),
eq(issueRelations.relatedIssueId, parsedIncident.issueId),
eq(issueRelations.type, "blocks"),
),
);
}
}
if (
actorUserId &&
receiptExisting.status !== "done" &&
updated.status === "done"
) {
if (dbOrTx !== db && !postCommitActivityPublications) {
throw new Error(
"Human completion in an external transaction requires a post-commit activity queue",
);
}
const now = new Date();
const archiveState = await archiveInbox(
updated.companyId,
updated.id,
actorUserId,
now,
undefined,
tx,
);
const { publication } = await persistActivity(tx as unknown as Db, {
companyId: updated.companyId,
actorType: "user",
actorId: actorUserId,
action: "issue.inbox_archived",
entityType: "issue",
entityId: updated.id,
details: {
userId: actorUserId,
archivedAt: archiveState.archivedAt,
targetResolvedFrom: "responsible_user",
source: "issue_status_done",
},
});
activityPublications.push(publication);
}
return {
...enriched,
...(nextBlockedByIssueIds !== undefined
? { blockedByIssueIds: nextBlockedByIssueIds }
: {}),
changes,
};
};
const result = await (dbOrTx === db
? db.transaction(runUpdate)
: runUpdate(dbOrTx));
if (dbOrTx === db && !postCommitActivityPublications) {
for (const publication of ownedActivityPublications)
publishActivity(publication);
}
if (dbOrTx === db && !postCommitActions) {
await executeIssuePostCommitActions(db, ownedPostCommitActions);
}
return result;
},
clearExecutionWorkspaceEnvironmentSelection: async (
companyId: string,
environmentId: string,
) => {
const rows = await db
.select({
id: issues.id,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.companyId, companyId));
let cleared = 0;
for (const row of rows) {
const settings = parseIssueExecutionWorkspaceSettings(
row.executionWorkspaceSettings,
{ includeEnvironmentId: true },
);
if (settings?.environmentId !== environmentId) continue;
await db
.update(issues)
.set({
executionWorkspaceSettings: {
...settings,
environmentId: null,
},
updatedAt: new Date(),
})
.where(eq(issues.id, row.id));
cleared += 1;
}
return cleared;
},
remove: (id: string) =>
db.transaction(async (tx) => {
const attachmentAssetIds = await tx
.select({ assetId: issueAttachments.assetId })
.from(issueAttachments)
.where(eq(issueAttachments.issueId, id));
const issueDocumentIds = await tx
.select({ documentId: issueDocuments.documentId })
.from(issueDocuments)
.where(eq(issueDocuments.issueId, id));
let removedIssue;
try {
removedIssue = await tx
.delete(issues)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
} catch (err) {
// A foreign key to issues.id without a delete policy blocks the delete
// and raises SQLSTATE 23503. Map it to a clear 409 instead of a bare
// 500. This also covers the decisions table, whose NOT NULL references
// to issues.id stay restricted on purpose.
if (isForeignKeyViolation(err)) {
throw conflict(
"Issue cannot be deleted because another record still references it.",
);
}
throw err;
}
if (removedIssue && attachmentAssetIds.length > 0) {
await tx.delete(assets).where(
inArray(
assets.id,
attachmentAssetIds.map((row) => row.assetId),
),
);
}
if (removedIssue && issueDocumentIds.length > 0) {
await tx.delete(documents).where(
inArray(
documents.id,
issueDocumentIds.map((row) => row.documentId),
),
);
}
if (!removedIssue) return null;
const [enriched] = await withIssueLabels(tx, [removedIssue]);
return enriched;
}),
checkout: async (
id: string,
agentId: string,
expectedStatuses: string[],
checkoutRunId: string | null,
) => {
const issueCompany = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!issueCompany) throw notFound("Issue not found");
await assertAssignableAgent(db, issueCompany.companyId, agentId, {
kind: "work",
});
const now = new Date();
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(
issueCompany.companyId,
id,
);
if (
activePauseHold &&
!(await isTreeHoldInteractionCheckoutAllowed(
issueCompany.companyId,
checkoutRunId,
activePauseHold,
))
) {
throw conflict("Issue checkout blocked by active subtree pause hold", {
issueId: id,
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
securityPrinciples: [
"Complete Mediation",
"Fail Securely",
"Secure Defaults",
],
});
}
await clearExecutionRunIfTerminal(id);
await clearCheckoutRunIfTerminal(id);
const dependencyReadiness = await listIssueDependencyReadinessMap(
db,
issueCompany.companyId,
[id],
);
const readiness = dependencyReadiness.get(id);
const unresolvedBlockerIssueIds =
readiness?.unresolvedBlockerIssueIds ?? [];
if (unresolvedBlockerIssueIds.length > 0) {
const unresolvedBlockers = await listUnresolvedBlockerDetails(
db,
issueCompany.companyId,
unresolvedBlockerIssueIds,
readiness?.pendingFinalizeBlockerIssueIds,
);
throw unprocessable("Issue is blocked by unresolved blockers", {
unresolvedBlockerIssueIds,
unresolvedBlockers,
});
}
const sameRunAssigneeCondition = checkoutRunId
? and(
eq(issues.assigneeAgentId, agentId),
or(
isNull(issues.checkoutRunId),
eq(issues.checkoutRunId, checkoutRunId),
),
)
: and(
eq(issues.assigneeAgentId, agentId),
isNull(issues.checkoutRunId),
);
const executionLockCondition = checkoutRunId
? or(
isNull(issues.executionRunId),
eq(issues.executionRunId, checkoutRunId),
)
: isNull(issues.executionRunId);
const updated = await db
.update(issues)
.set({
assigneeAgentId: agentId,
assigneeUserId: null,
checkoutRunId,
executionRunId: checkoutRunId,
status: "in_progress",
startedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, id),
inArray(issues.status, expectedStatuses),
or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition),
executionLockCondition,
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (updated) {
const [enriched] = await withIssueLabels(db, [updated]);
return enriched;
}
const current = await db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Issue not found");
if (
current.assigneeAgentId === agentId &&
current.status === "in_progress" &&
current.checkoutRunId == null &&
(current.executionRunId == null ||
current.executionRunId === checkoutRunId) &&
checkoutRunId
) {
const adopted = await db
.update(issues)
.set({
checkoutRunId,
executionRunId: checkoutRunId,
updatedAt: new Date(),
})
.where(
and(
eq(issues.id, id),
eq(issues.status, "in_progress"),
eq(issues.assigneeAgentId, agentId),
isNull(issues.checkoutRunId),
or(
isNull(issues.executionRunId),
eq(issues.executionRunId, checkoutRunId),
),
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (adopted) return adopted;
}
if (
checkoutRunId &&
current.assigneeAgentId === agentId &&
current.status === "in_progress" &&
current.checkoutRunId &&
current.checkoutRunId !== checkoutRunId
) {
const staleAdoption = await adoptStaleCheckoutRun({
issueId: id,
actorAgentId: agentId,
actorRunId: checkoutRunId,
expectedCheckoutRunId: current.checkoutRunId,
});
if (staleAdoption.adopted) {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Issue not found");
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
}
// Adopt stale executionRunId — if the execution lock points to a terminal/missing run, clear it and proceed.
// Only adopts when the caller's expectedStatuses guard still holds; preserves any existing assigneeUserId
// and preserves the original startedAt when the issue is already in_progress.
if (
checkoutRunId &&
current.executionRunId &&
current.executionRunId !== checkoutRunId &&
(current.assigneeAgentId === agentId || current.assigneeAgentId == null)
) {
const executionRun = await db.select().from(heartbeatRuns)
.where(eq(heartbeatRuns.id, current.executionRunId)).then(rows => rows[0] ?? null);
const stale = !executionRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status);
if (stale && !isExplicitContinuationRetryClaim({ ...current, companyId: issueCompany.companyId }, executionRun)) {
const now = new Date();
const adoptionSet: Record<string, unknown> = {
assigneeAgentId: agentId,
checkoutRunId,
executionRunId: checkoutRunId,
executionAgentNameKey: null,
executionLockedAt: now,
status: "in_progress",
updatedAt: now,
};
if (current.status !== "in_progress") {
adoptionSet.startedAt = now;
}
const adopted = await db
.update(issues)
.set(adoptionSet)
.where(
and(
eq(issues.id, id),
inArray(issues.status, expectedStatuses),
eq(issues.executionRunId, current.executionRunId),
or(
isNull(issues.assigneeAgentId),
eq(issues.assigneeAgentId, agentId),
),
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (adopted) {
const [enriched] = await withIssueLabels(db, [adopted]);
return enriched;
}
}
}
// If this run already owns it and it's in_progress, return it (no self-409)
if (
current.assigneeAgentId === agentId &&
current.status === "in_progress" &&
sameRunLock(current.checkoutRunId, checkoutRunId)
) {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Issue not found");
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
throw conflict("Issue checkout conflict", {
issueId: current.id,
status: current.status,
assigneeAgentId: current.assigneeAgentId,
checkoutRunId: current.checkoutRunId,
executionRunId: current.executionRunId,
});
},
assertCheckoutOwner: async (
id: string,
actorAgentId: string,
actorRunId: string | null,
) => {
await clearExecutionRunIfTerminal(id);
await clearCheckoutRunIfTerminal(id);
const loadCurrent = () =>
db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
const current = await loadCurrent();
if (!current) throw notFound("Issue not found");
const resolveSameRunOwnership = (candidate: {
id: string;
status: string;
assigneeAgentId: string | null;
checkoutRunId: string | null;
executionRunId: string | null;
}) => {
if (
candidate.status === "in_progress" &&
candidate.assigneeAgentId === actorAgentId &&
sameRunLock(candidate.checkoutRunId, actorRunId)
) {
return { ...candidate, adoptedFromRunId: null as string | null };
}
return null;
};
const canAdoptUnownedCheckout = (candidate: {
status: string;
assigneeAgentId: string | null;
checkoutRunId: string | null;
executionRunId: string | null;
}) =>
actorRunId &&
candidate.status === "in_progress" &&
candidate.assigneeAgentId === actorAgentId &&
candidate.checkoutRunId == null &&
(candidate.executionRunId == null ||
candidate.executionRunId === actorRunId);
const resolveOwnership = async (candidate: {
id: string;
status: string;
assigneeAgentId: string | null;
checkoutRunId: string | null;
executionRunId: string | null;
}) => {
const sameRunOwnership = resolveSameRunOwnership(candidate);
if (sameRunOwnership)
return { ownership: sameRunOwnership, latest: null };
if (canAdoptUnownedCheckout(candidate)) {
const adopted = await adoptUnownedCheckoutRun({
issueId: id,
actorAgentId,
actorRunId: actorRunId!,
});
if (adopted) {
return {
ownership: {
...adopted,
adoptedFromRunId: null as string | null,
},
latest: null,
};
}
}
if (
actorRunId &&
candidate.status === "in_progress" &&
candidate.assigneeAgentId === actorAgentId &&
candidate.checkoutRunId &&
candidate.checkoutRunId !== actorRunId
) {
const previousCheckoutRunId = candidate.checkoutRunId;
const staleAdoption = await adoptStaleCheckoutRun({
issueId: id,
actorAgentId,
actorRunId,
expectedCheckoutRunId: previousCheckoutRunId,
});
if (staleAdoption.adopted) {
return {
ownership: {
...staleAdoption.adopted,
adoptedFromRunId: previousCheckoutRunId,
},
latest: null,
};
}
if (staleAdoption.latest) {
const latestOwnership = resolveSameRunOwnership(
staleAdoption.latest,
);
if (latestOwnership)
return {
ownership: latestOwnership,
latest: staleAdoption.latest,
};
return { ownership: null, latest: staleAdoption.latest };
}
}
return { ownership: null, latest: null };
};
const resolved = await resolveOwnership(current);
if (resolved.ownership) return resolved.ownership;
const latest = resolved.latest ?? (await loadCurrent());
if (!latest) throw notFound("Issue not found");
const resolvedLatest = await resolveOwnership(latest);
if (resolvedLatest.ownership) return resolvedLatest.ownership;
if (resolvedLatest.latest) {
throw conflict("Issue run ownership conflict", {
issueId: resolvedLatest.latest.id,
status: resolvedLatest.latest.status,
assigneeAgentId: resolvedLatest.latest.assigneeAgentId,
checkoutRunId: resolvedLatest.latest.checkoutRunId,
executionRunId: resolvedLatest.latest.executionRunId,
actorAgentId,
actorRunId,
});
}
throw conflict("Issue run ownership conflict", {
issueId: latest.id,
status: latest.status,
assigneeAgentId: latest.assigneeAgentId,
checkoutRunId: latest.checkoutRunId,
executionRunId: latest.executionRunId,
actorAgentId,
actorRunId,
});
},
release: async (
id: string,
actorAgentId?: string,
actorRunId?: string | null,
) =>
db.transaction(async (tx) => {
await tx.execute(
sql`select ${issues.id} from ${issues} where ${issues.id} = ${id} for update`,
);
const existing = await tx
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (
actorAgentId &&
existing.assigneeAgentId &&
existing.assigneeAgentId !== actorAgentId
) {
throw conflict("Only assignee can release issue");
}
if (
actorAgentId &&
existing.status === "in_progress" &&
existing.assigneeAgentId === actorAgentId &&
existing.checkoutRunId &&
!sameRunLock(existing.checkoutRunId, actorRunId ?? null)
) {
const stale = await isTerminalOrMissingHeartbeatRun(
existing.checkoutRunId,
tx,
);
if (!stale) {
throw conflict("Only checkout run can release issue", {
issueId: existing.id,
assigneeAgentId: existing.assigneeAgentId,
checkoutRunId: existing.checkoutRunId,
actorRunId: actorRunId ?? null,
});
}
}
// Terminal assignment records who owned the work, not a live execution
// claim. Cleanup must preserve it; unfinished release still relinquishes it.
const isTerminal = existing.status === "done" || existing.status === "cancelled";
const releaseStatus =
existing.status === "in_progress" ? "todo" : existing.status;
const updated = await tx
.update(issues)
.set({
status: releaseStatus,
assigneeAgentId: isTerminal ? existing.assigneeAgentId : null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
const [enriched] = await withIssueLabels(tx, [updated]);
return enriched;
}),
adminForceRelease: async (
id: string,
options: { clearAssignee?: boolean } = {},
) =>
db.transaction(async (tx) => {
await tx.execute(
sql`select ${issues.id} from ${issues} where ${issues.id} = ${id} for update`,
);
const existing = await tx
.select({
id: issues.id,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const patch: Partial<typeof issues.$inferInsert> = {
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
};
if (options.clearAssignee) {
patch.assigneeAgentId = null;
}
const updated = await tx
.update(issues)
.set(patch)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
const [enriched] = await withIssueLabels(tx, [updated]);
return {
issue: enriched,
previous: {
checkoutRunId: existing.checkoutRunId,
executionRunId: existing.executionRunId,
},
};
}),
listLabels: (companyId: string) =>
db
.select()
.from(labels)
.where(eq(labels.companyId, companyId))
.orderBy(asc(labels.name), asc(labels.id)),
getLabelById: (id: string) =>
db
.select()
.from(labels)
.where(eq(labels.id, id))
.then((rows) => rows[0] ?? null),
createLabel: async (
companyId: string,
data: Pick<typeof labels.$inferInsert, "name" | "color">,
) => {
const [created] = await db
.insert(labels)
.values({
companyId,
name: data.name.trim(),
color: data.color,
})
.returning();
return created;
},
deleteLabel: async (id: string) =>
db
.delete(labels)
.where(eq(labels.id, id))
.returning()
.then((rows) => rows[0] ?? null),
listComments: async (
issueId: string,
opts?: {
afterCommentId?: string | null;
order?: "asc" | "desc";
limit?: number | null;
},
) => {
const order = opts?.order === "asc" ? "asc" : "desc";
const afterCommentId = opts?.afterCommentId?.trim() || null;
const limit =
opts?.limit && opts.limit > 0
? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT)
: null;
const conditions = [eq(issueComments.issueId, issueId)];
if (afterCommentId) {
// Guard: reject non-UUID cursors before hitting the DB to avoid Postgres type errors.
if (!isUuidLike(afterCommentId)) return [];
const anchor = await db
.select({
id: issueComments.id,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.issueId, issueId),
eq(issueComments.id, afterCommentId),
),
)
.then((rows) => rows[0] ?? null);
if (!anchor) return [];
const anchorCreatedAt =
anchor.createdAt instanceof Date
? anchor.createdAt
: new Date(String(anchor.createdAt));
conditions.push(
order === "asc"
? or(
gt(issueComments.createdAt, anchorCreatedAt),
and(
eq(issueComments.createdAt, anchorCreatedAt),
gt(issueComments.id, anchor.id),
),
)!
: or(
lt(issueComments.createdAt, anchorCreatedAt),
and(
eq(issueComments.createdAt, anchorCreatedAt),
lt(issueComments.id, anchor.id),
),
)!,
);
}
const query = db
.select()
.from(issueComments)
.where(and(...conditions))
.orderBy(
order === "asc"
? asc(issueComments.createdAt)
: desc(issueComments.createdAt),
order === "asc" ? asc(issueComments.id) : desc(issueComments.id),
);
const comments = limit ? await query.limit(limit) : await query;
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
const projectedComments = await projectHistoricalRunComments(comments);
const enrichedComments =
await enrichCommentsWithDerivedAgentAttribution(projectedComments);
return enrichedComments.map((comment) =>
redactIssueComment(comment, censorUsernameInLogs),
);
},
getCommentCursor: async (issueId: string) => {
const [latest, countRow] = await Promise.all([
db
.select({
latestCommentId: issueComments.id,
latestCommentAt: issueComments.createdAt,
})
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({
totalComments: sql<number>`count(*)::int`,
})
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.then((rows) => rows[0] ?? null),
]);
return {
totalComments: Number(countRow?.totalComments ?? 0),
latestCommentId: latest?.latestCommentId ?? null,
latestCommentAt: latest?.latestCommentAt ?? null,
};
},
getComment: async (commentId: string) => {
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
const comment = await db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => rows[0] ?? null);
if (!comment) return null;
const [projectedComment] = await projectHistoricalRunComments([comment]);
const [enrichedComment] = await enrichCommentsWithDerivedAgentAttribution(
[projectedComment ?? comment],
);
return redactIssueComment(
enrichedComment ?? projectedComment ?? comment,
censorUsernameInLogs,
);
},
removeComment: async (commentId: string) => {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
return db.transaction(async (tx) => {
const [comment] = await tx
.delete(issueComments)
.where(eq(issueComments.id, commentId))
.returning();
if (!comment) return null;
await tx
.update(issues)
.set({ updatedAt: new Date() })
.where(eq(issues.id, comment.issueId));
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
});
},
tombstoneComment: async (
commentId: string,
actor: {
actorType: "agent" | "user";
agentId?: string | null;
userId?: string | null;
runId?: string | null;
},
options?: {
afterTombstone?: (comment: IssueComment, tx: any) => Promise<void>;
},
) => {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
return db.transaction(async (tx) => {
const now = new Date();
const [comment] = await tx
.update(issueComments)
.set({
body: DELETED_ISSUE_COMMENT_BODY,
presentation: null,
metadata: null,
deletedAt: now,
deletedByType: actor.actorType,
deletedByAgentId:
actor.actorType === "agent" ? (actor.agentId ?? null) : null,
deletedByUserId:
actor.actorType === "user" ? (actor.userId ?? null) : null,
deletedByRunId: actor.runId ?? null,
updatedAt: now,
})
.where(
and(
eq(issueComments.id, commentId),
isNull(issueComments.deletedAt),
),
)
.returning();
if (!comment) return null;
await tx
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, comment.issueId));
const redacted = redactIssueComment(
comment,
currentUserRedactionOptions.enabled,
);
await options?.afterTombstone?.(redacted, tx);
return redacted;
});
},
addComment: async function addComment(
issueId: string,
body: string,
actor: {
agentId?: string;
userId?: string;
runId?: string | null;
onBehalfOfUserId?: string | null;
},
options?: {
authorType?: IssueCommentAuthorType | null;
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | null;
attachmentIds?: string[];
authorizationReason?: string | null;
/** Server-only final assistant response, never a tool/progress comment. */
completionReply?: boolean;
sourceTrust?: typeof issueComments.$inferInsert.sourceTrust;
createdAt?: Date | string | null;
clientRequestId?: string;
/** Server-only: authenticated Paperclip messages also belong in the Slack thread. */
mirrorToSlack?: boolean;
},
dbOrTx: any = db,
): Promise<IssueComment> {
if (dbOrTx === db && (actor.runId || actor.userId)) {
const append = () =>
db.transaction(async (tx) => {
// Serialize run-authored comments on the issue so a provider retry
// cannot publish the same visible result twice. This needs no schema
// change: the issue row is the transaction fence, and the recursive
// call below performs the lookup and insert while holding it.
await tx
.select({ id: issues.id })
.from(issues)
.where(eq(issues.id, issueId))
.for("update");
return addComment(issueId, body, actor, options, tx);
});
return options?.authorizationReason ===
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
? retryNativeChatReviewPresentation(append)
: append();
}
// The query below locks human comments on caller-owned transactions too,
// sharing the fence with both question creation and Slack settlement.
const issueQuery = dbOrTx
.select({ companyId: issues.companyId, conversationAgentId: issues.conversationAgentId })
.from(issues)
.where(eq(issues.id, issueId));
// Caller-owned transactions (including chat and review comments) must
// serialize with question creation before inserting the human comment.
const issue = await (actor.userId || (actor.runId && dbOrTx !== db) ? issueQuery.for("update") : issueQuery)
.then((rows: Array<{ companyId: string; conversationAgentId: string | null }>) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
if (issue.conversationAgentId && actor.userId && !(await instanceSettingsService(dbOrTx).getExperimental()).enableAgentChat) {
throw unprocessable("Agent Chat is disabled in Experimental settings");
}
const currentUserRedactionOptions = {
// Keep every read on the caller's transaction connection. Re-entering
// the outer pool here can deadlock when concurrent transactions fill
// the pool while waiting on the same issue or delivery row.
enabled: (await instanceSettings.getGeneral({ db: dbOrTx }))
.censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
if (actor.userId && options?.clientRequestId) {
const [existing] = await dbOrTx.select().from(issueComments).where(and(eq(issueComments.issueId, issueId),
eq(issueComments.authorUserId, actor.userId), eq(issueComments.clientRequestId, options.clientRequestId)));
if (existing) {
if (existing.body !== redactedBody) throw conflict("Message request ID was already used for different content");
return existing;
}
}
if (issue.conversationAgentId && actor.runId) {
const [run] = await dbOrTx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, actor.runId));
const [current] = await dbOrTx.select().from(issues).where(eq(issues.id, issueId));
if (run?.status === "cancelled") throw conflict("This conversation turn was cancelled; it cannot post a reply");
if (run?.contextSnapshot?.conversationSessionGeneration !== current.conversationSessionGeneration) {
throw conflict("Conversation session changed; this reply belongs to an earlier session");
}
}
if (options?.completionReply && actor.agentId && actor.runId) {
const delivered = await existingChatCompletionReply(dbOrTx, actor.runId, issueId);
if (delivered) return redactIssueComment(delivered, currentUserRedactionOptions.enabled);
}
const authorType = issueCommentAuthorTypeSchema.parse(
options?.authorType ??
(actor.agentId ? "agent" : actor.userId ? "user" : "system"),
);
assertIssueCommentAuthorTypeAllowed(actor, authorType);
const presentation = issueCommentPresentationSchema
.nullable()
.parse(options?.presentation ?? null);
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
const validCreatedAt =
createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt : null;
// Use one statement timestamp for both columns when the caller did not
// supply a valid historical timestamp. This keeps the comment's
// recency fields equal even when the surrounding transaction started
// earlier, while preserving imported timestamps and the normal default
// updatedAt behavior for those historical rows.
const currentInsertTimestamp = validCreatedAt
? null
: sql`statement_timestamp()`;
// Invalid/stale run ids must not 500 the insert — null out unknowns.
const createdByRun = await resolveCommentCreatedByRun(
dbOrTx,
issue.companyId,
actor.runId,
);
const createdByRunId = createdByRun?.id ?? null;
if (
createdByRunId &&
options?.authorizationReason === CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
) {
const [presentationRun] = await dbOrTx
.select({ resultJson: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, createdByRunId),
eq(heartbeatRuns.companyId, issue.companyId),
),
)
.limit(1);
if (
presentationRun?.resultJson?.finalizationReasonCode ===
"governed_response_waiting" &&
(parseObject(presentationRun.resultJson.nativeResult).summary !== body ||
!(await authorizeNativeChatReviewPresentation(
dbOrTx,
{
companyId: issue.companyId,
issueId,
runId: createdByRunId,
resultJson: presentationRun.resultJson,
},
"nonblocking",
)))
) {
throw conflict(
"This chat response is no longer authorized for external presentation",
{ code: "chat_review_response_presentation_denied" },
);
}
}
if (actor.runId && !createdByRunId) {
logger.warn(
{ issueId, companyId: issue.companyId, runId: actor.runId },
"dropping invalid createdByRunId for issue comment insert",
);
}
const attachmentIds = [...new Set(options?.attachmentIds ?? [])];
if (actor.agentId && attachmentIds.length > 0) {
if (!createdByRunId) {
throw unprocessable(
"Agent comment attachments require the current registered run",
);
}
// Routes commonly supply an existing transaction for attachment
// comments, so the outer addComment wrapper is not always responsible
// for serialization. Lock both stable parents here before selecting or
// binding files. Concurrent helper calls for the same run then observe
// one another's committed selection count instead of both admitting a
// twenty-first attachment.
const [lockedIssue] = await dbOrTx
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.id, issueId),
eq(issues.companyId, issue.companyId),
),
)
.for("update");
if (!lockedIssue) throw notFound("Issue not found");
const [lockedRun] = await dbOrTx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, createdByRunId),
eq(heartbeatRuns.companyId, issue.companyId),
eq(heartbeatRuns.agentId, actor.agentId),
),
)
.for("update");
if (!lockedRun) {
throw unprocessable(
"Agent comment attachments require the current registered run",
);
}
}
const onBehalfOfUserId = actor.agentId
? await resolveCommentResponsibleUserId(
dbOrTx,
issue.companyId,
createdByRunId,
actor.onBehalfOfUserId,
)
: null;
const metadata = issueCommentMetadataSchema
.nullable()
.parse(
actor.agentId
? withAgentCommentAuthorizationMetadata(
options?.metadata ?? null,
options?.authorizationReason,
)
: (options?.metadata ?? null),
);
let comment: typeof issueComments.$inferSelect | null = null;
if (createdByRunId) {
const existing = await dbOrTx
.select()
.from(issueComments)
.where(
and(
eq(issueComments.companyId, issue.companyId),
eq(issueComments.issueId, issueId),
eq(issueComments.createdByRunId, createdByRunId),
eq(issueComments.authorType, authorType),
actor.agentId
? eq(issueComments.authorAgentId, actor.agentId)
: isNull(issueComments.authorAgentId),
eq(issueComments.body, redactedBody),
isNull(issueComments.deletedAt),
),
)
.orderBy(issueComments.createdAt, issueComments.id)
.limit(1)
.then(
(rows: Array<typeof issueComments.$inferSelect>) => rows[0] ?? null,
);
if (existing) {
// Heartbeat's presentation resolver can intentionally select the
// exact same prose that the agent already wrote while handling an
// external-chat continuation. The first write is kept internal so
// lifecycle/tool chatter cannot escape; the later, narrowly
// authorized presentation must upgrade that one durable comment
// instead of returning early and letting the generic completion
// milestone win the provider reply slot.
const shouldUpgradeExternalAuthorization =
authorType === "agent" &&
metadata?.authorizationReason ===
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON &&
existing.metadata?.authorizationReason !==
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON;
const shouldBindAttachments =
(options?.attachmentIds?.length ?? 0) > 0;
const shouldUpgradeAttachmentAuthorization =
shouldBindAttachments &&
isExplicitExternalAgentComment(metadata) &&
!isExplicitExternalAgentComment(existing.metadata);
if (
!shouldUpgradeExternalAuthorization &&
!shouldUpgradeAttachmentAuthorization &&
!shouldBindAttachments
) {
if (options?.completionReply && actor.agentId && createdByRunId) await acknowledgeChatCompletionReply(dbOrTx, createdByRunId, existing.id);
return redactIssueComment(
existing,
currentUserRedactionOptions.enabled,
);
}
comment =
shouldUpgradeExternalAuthorization ||
shouldUpgradeAttachmentAuthorization
? await dbOrTx
.update(issueComments)
.set({
// Preserve any structured provenance the provisional comment
// carried; the presentation pass only elevates its narrowly
// resolved external-publication authorization.
metadata: {
...(existing.metadata ?? {}),
...(metadata ?? {}),
},
updatedAt: new Date(),
})
.where(
and(
eq(issueComments.id, existing.id),
eq(issueComments.companyId, issue.companyId),
),
)
.returning()
.then(
(rows: Array<typeof issueComments.$inferSelect>) =>
rows[0] ?? null,
)
: existing;
}
}
if (!comment) {
[comment] = await dbOrTx
.insert(issueComments)
.values({
companyId: issue.companyId,
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
onBehalfOfUserId,
authorType,
createdByRunId,
body: redactedBody,
clientRequestId: options?.clientRequestId ?? null,
presentation,
metadata,
sourceTrust: options?.sourceTrust ?? null,
createdAt: validCreatedAt ?? currentInsertTimestamp!,
...(currentInsertTimestamp
? { updatedAt: currentInsertTimestamp }
: {}),
})
.returning();
}
if (!comment) throw new Error("Failed to create issue comment");
if (options?.completionReply && actor.agentId && createdByRunId) await acknowledgeChatCompletionReply(dbOrTx, createdByRunId, comment.id);
const boundAttachments: Array<{
id: string;
commentId: string;
originalFilename: string | null;
}> = [];
if (attachmentIds.length > 0) {
const attachmentRows = await dbOrTx
.select({
id: issueAttachments.id,
issueCommentId: issueAttachments.issueCommentId,
originatingRunId: issueAttachments.originatingRunId,
createdByAgentId: assets.createdByAgentId,
originalFilename: assets.originalFilename,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(
and(
eq(issueAttachments.companyId, issue.companyId),
eq(issueAttachments.issueId, issueId),
inArray(issueAttachments.id, attachmentIds),
),
)
.for("update");
type CommentAttachmentRow = {
id: string;
issueCommentId: string | null;
originatingRunId: string | null;
createdByAgentId: string | null;
originalFilename: string | null;
};
const attachmentById = new Map<string, CommentAttachmentRow>(
attachmentRows.map(
(
attachment: CommentAttachmentRow,
): [string, CommentAttachmentRow] => [attachment.id, attachment],
),
);
if (attachmentById.size !== attachmentIds.length) {
throw unprocessable(
"Comment attachments must belong to the same task and company",
);
}
if (
attachmentRows.some(
(attachment: { issueCommentId: string | null }) =>
attachment.issueCommentId !== null &&
attachment.issueCommentId !== comment.id,
)
) {
throw conflict(
"Comment attachments are already bound to another comment",
{
code: "issue_comment_attachments_already_bound",
companyId: issue.companyId,
issueId,
attachmentIds: attachmentRows
.filter(
(attachment: CommentAttachmentRow) =>
attachment.issueCommentId !== null &&
attachment.issueCommentId !== comment.id,
)
.map((attachment: CommentAttachmentRow) => attachment.id),
},
);
}
if (actor.agentId && createdByRunId) {
if (
attachmentRows.some(
(attachment: CommentAttachmentRow) =>
attachment.createdByAgentId !== actor.agentId ||
attachment.originatingRunId !== createdByRunId,
)
) {
throw unprocessable(
"Agent comment attachments must originate from the same agent and current run",
{ code: "issue_attachment_run_origin_mismatch" },
);
}
const chatBindings = await resolveChatOriginPublicationBindings(
dbOrTx,
issue.companyId,
issueId,
createdByRunId,
);
if (chatBindings.length > 0) {
const alreadySelected =
await listSelectedChatPresentationAttachments(dbOrTx, {
companyId: issue.companyId,
issueId,
agentId: actor.agentId,
runId: createdByRunId,
});
const selectedIds = new Set(
alreadySelected.map((attachment) => attachment.id),
);
for (const attachmentId of attachmentIds) {
selectedIds.add(attachmentId);
}
if (selectedIds.size > MAX_CHAT_PRESENTATION_ATTACHMENTS) {
throw unprocessable(
`An agent run can select at most ${MAX_CHAT_PRESENTATION_ATTACHMENTS} attachments for one chat response`,
{
code: "chat_attachment_selection_limit_exceeded",
limit: MAX_CHAT_PRESENTATION_ATTACHMENTS,
selectedCount: selectedIds.size,
},
);
}
}
}
const unboundAttachmentIds = attachmentRows.flatMap(
(attachment: CommentAttachmentRow) =>
attachment.issueCommentId === null ? [attachment.id] : [],
);
const attached = unboundAttachmentIds.length
? await dbOrTx
.update(issueAttachments)
.set({ issueCommentId: comment.id, updatedAt: new Date() })
.where(
and(
eq(issueAttachments.companyId, issue.companyId),
eq(issueAttachments.issueId, issueId),
inArray(issueAttachments.id, unboundAttachmentIds),
isNull(issueAttachments.issueCommentId),
),
)
.returning({ id: issueAttachments.id })
: [];
if (attached.length !== unboundAttachmentIds.length) {
throw conflict(
"Comment attachments changed before they could be bound",
);
}
for (const attachmentId of attachmentIds) {
const attachment = attachmentById.get(attachmentId)!;
boundAttachments.push({
id: attachment.id,
commentId: comment.id,
originalFilename: attachment.originalFilename,
});
}
}
// A chat-origin run binds only the files it explicitly selects to an
// ordinary agent comment while it is still running. That comment stays
// internal until heartbeat chooses the final provider presentation. At
// that point, carry those already-authorized same-agent/same-run files
// forward without inferring any unbound artifact from the run.
if (
authorType === "agent" &&
actor.agentId &&
createdByRunId &&
metadata?.authorizationReason ===
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
) {
const priorRows = await listSelectedChatPresentationAttachments(
dbOrTx,
{
companyId: issue.companyId,
issueId,
agentId: actor.agentId,
runId: createdByRunId,
},
);
const selectedIds = new Set(
boundAttachments.map((attachment) => attachment.id),
);
for (const attachment of priorRows) {
if (
selectedIds.has(attachment.id) ||
!isExplicitExternalAgentComment(attachment.commentMetadata)
) {
continue;
}
selectedIds.add(attachment.id);
boundAttachments.push({
id: attachment.id,
commentId: attachment.commentId,
originalFilename: attachment.originalFilename,
});
}
if (boundAttachments.length > MAX_CHAT_PRESENTATION_ATTACHMENTS) {
throw unprocessable(
`An agent run can select at most ${MAX_CHAT_PRESENTATION_ATTACHMENTS} attachments for one chat response`,
{
code: "chat_attachment_selection_limit_exceeded",
limit: MAX_CHAT_PRESENTATION_ATTACHMENTS,
selectedCount: boundAttachments.length,
},
);
}
}
if (issue.conversationAgentId && actor.userId) {
await dbOrTx.update(issues).set({ conversationState: "active" }).where(eq(issues.id, issueId));
}
if (options?.mirrorToSlack && actor.userId && authorType === "user") {
await mirrorSlackBoardComment(dbOrTx, comment, { attachmentIds: options.attachmentIds });
}
if (authorType === "user" || actor.userId) {
await resumeSlackConversation(dbOrTx, issue.companyId, issueId);
}
// Update issue's updatedAt so comment activity is reflected in recency sorting
await dbOrTx
.update(issues)
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
// Only an explicitly authored comment from the run causally woken by an
// inbound chat message is automatically publishable. Presentation,
// recovery, automation, and ordinary internal agent comments stay in
// Paperclip even while a bound conversation is active.
if (authorType === "agent" && isExplicitExternalAgentComment(metadata)) {
// An external-chat run may perform ordinary Paperclip lifecycle or
// bookkeeping writes before its adapter result is finalized. Those
// writes remain internal: only heartbeat's selected final presentation
// may consume this provider response slot. Explicit board "Send to
// channel" publications use the separate publication path.
const chatFinalOwnsProviderReply =
createdByRun !== null &&
isExternalChatPresentationContext(
createdByRun.contextSnapshot,
readStringFromRecord(createdByRun.contextSnapshot, "source") === "tool_action_review" &&
(await resolveChatOriginPublicationBindings(
dbOrTx, issue.companyId, issueId, createdByRunId,
)).length > 0,
) &&
metadata?.authorizationReason !==
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON;
const interactionOwnsProviderReply = createdByRunId
? await hasChatRunOwnedProviderInteraction(dbOrTx, {
companyId: issue.companyId,
issueId,
runId: createdByRunId,
})
: false;
const bindings =
interactionOwnsProviderReply || chatFinalOwnsProviderReply
? []
: await resolveChatOriginPublicationBindings(
dbOrTx,
issue.companyId,
issueId,
createdByRunId,
);
const publicationCreatedAt = new Date();
for (const binding of bindings) {
await dbOrTx
.insert(chatPublications)
.values({
companyId: binding.companyId,
endpointId: binding.endpointId,
conversationId: binding.conversationId,
issueId,
commentId: comment.id,
idempotencyKey: `comment:${comment.id}:${binding.endpointId}`,
payload: projectSafeChatPublication({
classification: "external",
source: "agent_comment",
text: redactedBody,
}),
state: "pending",
createdAt: publicationCreatedAt,
updatedAt: publicationCreatedAt,
})
.onConflictDoNothing();
for (const [index, attachment] of boundAttachments.entries()) {
const attachmentCreatedAt = new Date(
publicationCreatedAt.getTime() + index + 1,
);
await dbOrTx
.insert(chatPublications)
.values({
companyId: binding.companyId,
endpointId: binding.endpointId,
conversationId: binding.conversationId,
issueId,
commentId: attachment.commentId,
idempotencyKey: `attachment:${attachment.id}:${binding.endpointId}`,
payload: projectSafeChatPublication({
classification: "external",
source: "agent_comment",
text: `Shared ${attachment.originalFilename ?? "a file"}.`,
attachmentIds: [attachment.id],
}),
state: "pending",
createdAt: attachmentCreatedAt,
updatedAt: attachmentCreatedAt,
})
.onConflictDoNothing();
}
}
}
if (
authorType === "user" &&
actor.userId &&
actor.userId !== "board-concierge" &&
!createdByRunId
) {
const { issueThreadInteractionService } =
await import("./issue-thread-interactions.js");
const expiredInteractions = await issueThreadInteractionService(
dbOrTx,
).expireRequestConfirmationsSupersededByComment(
{ id: issueId, companyId: issue.companyId },
comment,
{ agentId: actor.agentId, userId: actor.userId },
);
for (const interaction of expiredInteractions) {
await logActivity(dbOrTx, {
companyId: issue.companyId,
actorType: "user",
actorId: actor.userId,
agentId: actor.agentId ?? null,
runId: createdByRunId,
action: "issue.thread_interaction_expired",
entityType: "issue",
entityId: issueId,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
source: "issue.comment.service",
result: interaction.result ?? null,
},
});
}
}
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
},
createAttachment: async (input: {
issueId: string;
issueCommentId?: string | null;
provider: string;
objectKey: string;
contentType: string;
byteSize: number;
sha256: string;
originalFilename?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
createdByRunId?: string | null;
}) => {
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, input.issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
return db.transaction(async (tx) => {
if (input.createdByAgentId && input.issueCommentId) {
const [lockedIssue] = await tx
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.id, issue.id),
eq(issues.companyId, issue.companyId),
),
)
.for("update");
if (!lockedIssue) throw notFound("Issue not found");
}
const registeredRun =
input.createdByRunId && isUuidLike(input.createdByRunId)
? await tx
.select({
id: heartbeatRuns.id,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, input.createdByRunId),
eq(heartbeatRuns.companyId, issue.companyId),
...(input.createdByAgentId
? [eq(heartbeatRuns.agentId, input.createdByAgentId)]
: []),
),
)
.for("update")
.then((rows) => rows[0] ?? null)
: null;
const registeredRunId = registeredRun?.id ?? null;
const parentComment = input.issueCommentId
? await tx
.select({
id: issueComments.id,
companyId: issueComments.companyId,
issueId: issueComments.issueId,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
createdByRunId: issueComments.createdByRunId,
metadata: issueComments.metadata,
})
.from(issueComments)
.where(
and(
eq(issueComments.id, input.issueCommentId),
isNull(issueComments.deletedAt),
),
)
.for("update")
.then((rows) => rows[0] ?? null)
: null;
if (input.issueCommentId && !parentComment) {
throw notFound("Issue comment not found");
}
if (
parentComment &&
(parentComment.companyId !== issue.companyId ||
parentComment.issueId !== issue.id)
) {
throw unprocessable(
"Attachment comment must belong to same issue and company",
);
}
if (input.createdByAgentId && parentComment) {
if (
!registeredRunId ||
parentComment.authorType !== "agent" ||
parentComment.authorAgentId !== input.createdByAgentId ||
parentComment.createdByRunId !== registeredRunId
) {
throw unprocessable(
"Agent attachment comments must belong to the same agent and current run",
{ code: "issue_attachment_parent_run_mismatch" },
);
}
const chatBindings = isExplicitExternalAgentComment(
parentComment.metadata,
)
? await resolveChatOriginPublicationBindings(
tx,
issue.companyId,
issue.id,
registeredRunId,
)
: [];
if (chatBindings.length > 0) {
const alreadySelected =
await listSelectedChatPresentationAttachments(tx, {
companyId: issue.companyId,
issueId: issue.id,
agentId: input.createdByAgentId,
runId: registeredRunId,
});
if (alreadySelected.length >= MAX_CHAT_PRESENTATION_ATTACHMENTS) {
throw unprocessable(
`An agent run can select at most ${MAX_CHAT_PRESENTATION_ATTACHMENTS} attachments for one chat response`,
{
code: "chat_attachment_selection_limit_exceeded",
limit: MAX_CHAT_PRESENTATION_ATTACHMENTS,
selectedCount: alreadySelected.length + 1,
},
);
}
}
}
const [asset] = await tx
.insert(assets)
.values({
companyId: issue.companyId,
provider: input.provider,
objectKey: input.objectKey,
contentType: input.contentType,
byteSize: input.byteSize,
sha256: input.sha256,
originalFilename: input.originalFilename ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
})
.returning();
const [attachment] = await tx
.insert(issueAttachments)
.values({
companyId: issue.companyId,
issueId: issue.id,
assetId: asset.id,
issueCommentId: input.issueCommentId ?? null,
originatingRunId: registeredRunId,
})
.returning();
const contentPath = `/api/attachments/${attachment.id}/content`;
const [artifactWorkProduct] = registeredRunId
? await tx
.insert(issueWorkProducts)
.values({
companyId: issue.companyId,
issueId: issue.id,
type: "artifact",
provider: "paperclip",
externalId: attachment.id,
title: asset.originalFilename ?? "Attachment",
status: "active",
reviewState: "none",
isPrimary: false,
healthStatus: "unknown",
metadata: {
attachmentId: attachment.id,
contentType: asset.contentType,
byteSize: asset.byteSize,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: asset.originalFilename,
},
createdByRunId: registeredRunId,
})
.returning({ id: issueWorkProducts.id })
: [];
if (
input.createdByAgentId &&
registeredRunId &&
parentComment?.authorType === "agent" &&
parentComment.authorAgentId === input.createdByAgentId &&
parentComment.createdByRunId === registeredRunId &&
parentComment.metadata?.authorizationReason ===
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON
) {
const bindings = await resolveChatOriginPublicationBindings(
tx,
issue.companyId,
issue.id,
registeredRunId,
);
for (const binding of bindings) {
await tx
.insert(chatPublications)
.values({
companyId: issue.companyId,
endpointId: binding.endpointId,
conversationId: binding.conversationId,
issueId: issue.id,
commentId: parentComment.id,
idempotencyKey: `attachment:${attachment.id}:${binding.endpointId}`,
payload: projectSafeChatPublication({
classification: "external",
source: "agent_comment",
text: `Shared ${asset.originalFilename ?? "a file"}.`,
attachmentIds: [attachment.id],
}),
state: "pending",
})
.onConflictDoNothing();
}
}
return {
id: attachment.id,
companyId: attachment.companyId,
issueId: attachment.issueId,
issueCommentId: attachment.issueCommentId,
originatingRunId: attachment.originatingRunId,
assetId: attachment.assetId,
provider: asset.provider,
objectKey: asset.objectKey,
contentType: asset.contentType,
byteSize: asset.byteSize,
sha256: asset.sha256,
originalFilename: asset.originalFilename,
createdByAgentId: asset.createdByAgentId,
createdByUserId: asset.createdByUserId,
createdAt: attachment.createdAt,
updatedAt: attachment.updatedAt,
artifactWorkProductId: artifactWorkProduct?.id ?? null,
};
});
},
listAttachments: async (issueId: string) =>
db
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
issueCommentId: issueAttachments.issueCommentId,
originatingRunId: issueAttachments.originatingRunId,
assetId: issueAttachments.assetId,
provider: assets.provider,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
createdAt: issueAttachments.createdAt,
updatedAt: issueAttachments.updatedAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.issueId, issueId))
.orderBy(desc(issueAttachments.createdAt)),
getAttachmentById: async (id: string) =>
db
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
issueCommentId: issueAttachments.issueCommentId,
originatingRunId: issueAttachments.originatingRunId,
assetId: issueAttachments.assetId,
provider: assets.provider,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
createdAt: issueAttachments.createdAt,
updatedAt: issueAttachments.updatedAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.id, id))
.then((rows) => rows[0] ?? null),
removeAttachment: async (id: string) =>
db.transaction(async (tx) => {
const existing = await tx
.select({
id: issueAttachments.id,
companyId: issueAttachments.companyId,
issueId: issueAttachments.issueId,
issueCommentId: issueAttachments.issueCommentId,
originatingRunId: issueAttachments.originatingRunId,
assetId: issueAttachments.assetId,
provider: assets.provider,
objectKey: assets.objectKey,
contentType: assets.contentType,
byteSize: assets.byteSize,
sha256: assets.sha256,
originalFilename: assets.originalFilename,
createdByAgentId: assets.createdByAgentId,
createdByUserId: assets.createdByUserId,
createdAt: issueAttachments.createdAt,
updatedAt: issueAttachments.updatedAt,
})
.from(issueAttachments)
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
.where(eq(issueAttachments.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
await tx.delete(assets).where(eq(assets.id, existing.assetId));
return existing;
}),
findMentionedAgents: async (companyId: string, body: string) => {
const explicitAgentMentionIds = extractAgentMentionIds(body);
if (explicitAgentMentionIds.length === 0) return [];
const rows = await db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.companyId, companyId));
const companyAgentIds = new Set(rows.map((agent) => agent.id));
return explicitAgentMentionIds.filter((agentId) =>
companyAgentIds.has(agentId),
);
},
findMentionedProjectIds: async (
issueId: string,
opts?: { includeCommentBodies?: boolean },
) => {
const issue = await db
.select({
companyId: issues.companyId,
title: issues.title,
description: issues.description,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) return [];
const mentionedIds = new Set<string>();
for (const source of [issue.title, issue.description ?? ""]) {
for (const projectId of extractProjectMentionIds(source)) {
mentionedIds.add(projectId);
}
}
if (opts?.includeCommentBodies !== false) {
const comments = await db
.select({ body: issueComments.body })
.from(issueComments)
.where(
and(
eq(issueComments.issueId, issueId),
isNull(issueComments.deletedAt),
),
);
for (const comment of comments) {
for (const projectId of extractProjectMentionIds(comment.body)) {
mentionedIds.add(projectId);
}
}
}
if (mentionedIds.size === 0) return [];
const rows = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.companyId, issue.companyId),
inArray(projects.id, [...mentionedIds]),
),
);
const valid = new Set(rows.map((row) => row.id));
return [...mentionedIds].filter((projectId) => valid.has(projectId));
},
getAncestors: async (issueId: string) => {
const raw: Array<{
id: string;
identifier: string | null;
title: string;
description: string | null;
status: string;
priority: string;
assigneeAgentId: string | null;
projectId: string | null;
goalId: string | null;
}> = [];
const visited = new Set<string>([issueId]);
const start = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((r) => r[0] ?? null);
let currentId = start?.parentId ?? null;
while (currentId && !visited.has(currentId) && raw.length < 50) {
visited.add(currentId);
const parent = await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
description: issues.description,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
projectId: issues.projectId,
goalId: issues.goalId,
parentId: issues.parentId,
})
.from(issues)
.where(eq(issues.id, currentId))
.then((r) => r[0] ?? null);
if (!parent) break;
raw.push({
id: parent.id,
identifier: parent.identifier ?? null,
title: parent.title,
description: parent.description ?? null,
status: parent.status,
priority: parent.priority,
assigneeAgentId: parent.assigneeAgentId ?? null,
projectId: parent.projectId ?? null,
goalId: parent.goalId ?? null,
});
currentId = parent.parentId ?? null;
}
// Batch-fetch referenced projects and goals
const projectIds = [
...new Set(
raw.map((a) => a.projectId).filter((id): id is string => id != null),
),
];
const goalIds = [
...new Set(
raw.map((a) => a.goalId).filter((id): id is string => id != null),
),
];
const projectMap = new Map<
string,
{
id: string;
name: string;
description: string | null;
status: string;
goalId: string | null;
workspaces: Array<{
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
}>;
primaryWorkspace: {
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
} | null;
}
>();
const goalMap = new Map<
string,
{
id: string;
title: string;
description: string | null;
level: string;
status: string;
}
>();
if (projectIds.length > 0) {
const workspaceRows = await db
.select()
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(
desc(projectWorkspaces.isPrimary),
asc(projectWorkspaces.createdAt),
asc(projectWorkspaces.id),
);
const workspaceMap = new Map<
string,
Array<(typeof workspaceRows)[number]>
>();
for (const workspace of workspaceRows) {
const existing = workspaceMap.get(workspace.projectId);
if (existing) existing.push(workspace);
else workspaceMap.set(workspace.projectId, [workspace]);
}
const rows = await db
.select({
id: projects.id,
name: projects.name,
description: projects.description,
status: projects.status,
goalId: projects.goalId,
})
.from(projects)
.where(inArray(projects.id, projectIds));
for (const r of rows) {
const projectWorkspaceRows = workspaceMap.get(r.id) ?? [];
const workspaces = projectWorkspaceRows.map((workspace) => ({
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
name: workspace.name,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl ?? null,
repoRef: workspace.repoRef ?? null,
metadata:
(workspace.metadata as Record<string, unknown> | null) ?? null,
isPrimary: workspace.isPrimary,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
}));
const primaryWorkspace =
workspaces.find((workspace) => workspace.isPrimary) ??
workspaces[0] ??
null;
projectMap.set(r.id, {
...r,
workspaces,
primaryWorkspace,
});
// Also collect goalIds from projects
if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId);
}
}
if (goalIds.length > 0) {
const rows = await db
.select({
id: goals.id,
title: goals.title,
description: goals.description,
level: goals.level,
status: goals.status,
})
.from(goals)
.where(inArray(goals.id, goalIds));
for (const r of rows) goalMap.set(r.id, r);
}
return raw.map((a) => ({
...a,
project: a.projectId ? (projectMap.get(a.projectId) ?? null) : null,
goal: a.goalId ? (goalMap.get(a.goalId) ?? null) : null,
}));
},
};
type IssueServiceApi = typeof service & {
updateForCompany: (
id: string,
companyId: string,
data: Parameters<typeof service.update>[1],
dbOrTx?: any,
postCommitActivityPublications?: ActivityPublication[],
postCommitActions?: IssuePostCommitAction[],
) => ReturnType<typeof service.update>;
};
const serviceApi = service as IssueServiceApi;
// A company-scoped wrapper around `update`. It passes the company as a
// guard on every read, lock, and write predicate, so a caller with only
// a company id and an issue id cannot update an issue in another company.
serviceApi.updateForCompany = async (
id,
companyId,
data,
dbOrTx = db,
postCommitActivityPublications,
postCommitActions,
) => {
return service.update(
id,
{ ...data, companyGuard: companyId },
dbOrTx,
postCommitActivityPublications,
postCommitActions,
);
};
return serviceApi;
}