mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
Settle paid operations through operation-scoped transactions. Preserve exact ledger evidence and replay while moving aggregate budget decisions to durable deferred work. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2067 lines
74 KiB
TypeScript
2067 lines
74 KiB
TypeScript
import { notifyExternalConversationSettlement } from "../external-conversation-settlement-notifications.js";
|
|
import { readRuntimeAccountingTotals } from "../runtime-accounting-totals.js";
|
|
import { conversationEventCondition, conversationEventColumns } from "./conversation-events.js";
|
|
import { deriveTaskKey } from "./run-preparation.js";
|
|
import { retryIdempotentDatabaseOperation } from "../../database-retry.js";
|
|
import { isConversation } from "../agent-conversations.js";
|
|
import fs from "node:fs/promises";
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
getTableColumns,
|
|
gt,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
type BillingType,
|
|
type CostStatus,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
agents,
|
|
agentConfigRevisions,
|
|
agentRuntimeState,
|
|
agentTaskSessions,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
workspaceRestoreFailureCodesSql,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import { notFound } from "../../errors.js";
|
|
import { logger } from "../../middleware/logger.js";
|
|
import { getServerAdapter } from "../../adapters/index.js";
|
|
import type {
|
|
AdapterExecutionResult,
|
|
AdapterSessionCodec,
|
|
UsageSummary,
|
|
} from "../../adapters/index.js";
|
|
import {
|
|
parseObject,
|
|
asNumber,
|
|
} from "../../adapters/utils.js";
|
|
import {
|
|
HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS,
|
|
HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS,
|
|
HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES,
|
|
summarizeRunErrorForModel,
|
|
} from "../heartbeat-run-summary.js";
|
|
import {
|
|
hasSessionCompactionThresholds,
|
|
resolveSessionCompactionPolicy,
|
|
type SessionCompactionPolicy,
|
|
} from "@paperclipai/adapter-utils";
|
|
import { isUuidLike } from "@paperclipai/shared";
|
|
import { HEARTBEAT_RUN_LIST_DEFAULT_LIMIT, HEARTBEAT_RUN_LIST_MAX_LIMIT } from "@paperclipai/shared";
|
|
import { isUnsafeSessionWorkspaceCwd } from "../session-workspace-cwd.js";
|
|
|
|
export const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON =
|
|
"execution_review_participant_recovery";
|
|
|
|
const heartbeatRunProcessGroupIdColumn =
|
|
heartbeatRuns.processGroupId ?? sql<number | null>`NULL`.as("processGroupId");
|
|
|
|
const heartbeatRunListColumns = {
|
|
id: heartbeatRuns.id,
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
scopeKind: heartbeatRuns.scopeKind,
|
|
issueId: heartbeatRuns.issueId,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
triggerDetail: heartbeatRuns.triggerDetail,
|
|
status: heartbeatRuns.status,
|
|
inputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"),
|
|
cachedInputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"),
|
|
outputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"),
|
|
totalTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"),
|
|
costUsd: sql<number | null>`coalesce(
|
|
(${heartbeatRuns.resultJson} ->> 'costUsd')::numeric,
|
|
(${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric,
|
|
(${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric
|
|
)`.as("costUsd"),
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
error: heartbeatRuns.error,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
exitCode: heartbeatRuns.exitCode,
|
|
signal: heartbeatRuns.signal,
|
|
usageJson: heartbeatRuns.usageJson,
|
|
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
|
logStore: heartbeatRuns.logStore,
|
|
logRef: heartbeatRuns.logRef,
|
|
logBytes: heartbeatRuns.logBytes,
|
|
logSha256: heartbeatRuns.logSha256,
|
|
logCompressed: heartbeatRuns.logCompressed,
|
|
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
|
|
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
|
|
errorCode: heartbeatRuns.errorCode,
|
|
externalRunId: heartbeatRuns.externalRunId,
|
|
processPid: heartbeatRuns.processPid,
|
|
processGroupId: heartbeatRunProcessGroupIdColumn,
|
|
processStartedAt: heartbeatRuns.processStartedAt,
|
|
lastOutputAt: heartbeatRuns.lastOutputAt,
|
|
lastOutputSeq: heartbeatRuns.lastOutputSeq,
|
|
lastOutputStream: heartbeatRuns.lastOutputStream,
|
|
lastOutputBytes: heartbeatRuns.lastOutputBytes,
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
processLossRetryCount: heartbeatRuns.processLossRetryCount,
|
|
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
|
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
|
livenessState: heartbeatRuns.livenessState,
|
|
livenessReason: heartbeatRuns.livenessReason,
|
|
continuationAttempt: heartbeatRuns.continuationAttempt,
|
|
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
|
nextAction: heartbeatRuns.nextAction,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
updatedAt: heartbeatRuns.updatedAt,
|
|
} as const;
|
|
|
|
const heartbeatRunSummaryListColumns = {
|
|
...heartbeatRunListColumns,
|
|
usageJson: sql<Record<string, unknown> | null>`NULL`.as("usageJson"),
|
|
sessionIdBefore: sql<string | null>`NULL`.as("sessionIdBefore"),
|
|
sessionIdAfter: sql<string | null>`NULL`.as("sessionIdAfter"),
|
|
logStore: sql<string | null>`NULL`.as("logStore"),
|
|
logRef: sql<string | null>`NULL`.as("logRef"),
|
|
logSha256: sql<string | null>`NULL`.as("logSha256"),
|
|
externalRunId: sql<string | null>`NULL`.as("externalRunId"),
|
|
processPid: sql<number | null>`NULL`.as("processPid"),
|
|
processGroupId: sql<number | null>`NULL`.as("processGroupId"),
|
|
resultJson: sql<Record<string, unknown> | null>`NULL`.as("resultJson"),
|
|
} as const;
|
|
|
|
const heartbeatRunListContextRecord = sql`lateral jsonb_to_record(
|
|
case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object'
|
|
then ${heartbeatRuns.contextSnapshot} else '{}'::jsonb end
|
|
) as run_context("issueId" text, "taskId" text, "taskKey" text,
|
|
"commentId" text, "wakeCommentId" text, "wakeReason" text,
|
|
"wakeSource" text, "wakeTriggerDetail" text)`;
|
|
|
|
const heartbeatRunListContextColumns = {
|
|
contextIssueId: sql<string | null>`coalesce(
|
|
${heartbeatRuns.issueId}::text,
|
|
run_context."issueId"
|
|
)`.as("contextIssueId"),
|
|
contextTaskId: sql<string | null>`run_context."taskId"`.as("contextTaskId"),
|
|
contextTaskKey: sql<string | null>`run_context."taskKey"`.as("contextTaskKey"),
|
|
contextCommentId: sql<string | null>`run_context."commentId"`.as("contextCommentId"),
|
|
contextWakeCommentId: sql<string | null>`run_context."wakeCommentId"`.as("contextWakeCommentId"),
|
|
contextWakeReason: sql<string | null>`run_context."wakeReason"`.as("contextWakeReason"),
|
|
contextWakeSource: sql<string | null>`run_context."wakeSource"`.as("contextWakeSource"),
|
|
contextWakeTriggerDetail: sql<string | null>`run_context."wakeTriggerDetail"`.as("contextWakeTriggerDetail"),
|
|
} as const;
|
|
|
|
const heartbeatRunListResultColumns = {
|
|
resultSummary: sql<
|
|
string | null
|
|
>`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as(
|
|
"resultSummary",
|
|
),
|
|
resultResult: sql<
|
|
string | null
|
|
>`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as(
|
|
"resultResult",
|
|
),
|
|
resultMessage: sql<
|
|
string | null
|
|
>`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as(
|
|
"resultMessage",
|
|
),
|
|
resultError: sql<
|
|
string | null
|
|
>`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as(
|
|
"resultError",
|
|
),
|
|
resultTotalCostUsd: sql<
|
|
string | null
|
|
>`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"),
|
|
resultCostUsd: sql<
|
|
string | null
|
|
>`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"),
|
|
resultCostUsdCamel: sql<
|
|
string | null
|
|
>`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"),
|
|
} as const;
|
|
|
|
// Reserve at most 9 KiB for diagnostics in the reduced result. An oversized
|
|
// multibyte field uses a conservative four-byte-per-character prefix, with a
|
|
// visible pointer to the full (adapter-bounded) run error and transcript.
|
|
const diagnosticRetrievalTitleBytes = 1024;
|
|
|
|
const diagnosticRetrievalDetailsBytes = 8192;
|
|
|
|
function boundedRunDiagnosticText(field: "title" | "details", maxBytes: number) {
|
|
const value = sql`${heartbeatRuns.resultJson} #>> ARRAY['terminalSessionFailure', ${field}]`;
|
|
return sql`case when octet_length(${value}) <= ${maxBytes} then ${value}
|
|
else left(${value}, ${Math.floor((maxBytes - 100) / 4)})
|
|
|| E'\\n[truncated for run retrieval; full text in run error/transcript]' end`;
|
|
}
|
|
|
|
const heartbeatRunSafeResultJsonColumn = sql<Record<string, unknown> | null>`
|
|
case
|
|
when ${heartbeatRuns.resultJson} is null then null
|
|
when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES}
|
|
then ${heartbeatRuns.resultJson}
|
|
else jsonb_strip_nulls(
|
|
jsonb_build_object(
|
|
'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
|
|
'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
|
|
'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
|
|
'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
|
|
'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}),
|
|
'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}),
|
|
'terminalSessionFailure', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'terminalSessionFailure') = 'object'
|
|
then jsonb_strip_nulls(jsonb_build_object(
|
|
'category', left(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,category}', 32),
|
|
'title', ${boundedRunDiagnosticText("title", diagnosticRetrievalTitleBytes)},
|
|
'details', ${boundedRunDiagnosticText("details", diagnosticRetrievalDetailsBytes)},
|
|
'retrievalTruncated', case when
|
|
octet_length(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,title}') > ${diagnosticRetrievalTitleBytes}
|
|
or octet_length(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,details}') > ${diagnosticRetrievalDetailsBytes}
|
|
then to_jsonb(true) end,
|
|
'truncatedFields', case when ${heartbeatRuns.resultJson} #> '{terminalSessionFailure,truncatedFields}'
|
|
in ('["title"]'::jsonb, '["details"]'::jsonb, '["title","details"]'::jsonb)
|
|
then ${heartbeatRuns.resultJson} #> '{terminalSessionFailure,truncatedFields}' end
|
|
)) end,
|
|
'instructionSave', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'instructionSave') = 'object'
|
|
then jsonb_strip_nulls(jsonb_build_object(
|
|
'state', left(${heartbeatRuns.resultJson} #>> '{instructionSave,state}', 32),
|
|
'contract', left(${heartbeatRuns.resultJson} #>> '{instructionSave,contract}', 32),
|
|
'entryFile', left(${heartbeatRuns.resultJson} #>> '{instructionSave,entryFile}', 512),
|
|
'errorCode', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorCode}', 128),
|
|
'errorMessage', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorMessage}', 1024),
|
|
'storageWarning', left(${heartbeatRuns.resultJson} #>> '{instructionSave,storageWarning}', 1024)
|
|
)) end,
|
|
'workspaceRestoreRecovery', case when ${heartbeatRuns.resultJson} #>> '{workspaceRestoreRecovery,schema}' = 'paperclip.workspace-restore-recovery.v1'
|
|
then jsonb_build_object('schema', 'paperclip.workspace-restore-recovery.v1') end,
|
|
'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure'
|
|
in (${workspaceRestoreFailureCodesSql})
|
|
then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end,
|
|
'cancellation', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'cancellation') = 'object'
|
|
then jsonb_strip_nulls(jsonb_build_object(
|
|
'source', case when ${heartbeatRuns.resultJson} #>> '{cancellation,source}'
|
|
in ('operator', 'queued_message', 'shutdown', 'provider', 'transport', 'control_plane', 'controller_lease', 'unknown')
|
|
then ${heartbeatRuns.resultJson} #> '{cancellation,source}' end,
|
|
'expected', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{cancellation,expected}') = 'boolean'
|
|
then ${heartbeatRuns.resultJson} #> '{cancellation,expected}' end,
|
|
'initiator', jsonb_strip_nulls(jsonb_build_object(
|
|
'type', case when ${heartbeatRuns.resultJson} #>> '{cancellation,initiator,type}' in ('user', 'agent', 'system', 'provider')
|
|
then ${heartbeatRuns.resultJson} #> '{cancellation,initiator,type}' end,
|
|
'id', left(${heartbeatRuns.resultJson} #>> '{cancellation,initiator,id}', 128)
|
|
)),
|
|
'reason', left(${heartbeatRuns.resultJson} #>> '{cancellation,reason}', 512),
|
|
'recordedAt', left(${heartbeatRuns.resultJson} #>> '{cancellation,recordedAt}', 64)
|
|
)) end,
|
|
'acpToolInventoryComplete', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete') = 'boolean'
|
|
then ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete' end,
|
|
'acpPendingToolCount', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'acpPendingToolCount') = 'number'
|
|
and length(${heartbeatRuns.resultJson} ->> 'acpPendingToolCount') < 16
|
|
then ${heartbeatRuns.resultJson} -> 'acpPendingToolCount' end,
|
|
'errorFamily', left(${heartbeatRuns.resultJson} ->> 'errorFamily', 32),
|
|
'finalResponseRecorded', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'finalResponseRecorded') = 'boolean'
|
|
then ${heartbeatRuns.resultJson} -> 'finalResponseRecorded' end,
|
|
'executionBeforeRestore', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'executionBeforeRestore') = 'object'
|
|
then jsonb_strip_nulls(jsonb_build_object(
|
|
'errorCode', left(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,errorCode}', 128),
|
|
'exitCode', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{executionBeforeRestore,exitCode}') = 'number'
|
|
and length(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,exitCode}') < 16
|
|
then ${heartbeatRuns.resultJson} #> '{executionBeforeRestore,exitCode}' end,
|
|
'signal', left(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,signal}', 50),
|
|
'timedOut', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{executionBeforeRestore,timedOut}') = 'boolean'
|
|
then ${heartbeatRuns.resultJson} #> '{executionBeforeRestore,timedOut}' end
|
|
)) end,
|
|
'stdoutTruncated', case
|
|
when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}
|
|
then to_jsonb(true)
|
|
else null
|
|
end,
|
|
'stderrTruncated', case
|
|
when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}
|
|
then to_jsonb(true)
|
|
else null
|
|
end,
|
|
'costUsd', coalesce(
|
|
${heartbeatRuns.resultJson} -> 'costUsd',
|
|
${heartbeatRuns.resultJson} -> 'cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'total_cost_usd'
|
|
),
|
|
'cost_usd', coalesce(
|
|
${heartbeatRuns.resultJson} -> 'cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'costUsd',
|
|
${heartbeatRuns.resultJson} -> 'total_cost_usd'
|
|
),
|
|
'total_cost_usd', coalesce(
|
|
${heartbeatRuns.resultJson} -> 'total_cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'costUsd'
|
|
),
|
|
'truncated', true,
|
|
'truncationReason', 'oversized_result_json',
|
|
'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson})
|
|
)
|
|
)
|
|
end
|
|
`.as("resultJson");
|
|
|
|
// Execution admission needs retained server receipts even when presentation
|
|
// projection omits resultJson (SQL_ASCII or oversized provider output). Select
|
|
// only the evidence used by eligibility; never retrieve provider diagnostics.
|
|
const heartbeatRunExecutionEvidenceColumn = sql<Record<string, unknown> | null>`
|
|
case when ${heartbeatRuns.resultJson} is null then null else jsonb_build_object(
|
|
'startupCancellation', ${heartbeatRuns.resultJson} -> 'startupCancellation',
|
|
'startupPreparationSettledAt', ${heartbeatRuns.resultJson} -> 'startupPreparationSettledAt',
|
|
'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason',
|
|
'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource',
|
|
'workspaceRestoreFailure', ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure',
|
|
'workspaceRestoreRecovery', ${heartbeatRuns.resultJson} -> 'workspaceRestoreRecovery',
|
|
'executionRecovery', jsonb_build_object(
|
|
'kind', ${heartbeatRuns.resultJson} #> '{executionRecovery,kind}',
|
|
'providerWorkStarted', ${heartbeatRuns.resultJson} #> '{executionRecovery,providerWorkStarted}'),
|
|
'executionCancellation', ${heartbeatRuns.resultJson} -> 'executionCancellation',
|
|
'nativeCancellation', ${heartbeatRuns.resultJson} -> 'nativeCancellation',
|
|
'cancelledByActorType', ${heartbeatRuns.resultJson} -> 'cancelledByActorType',
|
|
'cancelledByUserId', ${heartbeatRuns.resultJson} -> 'cancelledByUserId',
|
|
'conversationContinuation', ${heartbeatRuns.resultJson} -> 'conversationContinuation',
|
|
'cancellation', ${heartbeatRuns.resultJson} -> 'cancellation',
|
|
'acpToolInventoryComplete', ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete',
|
|
'acpPendingToolCount', ${heartbeatRuns.resultJson} -> 'acpPendingToolCount'
|
|
) end
|
|
`.as("resultJson");
|
|
|
|
const heartbeatRunSafeColumns = {
|
|
...getTableColumns(heartbeatRuns),
|
|
processGroupId: heartbeatRunProcessGroupIdColumn,
|
|
resultJson: heartbeatRunSafeResultJsonColumn,
|
|
} as const;
|
|
|
|
const heartbeatRunSqlAsciiSafeColumns = {
|
|
...getTableColumns(heartbeatRuns),
|
|
processGroupId: heartbeatRunProcessGroupIdColumn,
|
|
error: sql<string | null>`NULL`.as("error"),
|
|
resultJson: sql<Record<string, unknown> | null>`NULL`.as("resultJson"),
|
|
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
|
|
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
|
|
} as const;
|
|
|
|
const heartbeatRunReadAccessColumns = {
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
scopeKind: heartbeatRuns.scopeKind,
|
|
issueId: heartbeatRuns.issueId,
|
|
} as const;
|
|
|
|
const heartbeatRunLogAccessColumns = {
|
|
...heartbeatRunReadAccessColumns,
|
|
logStore: heartbeatRuns.logStore,
|
|
logRef: heartbeatRuns.logRef,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
status: heartbeatRuns.status,
|
|
logBytes: heartbeatRuns.logBytes,
|
|
} as const;
|
|
|
|
const heartbeatRunIssueControlColumns = {
|
|
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
|
|
runtimeModeResolvedAt: heartbeatRuns.runtimeModeResolvedAt,
|
|
startedAt: heartbeatRuns.startedAt, createdAt: heartbeatRuns.createdAt,
|
|
contextSnapshot: sql<Record<string, unknown> | null>`case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object'
|
|
then jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId') else null end`,
|
|
runnerProfileJson: sql<Record<string, unknown> | null>`case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object'
|
|
then jsonb_build_object('adapterDispatch', jsonb_build_object('adapterType', ${heartbeatRuns.runnerProfileJson}->'adapterDispatch'->'adapterType'))
|
|
else null end`,
|
|
} as const;
|
|
|
|
const heartbeatRunIssueSummaryColumns = {
|
|
id: heartbeatRuns.id,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
queuedCommentSourceWakeIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`.as("queuedCommentSourceWakeIds"),
|
|
status: heartbeatRuns.status,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
triggerDetail: heartbeatRuns.triggerDetail,
|
|
contextCommentId: sql<
|
|
string | null
|
|
>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
|
|
contextWakeCommentId: sql<
|
|
string | null
|
|
>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as(
|
|
"contextWakeCommentId",
|
|
),
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
agentId: heartbeatRuns.agentId,
|
|
logBytes: heartbeatRuns.logBytes,
|
|
processStartedAt: heartbeatRuns.processStartedAt,
|
|
livenessState: heartbeatRuns.livenessState,
|
|
livenessReason: heartbeatRuns.livenessReason,
|
|
continuationAttempt: heartbeatRuns.continuationAttempt,
|
|
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
|
nextAction: heartbeatRuns.nextAction,
|
|
lastOutputAt: heartbeatRuns.lastOutputAt,
|
|
lastOutputSeq: heartbeatRuns.lastOutputSeq,
|
|
lastOutputStream: heartbeatRuns.lastOutputStream,
|
|
lastOutputBytes: heartbeatRuns.lastOutputBytes,
|
|
issueId: heartbeatRuns.issueId,
|
|
} as const;
|
|
|
|
export type UsageTotals = {
|
|
inputTokens: number;
|
|
cachedInputTokens: number;
|
|
outputTokens: number;
|
|
};
|
|
|
|
type SessionCompactionDecision = {
|
|
rotate: boolean;
|
|
reason: string | null;
|
|
handoffMarkdown: string | null;
|
|
previousRunId: string | null;
|
|
};
|
|
|
|
export function summarizeHeartbeatRunContextSnapshot(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
): Record<string, unknown> | null {
|
|
const summary: Record<string, unknown> = {};
|
|
const allowedKeys = [
|
|
"issueId",
|
|
"taskId",
|
|
"taskKey",
|
|
"commentId",
|
|
"wakeCommentId",
|
|
"wakeReason",
|
|
"wakeSource",
|
|
"wakeTriggerDetail",
|
|
] as const;
|
|
|
|
for (const key of allowedKeys) {
|
|
const value = readNonEmptyString(contextSnapshot?.[key]);
|
|
if (value) summary[key] = value;
|
|
}
|
|
|
|
return Object.keys(summary).length > 0 ? summary : null;
|
|
}
|
|
|
|
export function summarizeHeartbeatRunListResultJson(input: {
|
|
summary?: string | null;
|
|
result?: string | null;
|
|
message?: string | null;
|
|
error?: string | null;
|
|
totalCostUsd?: string | null;
|
|
costUsd?: string | null;
|
|
costUsdCamel?: string | null;
|
|
}): Record<string, unknown> | null {
|
|
const summary: Record<string, unknown> = {};
|
|
for (const [key, value] of [
|
|
["summary", input.summary],
|
|
["result", input.result],
|
|
["message", input.message],
|
|
["error", input.error],
|
|
] as const) {
|
|
const normalized = readNonEmptyString(value);
|
|
if (normalized) summary[key] = normalized;
|
|
}
|
|
|
|
for (const [key, value] of [
|
|
["total_cost_usd", input.totalCostUsd],
|
|
["cost_usd", input.costUsd],
|
|
["costUsd", input.costUsdCamel],
|
|
] as const) {
|
|
const normalized = readNonEmptyString(value);
|
|
if (!normalized) continue;
|
|
const parsed = Number(normalized);
|
|
if (Number.isFinite(parsed)) summary[key] = parsed;
|
|
}
|
|
|
|
return Object.keys(summary).length > 0 ? summary : null;
|
|
}
|
|
|
|
export function normalizeLedgerBillingType(value: unknown): BillingType {
|
|
const raw = readNonEmptyString(value);
|
|
switch (raw) {
|
|
case "api":
|
|
case "metered_api":
|
|
return "metered_api";
|
|
case "subscription":
|
|
case "subscription_included":
|
|
return "subscription_included";
|
|
case "subscription_overage":
|
|
return "subscription_overage";
|
|
case "credits":
|
|
return "credits";
|
|
case "fixed":
|
|
return "fixed";
|
|
default:
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
export function resolveLedgerBiller(result: AdapterExecutionResult): string {
|
|
return (
|
|
readNonEmptyString(result.biller) ??
|
|
readNonEmptyString(result.provider) ??
|
|
"unknown"
|
|
);
|
|
}
|
|
|
|
export function normalizeBilledCostCents(
|
|
costUsd: number | null | undefined,
|
|
billingType: BillingType,
|
|
): number {
|
|
if (billingType === "subscription_included") return 0;
|
|
if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0;
|
|
return Math.max(0, Number((costUsd * 100).toFixed(7)));
|
|
}
|
|
|
|
export function resolveLedgerCostStatus(input: {
|
|
costUsd: number | null | undefined;
|
|
billingType?: BillingType;
|
|
inputTokens: number;
|
|
cachedInputTokens: number;
|
|
outputTokens: number;
|
|
}): CostStatus {
|
|
if (input.billingType === "subscription_included") return "reported";
|
|
// A paused turn can have neither a token receipt nor a cost receipt. Zero
|
|
// normalized counters do not establish that its billed cost was zero.
|
|
return typeof input.costUsd === "number" &&
|
|
Number.isFinite(input.costUsd) &&
|
|
input.costUsd >= 0
|
|
? "reported"
|
|
: "unpriced";
|
|
}
|
|
|
|
export function resolveCacheAdjustedCostUsd(input: {
|
|
costUsd?: number | null;
|
|
cacheAdjustedCostUsd?: number | null;
|
|
}) {
|
|
const explicit = input.cacheAdjustedCostUsd;
|
|
if (
|
|
typeof explicit === "number" &&
|
|
Number.isFinite(explicit) &&
|
|
explicit >= 0
|
|
) {
|
|
return explicit;
|
|
}
|
|
const reported = input.costUsd;
|
|
if (
|
|
typeof reported === "number" &&
|
|
Number.isFinite(reported) &&
|
|
reported >= 0
|
|
) {
|
|
return reported;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function resolveLedgerScopeForRun(
|
|
db: Db,
|
|
companyId: string,
|
|
run: typeof heartbeatRuns.$inferSelect,
|
|
) {
|
|
const context = parseObject(run.contextSnapshot);
|
|
const contextIssueId = readNonEmptyString(context.issueId);
|
|
const contextProjectId = readNonEmptyString(context.projectId);
|
|
|
|
if (!contextIssueId) {
|
|
return {
|
|
issueId: null,
|
|
projectId: contextProjectId,
|
|
billingCode: null,
|
|
};
|
|
}
|
|
|
|
const issue = await db
|
|
.select({
|
|
id: issues.id,
|
|
projectId: issues.projectId,
|
|
billingCode: issues.billingCode,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.id, contextIssueId), eq(issues.companyId, companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return {
|
|
issueId: issue?.id ?? null,
|
|
projectId: issue?.projectId ?? contextProjectId,
|
|
billingCode: issue?.billingCode ?? null,
|
|
};
|
|
}
|
|
|
|
type ResumeSessionRow = {
|
|
sessionParamsJson: Record<string, unknown> | null;
|
|
sessionDisplayId: string | null;
|
|
lastRunId: string | null;
|
|
};
|
|
|
|
export function buildExplicitResumeSessionOverride(input: {
|
|
adapterType?: string | null;
|
|
resumeFromRunId: string;
|
|
resumeRunSessionIdBefore: string | null;
|
|
resumeRunSessionIdAfter: string | null;
|
|
resumeRunSessionParams?: Record<string, unknown> | null;
|
|
taskSession: ResumeSessionRow | null;
|
|
sessionCodec: AdapterSessionCodec;
|
|
}) {
|
|
const resumeRunSessionIdAfter = truncateDisplayId(
|
|
input.resumeRunSessionIdAfter,
|
|
);
|
|
const resumeRunSessionIdBefore = truncateDisplayId(
|
|
input.resumeRunSessionIdBefore,
|
|
);
|
|
const desiredDisplayId = requiresCanonicalSessionIds(input.adapterType)
|
|
? isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdAfter)
|
|
? resumeRunSessionIdAfter
|
|
: isCanonicalSessionIdForAdapter(
|
|
input.adapterType,
|
|
resumeRunSessionIdBefore,
|
|
)
|
|
? resumeRunSessionIdBefore
|
|
: null
|
|
: (resumeRunSessionIdAfter ?? resumeRunSessionIdBefore);
|
|
const runSessionParams = requiresCanonicalSessionIds(input.adapterType)
|
|
? normalizeResumeParamsForAdapter(
|
|
input.adapterType,
|
|
input.sessionCodec.deserialize(input.resumeRunSessionParams ?? null),
|
|
)
|
|
: null;
|
|
const runSessionDisplayId = truncateDisplayId(
|
|
readNonEmptyString(runSessionParams?.sessionId),
|
|
);
|
|
const taskSessionParams = normalizeResumeParamsForAdapter(
|
|
input.adapterType,
|
|
input.sessionCodec.deserialize(
|
|
input.taskSession?.sessionParamsJson ?? null,
|
|
),
|
|
);
|
|
const taskSessionRawDisplayId = input.taskSession?.sessionDisplayId ?? null;
|
|
const taskSessionDisplayId = truncateDisplayId(
|
|
requiresCanonicalSessionIds(input.adapterType)
|
|
? (readNonEmptyString(taskSessionParams?.sessionId) ??
|
|
(isCanonicalSessionIdForAdapter(
|
|
input.adapterType,
|
|
taskSessionRawDisplayId,
|
|
)
|
|
? taskSessionRawDisplayId
|
|
: null))
|
|
: (taskSessionRawDisplayId ??
|
|
(input.sessionCodec.getDisplayId
|
|
? input.sessionCodec.getDisplayId(taskSessionParams)
|
|
: null) ??
|
|
readNonEmptyString(taskSessionParams?.sessionId)),
|
|
);
|
|
const canReuseTaskSessionParams =
|
|
input.taskSession != null &&
|
|
(!requiresCanonicalSessionIds(input.adapterType) ||
|
|
taskSessionParams != null) &&
|
|
(input.taskSession.lastRunId === input.resumeFromRunId ||
|
|
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId));
|
|
const sessionParams = canReuseTaskSessionParams
|
|
? taskSessionParams
|
|
: runSessionParams
|
|
? runSessionParams
|
|
: desiredDisplayId
|
|
? { sessionId: desiredDisplayId }
|
|
: null;
|
|
const sessionDisplayId = canReuseTaskSessionParams
|
|
? taskSessionDisplayId
|
|
: runSessionParams
|
|
? runSessionDisplayId
|
|
: desiredDisplayId;
|
|
|
|
if (!sessionDisplayId && !sessionParams) return null;
|
|
return {
|
|
sessionDisplayId,
|
|
sessionParams,
|
|
};
|
|
}
|
|
|
|
export function normalizeUsageTotals(
|
|
usage: UsageSummary | null | undefined,
|
|
): UsageTotals | null {
|
|
if (!usage) return null;
|
|
return {
|
|
inputTokens: Math.max(0, Math.floor(asNumber(usage.inputTokens, 0))),
|
|
cachedInputTokens: Math.max(
|
|
0,
|
|
Math.floor(asNumber(usage.cachedInputTokens, 0)),
|
|
),
|
|
outputTokens: Math.max(0, Math.floor(asNumber(usage.outputTokens, 0))),
|
|
};
|
|
}
|
|
|
|
function readRawUsageTotals(usageJson: unknown): UsageTotals | null {
|
|
const parsed = parseObject(usageJson);
|
|
if (Object.keys(parsed).length === 0) return null;
|
|
|
|
const inputTokens = Math.max(
|
|
0,
|
|
Math.floor(
|
|
asNumber(parsed.rawInputTokens, asNumber(parsed.inputTokens, 0)),
|
|
),
|
|
);
|
|
const cachedInputTokens = Math.max(
|
|
0,
|
|
Math.floor(
|
|
asNumber(
|
|
parsed.rawCachedInputTokens,
|
|
asNumber(parsed.cachedInputTokens, 0),
|
|
),
|
|
),
|
|
);
|
|
const outputTokens = Math.max(
|
|
0,
|
|
Math.floor(
|
|
asNumber(parsed.rawOutputTokens, asNumber(parsed.outputTokens, 0)),
|
|
),
|
|
);
|
|
|
|
if (inputTokens <= 0 && cachedInputTokens <= 0 && outputTokens <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
inputTokens,
|
|
cachedInputTokens,
|
|
outputTokens,
|
|
};
|
|
}
|
|
|
|
export function normalizeAdapterRunUsage(
|
|
current: UsageTotals | null,
|
|
previous: UsageTotals | null,
|
|
usageBasis?: "per_run" | "session_cumulative" | null,
|
|
): UsageTotals | null {
|
|
if (!current) return null;
|
|
if (!previous || usageBasis !== "session_cumulative") return { ...current };
|
|
|
|
const inputTokens =
|
|
current.inputTokens >= previous.inputTokens
|
|
? current.inputTokens - previous.inputTokens
|
|
: current.inputTokens;
|
|
const cachedInputTokens =
|
|
current.cachedInputTokens >= previous.cachedInputTokens
|
|
? current.cachedInputTokens - previous.cachedInputTokens
|
|
: current.cachedInputTokens;
|
|
const outputTokens =
|
|
current.outputTokens >= previous.outputTokens
|
|
? current.outputTokens - previous.outputTokens
|
|
: current.outputTokens;
|
|
|
|
return {
|
|
inputTokens: Math.max(0, inputTokens),
|
|
cachedInputTokens: Math.max(0, cachedInputTokens),
|
|
outputTokens: Math.max(0, outputTokens),
|
|
};
|
|
}
|
|
|
|
function formatCount(value: number | null | undefined) {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return "0";
|
|
return value.toLocaleString("en-US");
|
|
}
|
|
|
|
export function parseSessionCompactionPolicy(
|
|
agent: typeof agents.$inferSelect,
|
|
): SessionCompactionPolicy {
|
|
return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig)
|
|
.policy;
|
|
}
|
|
|
|
/**
|
|
* Synthetic task key for timer/heartbeat wakes that have no issue context.
|
|
* This allows timer wakes to participate in the `agentTaskSessions` system
|
|
* and benefit from robust session resume, instead of relying solely on the
|
|
* simpler `agentRuntimeState.sessionId` fallback.
|
|
*/
|
|
const HEARTBEAT_TASK_KEY = "__heartbeat__";
|
|
|
|
/**
|
|
* Extended task key derivation that falls back to a stable synthetic key
|
|
* for timer/heartbeat wakes. The synthetic key keeps the
|
|
* `agentTaskSessions` row addressable across heartbeats so the row can be
|
|
* cleared and re-keyed deterministically. Unscoped exploratory timer wakes
|
|
* still start fresh to avoid accumulating low-value inbox scans, while timer
|
|
* wakes scoped to a real issue reuse that issue's task session.
|
|
*
|
|
* The synthetic key is only used when:
|
|
* - No explicit task/issue key exists in the context
|
|
* - The wake source is "timer" (scheduled heartbeat)
|
|
*/
|
|
export function deriveTaskKeyWithHeartbeatFallback(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
payload: Record<string, unknown> | null | undefined,
|
|
) {
|
|
const explicit = deriveTaskKey(contextSnapshot, payload);
|
|
if (explicit) return explicit;
|
|
|
|
const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource);
|
|
if (wakeSource === "timer") return HEARTBEAT_TASK_KEY;
|
|
|
|
return null;
|
|
}
|
|
|
|
export function shouldResetTaskSessionForWake(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
) {
|
|
if (contextSnapshot?.forceFreshSession === true) return true;
|
|
|
|
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
|
if (
|
|
wakeReason === "issue_assigned" ||
|
|
wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON ||
|
|
wakeReason === "execution_approval_requested" ||
|
|
// PF-4: unscoped timer wakes are exploratory ("any new work?") and should
|
|
// not accumulate low-value inbox scans. Issue-scoped timer wakes are
|
|
// continuation work, so reuse their task session to avoid paying the full
|
|
// session-start and re-orientation cost on every heartbeat.
|
|
(wakeReason === "heartbeat_timer" && !deriveTaskKey(contextSnapshot, null))
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function describeSessionResetReason(
|
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
|
) {
|
|
if (contextSnapshot?.forceFreshSession === true)
|
|
return "forceFreshSession was requested";
|
|
|
|
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
|
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
|
|
if (wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON) {
|
|
return `wake reason is ${EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON}`;
|
|
}
|
|
if (wakeReason === "execution_approval_requested")
|
|
return "wake reason is execution_approval_requested";
|
|
// PF-4: paired with shouldResetTaskSessionForWake — keep the reason wording
|
|
// explicit so run logs make session reuse/reset behavior legible.
|
|
if (
|
|
wakeReason === "heartbeat_timer" &&
|
|
!deriveTaskKey(contextSnapshot, null)
|
|
) {
|
|
return "wake reason is heartbeat_timer (unscoped timer wake starts fresh)";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function truncateDisplayId(value: string | null | undefined, max = 128) {
|
|
if (!value) return null;
|
|
return value.length > max ? value.slice(0, max) : value;
|
|
}
|
|
|
|
const defaultSessionCodec: AdapterSessionCodec = {
|
|
deserialize(raw: unknown) {
|
|
const asObj = parseObject(raw);
|
|
if (Object.keys(asObj).length > 0) return asObj;
|
|
const sessionId = readNonEmptyString(
|
|
(raw as Record<string, unknown> | null)?.sessionId,
|
|
);
|
|
if (sessionId) return { sessionId };
|
|
return null;
|
|
},
|
|
serialize(params: Record<string, unknown> | null) {
|
|
if (!params || Object.keys(params).length === 0) return null;
|
|
return params;
|
|
},
|
|
getDisplayId(params: Record<string, unknown> | null) {
|
|
return readNonEmptyString(params?.sessionId);
|
|
},
|
|
};
|
|
|
|
export function getAdapterSessionCodec(adapterType: string) {
|
|
const adapter = getServerAdapter(adapterType);
|
|
return adapter.sessionCodec ?? defaultSessionCodec;
|
|
}
|
|
|
|
export function normalizeSessionParams(
|
|
params: Record<string, unknown> | null | undefined,
|
|
) {
|
|
if (!params) return null;
|
|
return Object.keys(params).length > 0 ? params : null;
|
|
}
|
|
|
|
export type RunSessionOutcome =
|
|
"succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out";
|
|
|
|
const HERMES_ADAPTER_TYPE = "hermes_local";
|
|
|
|
const HERMES_SESSION_ID_REGEX =
|
|
/^(?:\d{8}_\d{6}_[A-Za-z0-9_-]{4,}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
|
|
|
|
export function requiresCanonicalSessionIds(adapterType: string | null | undefined) {
|
|
return adapterType === HERMES_ADAPTER_TYPE;
|
|
}
|
|
|
|
export function isCanonicalSessionIdForAdapter(
|
|
adapterType: string | null | undefined,
|
|
sessionId: string | null | undefined,
|
|
) {
|
|
if (!sessionId) return false;
|
|
if (!requiresCanonicalSessionIds(adapterType)) return true;
|
|
return HERMES_SESSION_ID_REGEX.test(sessionId);
|
|
}
|
|
|
|
export function normalizeResumeParamsForAdapter(
|
|
adapterType: string | null | undefined,
|
|
params: Record<string, unknown> | null | undefined,
|
|
) {
|
|
const normalized = normalizeSessionParams(params);
|
|
if (!normalized) return null;
|
|
if (!requiresCanonicalSessionIds(adapterType)) return normalized;
|
|
const sessionId = readNonEmptyString(normalized.sessionId);
|
|
return isCanonicalSessionIdForAdapter(adapterType, sessionId)
|
|
? normalized
|
|
: null;
|
|
}
|
|
|
|
export function resolveNextSessionState(input: {
|
|
adapterType?: string | null;
|
|
codec: AdapterSessionCodec;
|
|
adapterResult: AdapterExecutionResult;
|
|
outcome: RunSessionOutcome;
|
|
previousParams: Record<string, unknown> | null;
|
|
previousDisplayId: string | null;
|
|
previousLegacySessionId: string | null;
|
|
}) {
|
|
const {
|
|
adapterType,
|
|
codec,
|
|
adapterResult,
|
|
previousParams,
|
|
previousDisplayId,
|
|
previousLegacySessionId,
|
|
} = input;
|
|
|
|
if (adapterResult.clearSession) {
|
|
return {
|
|
params: null as Record<string, unknown> | null,
|
|
displayId: null as string | null,
|
|
legacySessionId: null as string | null,
|
|
};
|
|
}
|
|
|
|
if (!requiresCanonicalSessionIds(adapterType)) {
|
|
const explicitParams = adapterResult.sessionParams;
|
|
const hasExplicitParams = adapterResult.sessionParams !== undefined;
|
|
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
|
|
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
|
|
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
|
|
const explicitDisplayId = readNonEmptyString(
|
|
adapterResult.sessionDisplayId,
|
|
);
|
|
const shouldUsePrevious =
|
|
!hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay;
|
|
|
|
const candidateParams = hasExplicitParams
|
|
? explicitParams
|
|
: hasExplicitSessionId
|
|
? explicitSessionId
|
|
? { sessionId: explicitSessionId }
|
|
: null
|
|
: previousParams;
|
|
|
|
const serialized = normalizeSessionParams(
|
|
codec.serialize(normalizeSessionParams(candidateParams) ?? null),
|
|
);
|
|
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
|
|
|
|
const displayId = truncateDisplayId(
|
|
explicitDisplayId ??
|
|
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
|
|
readNonEmptyString(deserialized?.sessionId) ??
|
|
(shouldUsePrevious ? previousDisplayId : null) ??
|
|
explicitSessionId ??
|
|
(shouldUsePrevious ? previousLegacySessionId : null),
|
|
);
|
|
|
|
const legacySessionId =
|
|
explicitSessionId ??
|
|
readNonEmptyString(deserialized?.sessionId) ??
|
|
displayId ??
|
|
(shouldUsePrevious ? previousLegacySessionId : null);
|
|
|
|
return {
|
|
params: serialized,
|
|
displayId,
|
|
legacySessionId,
|
|
};
|
|
}
|
|
|
|
const previousSerializedParams = normalizeResumeParamsForAdapter(
|
|
adapterType,
|
|
codec.serialize(
|
|
normalizeResumeParamsForAdapter(adapterType, previousParams),
|
|
),
|
|
);
|
|
const validPreviousDisplayId = isCanonicalSessionIdForAdapter(
|
|
adapterType,
|
|
previousDisplayId,
|
|
)
|
|
? previousDisplayId
|
|
: null;
|
|
const validPreviousLegacySessionId = isCanonicalSessionIdForAdapter(
|
|
adapterType,
|
|
previousLegacySessionId,
|
|
)
|
|
? previousLegacySessionId
|
|
: null;
|
|
const previousState = () => {
|
|
const displayId = truncateDisplayId(
|
|
readNonEmptyString(previousSerializedParams?.sessionId) ??
|
|
validPreviousDisplayId ??
|
|
validPreviousLegacySessionId,
|
|
);
|
|
return {
|
|
params: previousSerializedParams,
|
|
displayId,
|
|
legacySessionId:
|
|
readNonEmptyString(previousSerializedParams?.sessionId) ??
|
|
displayId ??
|
|
validPreviousLegacySessionId,
|
|
};
|
|
};
|
|
|
|
if (input.outcome !== "succeeded") {
|
|
return previousState();
|
|
}
|
|
|
|
const explicitParams = adapterResult.sessionParams;
|
|
const hasExplicitParams = adapterResult.sessionParams !== undefined;
|
|
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
|
|
const validExplicitSessionId = isCanonicalSessionIdForAdapter(
|
|
adapterType,
|
|
explicitSessionId,
|
|
)
|
|
? explicitSessionId
|
|
: null;
|
|
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
|
|
const validExplicitDisplayId = isCanonicalSessionIdForAdapter(
|
|
adapterType,
|
|
explicitDisplayId,
|
|
)
|
|
? explicitDisplayId
|
|
: null;
|
|
const explicitSerializedParams = hasExplicitParams
|
|
? normalizeResumeParamsForAdapter(
|
|
adapterType,
|
|
codec.serialize(normalizeSessionParams(explicitParams) ?? null),
|
|
)
|
|
: null;
|
|
const explicitCanonicalSessionId =
|
|
readNonEmptyString(explicitSerializedParams?.sessionId) ??
|
|
validExplicitSessionId ??
|
|
validExplicitDisplayId;
|
|
|
|
if (!explicitCanonicalSessionId) {
|
|
return previousState();
|
|
}
|
|
|
|
const serialized = normalizeResumeParamsForAdapter(
|
|
adapterType,
|
|
codec.serialize({ sessionId: explicitCanonicalSessionId }),
|
|
);
|
|
const displayId = truncateDisplayId(
|
|
readNonEmptyString(serialized?.sessionId) ??
|
|
(codec.getDisplayId ? codec.getDisplayId(serialized) : null) ??
|
|
explicitCanonicalSessionId,
|
|
);
|
|
const legacySessionId =
|
|
readNonEmptyString(serialized?.sessionId) ?? explicitCanonicalSessionId;
|
|
|
|
return {
|
|
params: serialized,
|
|
displayId,
|
|
legacySessionId,
|
|
};
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
/** Run retrieval and session persistence bound to one service database. */
|
|
export function createHeartbeatRunState(db: Db) {
|
|
let unsafeTextProjectionPromise: Promise<boolean> | null = null;
|
|
|
|
async function hasUnsafeTextProjectionDatabase() {
|
|
if (!unsafeTextProjectionPromise) {
|
|
unsafeTextProjectionPromise = db
|
|
.execute(
|
|
sql`select current_setting('server_encoding') as server_encoding`,
|
|
)
|
|
.then((rows) => {
|
|
const first = Array.isArray(rows) ? rows[0] : null;
|
|
const serverEncoding =
|
|
typeof first === "object" && first !== null
|
|
? (first as Record<string, unknown>).server_encoding
|
|
: null;
|
|
return (
|
|
typeof serverEncoding === "string" &&
|
|
serverEncoding.toUpperCase() === "SQL_ASCII"
|
|
);
|
|
})
|
|
.catch((err) => {
|
|
logger.warn(
|
|
{ err },
|
|
"failed to inspect database server encoding; using conservative heartbeat result projection",
|
|
);
|
|
return true;
|
|
});
|
|
}
|
|
return unsafeTextProjectionPromise;
|
|
}
|
|
|
|
async function getAgent(agentId: string) {
|
|
return db
|
|
.select()
|
|
.from(agents)
|
|
.where(eq(agents.id, agentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRun(
|
|
runId: string,
|
|
opts?: { unsafeFullResultJson?: boolean; includeExecutionEvidence?: boolean },
|
|
) {
|
|
const safeForLegacyEncoding =
|
|
!opts?.unsafeFullResultJson && (await hasUnsafeTextProjectionDatabase());
|
|
const columns = opts?.unsafeFullResultJson
|
|
? getTableColumns(heartbeatRuns)
|
|
: safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns : heartbeatRunSafeColumns;
|
|
return db
|
|
.select(opts?.includeExecutionEvidence
|
|
? { ...columns, resultJson: heartbeatRunExecutionEvidenceColumn }
|
|
: columns)
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunExecutionWait(companyId: string, runId: string) {
|
|
const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase();
|
|
return db.select({
|
|
...heartbeatRunIssueControlColumns,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
error: safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns.error : heartbeatRuns.error,
|
|
issueId: heartbeatRuns.issueId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
nativeSessionId: heartbeatRuns.nativeSessionId,
|
|
processPid: heartbeatRuns.processPid,
|
|
processGroupId: heartbeatRunProcessGroupIdColumn,
|
|
processStartedAt: heartbeatRuns.processStartedAt,
|
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
|
executionStage: heartbeatRuns.executionStage,
|
|
controllerBootId: heartbeatRuns.controllerBootId,
|
|
controllerLeaseExpiresAt: heartbeatRuns.controllerLeaseExpiresAt,
|
|
resultJson: heartbeatRunExecutionEvidenceColumn,
|
|
contextSnapshot: sql<Record<string, unknown> | null>`case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object'
|
|
then (select coalesce(jsonb_object_agg(key, value), '{}'::jsonb)
|
|
from jsonb_each(${heartbeatRuns.contextSnapshot})
|
|
where key = any(array['issueId', 'connectionIntentResolution', 'source', 'interactionKind',
|
|
'interactionStatus', 'mutation', 'interactionId'])) else null end`,
|
|
}).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId),
|
|
)).then(rows => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunCleanupState(runId: string) {
|
|
return db.select({
|
|
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
|
runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status,
|
|
errorCode: heartbeatRuns.errorCode, nativePhase: heartbeatRuns.nativePhase,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then(rows => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunWorkspaceSettlement(runId: string) {
|
|
const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase();
|
|
return db.select({
|
|
id: heartbeatRuns.id, status: heartbeatRuns.status,
|
|
error: safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns.error : heartbeatRuns.error,
|
|
runnerProfileJson: sql<Record<string, unknown> | null>`case when ${heartbeatRuns.runnerProfileJson} is null then null
|
|
else jsonb_build_object('nativeWorkspaceSync', ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync',
|
|
'nativeResourceDisposition', ${heartbeatRuns.runnerProfileJson}->'nativeResourceDisposition') end`,
|
|
resultJson: sql<Record<string, unknown> | null>`case when ${heartbeatRuns.resultJson} is null then null
|
|
else jsonb_build_object('workspaceExportRetry', ${heartbeatRuns.resultJson}->'workspaceExportRetry') end`,
|
|
contextSnapshot: sql<Record<string, unknown> | null>`case when ${heartbeatRuns.contextSnapshot} is null then null
|
|
else jsonb_build_object('timerClaimWasFirstHeartbeat', ${heartbeatRuns.contextSnapshot}->'timerClaimWasFirstHeartbeat') end`,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then(rows => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunStatus(runId: string) {
|
|
return db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunReadAccess(runId: string) {
|
|
return db
|
|
.select(heartbeatRunReadAccessColumns)
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunLogAccess(runId: string) {
|
|
return db
|
|
.select(heartbeatRunLogAccessColumns)
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRunWorkspaceAccess(runId: string) {
|
|
return db.select({
|
|
...heartbeatRunReadAccessColumns,
|
|
executionWorkspaceId: sql<unknown>`${heartbeatRuns.contextSnapshot}->'executionWorkspaceId'`,
|
|
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then(rows => rows[0] ?? null);
|
|
}
|
|
|
|
async function getRuntimeState(agentId: string) {
|
|
return db
|
|
.select()
|
|
.from(agentRuntimeState)
|
|
.where(eq(agentRuntimeState.agentId, agentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getLatestAgentConfigRevision(
|
|
companyId: string,
|
|
agentId: string,
|
|
) {
|
|
return db
|
|
.select({
|
|
id: agentConfigRevisions.id,
|
|
changedKeys: agentConfigRevisions.changedKeys,
|
|
createdAt: agentConfigRevisions.createdAt,
|
|
})
|
|
.from(agentConfigRevisions)
|
|
.where(
|
|
and(
|
|
eq(agentConfigRevisions.companyId, companyId),
|
|
eq(agentConfigRevisions.agentId, agentId),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(agentConfigRevisions.createdAt),
|
|
desc(agentConfigRevisions.id),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getTaskSession(
|
|
companyId: string,
|
|
agentId: string,
|
|
adapterType: string,
|
|
taskKey: string,
|
|
) {
|
|
return db
|
|
.select()
|
|
.from(agentTaskSessions)
|
|
.where(
|
|
and(
|
|
eq(agentTaskSessions.companyId, companyId),
|
|
eq(agentTaskSessions.agentId, agentId),
|
|
eq(agentTaskSessions.adapterType, adapterType),
|
|
eq(agentTaskSessions.taskKey, taskKey),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getLatestRunForSession(
|
|
agentId: string,
|
|
sessionId: string,
|
|
opts?: { excludeRunId?: string | null },
|
|
) {
|
|
const conditions = [
|
|
eq(heartbeatRuns.agentId, agentId),
|
|
eq(heartbeatRuns.sessionIdAfter, sessionId),
|
|
];
|
|
if (opts?.excludeRunId) {
|
|
conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`);
|
|
}
|
|
return db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
usageJson: heartbeatRuns.usageJson,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(heartbeatRuns.createdAt))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getOldestRunForSession(agentId: string, sessionId: string) {
|
|
return db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.agentId, agentId),
|
|
eq(heartbeatRuns.sessionIdAfter, sessionId),
|
|
),
|
|
)
|
|
.orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function resolveNormalizedUsageForSession(input: {
|
|
agentId: string;
|
|
runId: string;
|
|
sessionId: string | null;
|
|
rawUsage: UsageTotals | null;
|
|
usageBasis?: "per_run" | "session_cumulative" | null;
|
|
}) {
|
|
const { agentId, runId, sessionId, rawUsage, usageBasis } = input;
|
|
// Adapters that declare per-run usage (e.g. the ACPX lane reports each
|
|
// turn's tokens, not session totals) must not be session-delta'd, or
|
|
// consecutive runs would be undercounted.
|
|
if (!sessionId || !rawUsage || usageBasis !== "session_cumulative") {
|
|
return {
|
|
normalizedUsage: rawUsage,
|
|
previousRawUsage: null as UsageTotals | null,
|
|
derivedFromSessionTotals: false,
|
|
};
|
|
}
|
|
|
|
const previousRun = await getLatestRunForSession(agentId, sessionId, {
|
|
excludeRunId: runId,
|
|
});
|
|
const previousRawUsage = readRawUsageTotals(previousRun?.usageJson);
|
|
return {
|
|
normalizedUsage: normalizeAdapterRunUsage(rawUsage, previousRawUsage, usageBasis),
|
|
previousRawUsage,
|
|
derivedFromSessionTotals: previousRawUsage !== null,
|
|
};
|
|
}
|
|
|
|
async function evaluateSessionCompaction(input: {
|
|
agent: typeof agents.$inferSelect;
|
|
sessionId: string | null;
|
|
issueId: string | null;
|
|
continuationSummaryBody?: string | null;
|
|
}): Promise<SessionCompactionDecision> {
|
|
const { agent, sessionId, issueId } = input;
|
|
if (!sessionId) {
|
|
return {
|
|
rotate: false,
|
|
reason: null,
|
|
handoffMarkdown: null,
|
|
previousRunId: null,
|
|
};
|
|
}
|
|
|
|
const policy = parseSessionCompactionPolicy(agent);
|
|
if (!policy.enabled || !hasSessionCompactionThresholds(policy)) {
|
|
return {
|
|
rotate: false,
|
|
reason: null,
|
|
handoffMarkdown: null,
|
|
previousRunId: null,
|
|
};
|
|
}
|
|
|
|
const fetchLimit = Math.max(
|
|
policy.maxSessionRuns > 0 ? policy.maxSessionRuns + 1 : 0,
|
|
4,
|
|
);
|
|
const runs = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
usageJson: heartbeatRuns.usageJson,
|
|
error: heartbeatRuns.error,
|
|
terminalFailureCategory: sql<string | null>`case
|
|
when jsonb_typeof(${heartbeatRuns.resultJson} -> 'terminalSessionFailure') = 'object'
|
|
then coalesce(left(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,category}', 32), 'unknown') end`,
|
|
...heartbeatRunListResultColumns,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.agentId, agent.id),
|
|
eq(heartbeatRuns.sessionIdAfter, sessionId),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt))
|
|
.limit(fetchLimit);
|
|
|
|
if (runs.length === 0) {
|
|
return {
|
|
rotate: false,
|
|
reason: null,
|
|
handoffMarkdown: null,
|
|
previousRunId: null,
|
|
};
|
|
}
|
|
|
|
const latestRun = runs[0] ?? null;
|
|
const oldestRun =
|
|
policy.maxSessionAgeHours > 0
|
|
? await getOldestRunForSession(agent.id, sessionId)
|
|
: (runs[runs.length - 1] ?? latestRun);
|
|
const latestRawUsage = readRawUsageTotals(latestRun?.usageJson);
|
|
// Historical Codex/Gemini raw input includes cache reads. Only add the
|
|
// separate cache counter when the writer explicitly saved exclusive input.
|
|
const latestRawInputTokens = (latestRawUsage?.inputTokens ?? 0) +
|
|
(latestRun?.usageJson?.rawInputIncludesCached === false ? latestRawUsage?.cachedInputTokens ?? 0 : 0);
|
|
const sessionAgeHours =
|
|
latestRun && oldestRun
|
|
? Math.max(
|
|
0,
|
|
(new Date(latestRun.createdAt).getTime() -
|
|
new Date(oldestRun.createdAt).getTime()) /
|
|
(1000 * 60 * 60),
|
|
)
|
|
: 0;
|
|
|
|
let reason: string | null = null;
|
|
if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) {
|
|
reason = `session exceeded ${policy.maxSessionRuns} runs`;
|
|
} else if (
|
|
policy.maxRawInputTokens > 0 &&
|
|
latestRawUsage &&
|
|
latestRawInputTokens >= policy.maxRawInputTokens
|
|
) {
|
|
reason =
|
|
`session raw input reached ${formatCount(latestRawInputTokens)} tokens ` +
|
|
`(threshold ${formatCount(policy.maxRawInputTokens)})`;
|
|
} else if (
|
|
policy.maxSessionAgeHours > 0 &&
|
|
sessionAgeHours >= policy.maxSessionAgeHours
|
|
) {
|
|
reason = `session age reached ${Math.floor(sessionAgeHours)} hours`;
|
|
}
|
|
|
|
if (!reason || !latestRun) {
|
|
return {
|
|
rotate: false,
|
|
reason: null,
|
|
handoffMarkdown: null,
|
|
previousRunId: latestRun?.id ?? null,
|
|
};
|
|
}
|
|
|
|
const latestSummary = summarizeHeartbeatRunListResultJson({
|
|
summary: latestRun?.resultSummary,
|
|
result: latestRun?.resultResult,
|
|
message: latestRun?.resultMessage,
|
|
error: latestRun?.resultError,
|
|
totalCostUsd: latestRun?.resultTotalCostUsd,
|
|
costUsd: latestRun?.resultCostUsd,
|
|
costUsdCamel: latestRun?.resultCostUsdCamel,
|
|
});
|
|
const latestTextSummary =
|
|
readNonEmptyString(latestSummary?.summary) ??
|
|
readNonEmptyString(latestSummary?.result) ??
|
|
readNonEmptyString(latestSummary?.message) ??
|
|
readNonEmptyString(summarizeRunErrorForModel(latestRun.error, latestRun.terminalFailureCategory));
|
|
|
|
const handoffMarkdown = [
|
|
"Paperclip session handoff:",
|
|
`- Previous session: ${sessionId}`,
|
|
issueId ? `- Issue: ${issueId}` : "",
|
|
`- Rotation reason: ${reason}`,
|
|
latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "",
|
|
input.continuationSummaryBody
|
|
? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}`
|
|
: "",
|
|
"Continue from the current task state. Rebuild only the minimum context you need.",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return {
|
|
rotate: true,
|
|
reason,
|
|
handoffMarkdown,
|
|
previousRunId: latestRun.id,
|
|
};
|
|
}
|
|
|
|
async function resolveSessionBeforeForWakeup(
|
|
agent: typeof agents.$inferSelect,
|
|
taskKey: string | null,
|
|
) {
|
|
if (taskKey) {
|
|
const codec = getAdapterSessionCodec(agent.adapterType);
|
|
const existingTaskSession = await getTaskSession(
|
|
agent.companyId,
|
|
agent.id,
|
|
agent.adapterType,
|
|
taskKey,
|
|
);
|
|
const parsedParams = normalizeSessionParams(
|
|
codec.deserialize(existingTaskSession?.sessionParamsJson ?? null),
|
|
);
|
|
return truncateDisplayId(
|
|
existingTaskSession?.sessionDisplayId ??
|
|
(codec.getDisplayId ? codec.getDisplayId(parsedParams) : null) ??
|
|
readNonEmptyString(parsedParams?.sessionId),
|
|
);
|
|
}
|
|
|
|
const runtimeForRun = await getRuntimeState(agent.id);
|
|
return runtimeForRun?.sessionId ?? null;
|
|
}
|
|
|
|
async function hasResolvableSessionWorkspaceCwd(
|
|
sessionParams: Record<string, unknown> | null | undefined,
|
|
) {
|
|
const cwd = readNonEmptyString(sessionParams?.cwd);
|
|
if (!cwd || isUnsafeSessionWorkspaceCwd(cwd)) return false;
|
|
return fs
|
|
.stat(cwd)
|
|
.then((stats) => stats.isDirectory())
|
|
.catch(() => false);
|
|
}
|
|
|
|
async function hasResolvablePriorSessionWorkspaceForWake(input: {
|
|
agent: typeof agents.$inferSelect;
|
|
contextSnapshot: Record<string, unknown>;
|
|
taskKey: string | null;
|
|
explicitResumeSession: Awaited<
|
|
ReturnType<typeof resolveExplicitResumeSessionOverride>
|
|
> | null;
|
|
}) {
|
|
if (
|
|
await hasResolvableSessionWorkspaceCwd(
|
|
input.explicitResumeSession?.sessionParams,
|
|
)
|
|
)
|
|
return true;
|
|
if (shouldResetTaskSessionForWake(input.contextSnapshot)) return false;
|
|
if (!input.taskKey) return false;
|
|
|
|
const codec = getAdapterSessionCodec(input.agent.adapterType);
|
|
const taskSession = await getTaskSession(
|
|
input.agent.companyId,
|
|
input.agent.id,
|
|
input.agent.adapterType,
|
|
input.taskKey,
|
|
);
|
|
const taskSessionParams = normalizeResumeParamsForAdapter(
|
|
input.agent.adapterType,
|
|
codec.deserialize(taskSession?.sessionParamsJson ?? null),
|
|
);
|
|
return hasResolvableSessionWorkspaceCwd(taskSessionParams);
|
|
}
|
|
|
|
async function resolveExplicitResumeSessionOverride(
|
|
agent: typeof agents.$inferSelect,
|
|
payload: Record<string, unknown> | null,
|
|
taskKey: string | null,
|
|
) {
|
|
const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId);
|
|
if (!resumeFromRunId) return null;
|
|
|
|
const resumeRun = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
resultJson: heartbeatRuns.resultJson,
|
|
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, resumeFromRunId),
|
|
eq(heartbeatRuns.companyId, agent.companyId),
|
|
eq(heartbeatRuns.agentId, agent.id),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!resumeRun) return null;
|
|
|
|
const resumeContext = parseObject(resumeRun.contextSnapshot);
|
|
const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey;
|
|
const resumeTaskSession = resumeTaskKey
|
|
? await getTaskSession(
|
|
agent.companyId,
|
|
agent.id,
|
|
agent.adapterType,
|
|
resumeTaskKey,
|
|
)
|
|
: null;
|
|
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
|
const resumeRunResult = parseObject(resumeRun.resultJson);
|
|
const resumeRunSessionId = requiresCanonicalSessionIds(agent.adapterType)
|
|
? (readNonEmptyString(resumeRunResult.sessionId) ??
|
|
readNonEmptyString(resumeRunResult.session_id))
|
|
: null;
|
|
const sessionOverride = buildExplicitResumeSessionOverride({
|
|
adapterType: agent.adapterType,
|
|
resumeFromRunId,
|
|
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
|
|
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
|
|
resumeRunSessionParams: resumeRunSessionId
|
|
? { sessionId: resumeRunSessionId }
|
|
: null,
|
|
taskSession: resumeTaskSession,
|
|
sessionCodec,
|
|
});
|
|
if (!sessionOverride) return null;
|
|
|
|
return {
|
|
resumeFromRunId,
|
|
taskKey: resumeTaskKey,
|
|
issueId: readNonEmptyString(resumeContext.issueId),
|
|
taskId:
|
|
readNonEmptyString(resumeContext.taskId) ??
|
|
readNonEmptyString(resumeContext.issueId),
|
|
sessionDisplayId: sessionOverride.sessionDisplayId,
|
|
sessionParams: sessionOverride.sessionParams,
|
|
};
|
|
}
|
|
|
|
async function upsertTaskSession(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
adapterType: string;
|
|
taskKey: string;
|
|
sessionParamsJson: Record<string, unknown> | null;
|
|
sessionDisplayId: string | null;
|
|
lastRunId: string | null;
|
|
lastError: string | null;
|
|
}) {
|
|
return db.transaction(async (tx) => {
|
|
const [issue] = await tx.select().from(issues).where(and(sql`${issues.id}::text = ${input.taskKey}`, eq(issues.companyId, input.companyId))).for("update");
|
|
if (isConversation(issue)) {
|
|
const [run] = input.lastRunId ? await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.lastRunId)) : [];
|
|
if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return null;
|
|
}
|
|
const existing = await tx.select().from(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, input.companyId), eq(agentTaskSessions.agentId, input.agentId), eq(agentTaskSessions.adapterType, input.adapterType), eq(agentTaskSessions.taskKey, input.taskKey))).then((rows) => rows[0] ?? null);
|
|
if (existing) {
|
|
return tx
|
|
.update(agentTaskSessions)
|
|
.set({
|
|
sessionParamsJson: input.sessionParamsJson,
|
|
sessionDisplayId: input.sessionDisplayId,
|
|
lastRunId: input.lastRunId,
|
|
lastError: input.lastError,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(agentTaskSessions.id, existing.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
return tx
|
|
.insert(agentTaskSessions)
|
|
.values({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
adapterType: input.adapterType,
|
|
taskKey: input.taskKey,
|
|
sessionParamsJson: input.sessionParamsJson,
|
|
sessionDisplayId: input.sessionDisplayId,
|
|
lastRunId: input.lastRunId,
|
|
lastError: input.lastError,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
});
|
|
}
|
|
|
|
async function clearTaskSessions(
|
|
companyId: string,
|
|
agentId: string,
|
|
opts?: {
|
|
taskKey?: string | null;
|
|
adapterType?: string | null;
|
|
expectedRunId?: string;
|
|
includeIssueAliases?: boolean;
|
|
},
|
|
) {
|
|
const conditions = [
|
|
eq(agentTaskSessions.companyId, companyId),
|
|
eq(agentTaskSessions.agentId, agentId),
|
|
];
|
|
if (opts?.taskKey) {
|
|
const exactTaskKey = eq(agentTaskSessions.taskKey, opts.taskKey);
|
|
if (opts.includeIssueAliases) {
|
|
const selectedIssue = isUuidLike(opts.taskKey)
|
|
? eq(issues.id, opts.taskKey)
|
|
: eq(issues.identifier, opts.taskKey.toUpperCase());
|
|
// Operator task resets accept the UUID sent by run detail and the
|
|
// identifier used by some saved sessions. Resolve only from the current
|
|
// same-company issue row, in this DELETE's snapshot; arbitrary custom
|
|
// keys retain exact-match behavior and run/model context grants no alias.
|
|
conditions.push(
|
|
or(
|
|
exactTaskKey,
|
|
sql`exists (
|
|
select 1 from ${issues}
|
|
where ${issues.companyId} = ${companyId}
|
|
and ${selectedIssue}
|
|
and (${agentTaskSessions.taskKey} = ${issues.id}::text
|
|
or ${agentTaskSessions.taskKey} = ${issues.identifier})
|
|
)`,
|
|
)!,
|
|
);
|
|
} else {
|
|
conditions.push(exactTaskKey);
|
|
}
|
|
}
|
|
if (opts?.adapterType) {
|
|
conditions.push(eq(agentTaskSessions.adapterType, opts.adapterType));
|
|
}
|
|
|
|
return db.transaction(async (tx) => {
|
|
if (opts?.taskKey && opts.expectedRunId) {
|
|
const [issue] = await tx.select().from(issues).where(sql`${issues.id}::text = ${opts.taskKey}`).for("update");
|
|
if (isConversation(issue)) {
|
|
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, opts.expectedRunId));
|
|
if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return 0;
|
|
}
|
|
}
|
|
const goals = await tx.select({ id: agentTaskSessions.id }).from(agentTaskSessions)
|
|
.where(and(...conditions, sql`${agentTaskSessions.goalStatus} is not null`, sql`${agentTaskSessions.goalStatus} <> 'complete'`)).limit(1);
|
|
if (goals.length) await notifyExternalConversationSettlement(tx, { companyId });
|
|
return tx.delete(agentTaskSessions).where(and(...conditions)).returning().then((rows) => rows.length);
|
|
});
|
|
}
|
|
|
|
async function ensureRuntimeState(agent: typeof agents.$inferSelect) {
|
|
const existing = await getRuntimeState(agent.id);
|
|
if (existing) return existing;
|
|
|
|
const inserted = await db
|
|
.insert(agentRuntimeState)
|
|
.values({
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
adapterType: agent.adapterType,
|
|
stateJson: {},
|
|
})
|
|
.onConflictDoNothing({
|
|
target: agentRuntimeState.agentId,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (inserted) return inserted;
|
|
|
|
const ensured = await getRuntimeState(agent.id);
|
|
if (!ensured) {
|
|
throw new Error(`Failed to ensure runtime state for agent ${agent.id}`);
|
|
}
|
|
return ensured;
|
|
}
|
|
|
|
return {
|
|
getAgent,
|
|
resolveSessionBeforeForWakeup,
|
|
getRun,
|
|
getRunCleanupState,
|
|
getRunExecutionWait,
|
|
getRunWorkspaceSettlement,
|
|
ensureRuntimeState,
|
|
getTaskSession,
|
|
getLatestAgentConfigRevision,
|
|
evaluateSessionCompaction,
|
|
upsertTaskSession,
|
|
resolveNormalizedUsageForSession,
|
|
clearTaskSessions,
|
|
resolveExplicitResumeSessionOverride,
|
|
hasResolvablePriorSessionWorkspaceForWake,
|
|
getRunStatus,
|
|
getRunReadAccess,
|
|
getRunLogAccess,
|
|
getRunWorkspaceAccess,
|
|
|
|
listRuns: async (
|
|
companyId: string,
|
|
agentId?: string,
|
|
limit?: number,
|
|
options: { summary?: boolean } = {},
|
|
) => {
|
|
const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase();
|
|
const summary = options.summary === true;
|
|
const rows = await retryIdempotentDatabaseOperation(async () => {
|
|
const query = db
|
|
.select(
|
|
summary
|
|
? {
|
|
...heartbeatRunSummaryListColumns,
|
|
...heartbeatRunListContextColumns,
|
|
}
|
|
: safeForLegacyEncoding
|
|
? {
|
|
...heartbeatRunListColumns,
|
|
error: sql<string | null>`NULL`.as("error"),
|
|
...heartbeatRunListContextColumns,
|
|
}
|
|
: {
|
|
...heartbeatRunListColumns,
|
|
...heartbeatRunListContextColumns,
|
|
...heartbeatRunListResultColumns,
|
|
},
|
|
)
|
|
.from(heartbeatRuns)
|
|
.leftJoin(heartbeatRunListContextRecord, sql`true`)
|
|
.where(
|
|
agentId
|
|
? and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.agentId, agentId),
|
|
)
|
|
: eq(heartbeatRuns.companyId, companyId),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt));
|
|
|
|
const boundedLimit = Number.isFinite(limit)
|
|
? Math.max(1, Math.min(HEARTBEAT_RUN_LIST_MAX_LIMIT, Math.trunc(limit!)))
|
|
: HEARTBEAT_RUN_LIST_DEFAULT_LIMIT;
|
|
return await query.limit(boundedLimit);
|
|
});
|
|
return rows.map((row) => {
|
|
const {
|
|
contextIssueId,
|
|
contextTaskId,
|
|
contextTaskKey,
|
|
contextCommentId,
|
|
contextWakeCommentId,
|
|
contextWakeReason,
|
|
contextWakeSource,
|
|
contextWakeTriggerDetail,
|
|
resultSummary,
|
|
resultResult,
|
|
resultMessage,
|
|
resultError,
|
|
resultTotalCostUsd,
|
|
resultCostUsd,
|
|
resultCostUsdCamel,
|
|
...rest
|
|
} = row as typeof row & {
|
|
resultSummary?: string | null;
|
|
resultResult?: string | null;
|
|
resultMessage?: string | null;
|
|
resultError?: string | null;
|
|
resultTotalCostUsd?: string | null;
|
|
resultCostUsd?: string | null;
|
|
resultCostUsdCamel?: string | null;
|
|
};
|
|
|
|
return {
|
|
...rest,
|
|
contextSnapshot: summarizeHeartbeatRunContextSnapshot({
|
|
issueId: contextIssueId,
|
|
taskId: contextTaskId,
|
|
taskKey: contextTaskKey,
|
|
commentId: contextCommentId,
|
|
wakeCommentId: contextWakeCommentId,
|
|
wakeReason: contextWakeReason,
|
|
wakeSource: contextWakeSource,
|
|
wakeTriggerDetail: contextWakeTriggerDetail,
|
|
}),
|
|
resultJson:
|
|
safeForLegacyEncoding || summary
|
|
? null
|
|
: summarizeHeartbeatRunListResultJson({
|
|
summary: resultSummary,
|
|
result: resultResult,
|
|
message: resultMessage,
|
|
error: resultError,
|
|
totalCostUsd: resultTotalCostUsd,
|
|
costUsd: resultCostUsd,
|
|
costUsdCamel: resultCostUsdCamel,
|
|
}),
|
|
};
|
|
});
|
|
},
|
|
|
|
getRuntimeStateWithSessions: async (agentId: string) => {
|
|
const state = await getRuntimeState(agentId);
|
|
const agent = await getAgent(agentId);
|
|
if (!agent) return null;
|
|
const ensured = state ?? (await ensureRuntimeState(agent));
|
|
const latestTaskSession = await db
|
|
.select()
|
|
.from(agentTaskSessions)
|
|
.where(
|
|
and(
|
|
eq(agentTaskSessions.companyId, agent.companyId),
|
|
eq(agentTaskSessions.agentId, agent.id),
|
|
),
|
|
)
|
|
.orderBy(desc(agentTaskSessions.updatedAt))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
return {
|
|
...ensured,
|
|
...await readRuntimeAccountingTotals(db, agent.companyId, agentId),
|
|
sessionDisplayId:
|
|
latestTaskSession?.sessionDisplayId ?? ensured.sessionId,
|
|
sessionParamsJson: latestTaskSession?.sessionParamsJson ?? null,
|
|
};
|
|
},
|
|
|
|
listTaskSessions: async (agentId: string) => {
|
|
const agent = await getAgent(agentId);
|
|
if (!agent) throw notFound("Agent not found");
|
|
|
|
return db
|
|
.select()
|
|
.from(agentTaskSessions)
|
|
.where(
|
|
and(
|
|
eq(agentTaskSessions.companyId, agent.companyId),
|
|
eq(agentTaskSessions.agentId, agentId),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(agentTaskSessions.updatedAt),
|
|
desc(agentTaskSessions.createdAt),
|
|
);
|
|
},
|
|
|
|
resetRuntimeSession: async (
|
|
agentId: string,
|
|
opts?: { taskKey?: string | null },
|
|
) => {
|
|
const agent = await getAgent(agentId);
|
|
if (!agent) throw notFound("Agent not found");
|
|
await ensureRuntimeState(agent);
|
|
const taskKey = readNonEmptyString(opts?.taskKey);
|
|
const clearedTaskSessions = await clearTaskSessions(
|
|
agent.companyId,
|
|
agent.id,
|
|
taskKey
|
|
? {
|
|
taskKey,
|
|
adapterType: agent.adapterType,
|
|
includeIssueAliases: true,
|
|
}
|
|
: undefined,
|
|
);
|
|
const runtimePatch: Partial<typeof agentRuntimeState.$inferInsert> = {
|
|
sessionId: null,
|
|
lastError: null,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (!taskKey) {
|
|
runtimePatch.stateJson = {};
|
|
}
|
|
|
|
const updated = await db
|
|
.update(agentRuntimeState)
|
|
.set(runtimePatch)
|
|
.where(eq(agentRuntimeState.agentId, agentId))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!updated) return null;
|
|
return {
|
|
...updated,
|
|
sessionDisplayId: null,
|
|
sessionParamsJson: null,
|
|
clearedTaskSessions,
|
|
};
|
|
},
|
|
|
|
listEvents: (runId: string, afterSeq = 0, limit = 200, conversationOnly = false) =>
|
|
db
|
|
.select(conversationOnly ? conversationEventColumns(runId) : getTableColumns(heartbeatRunEvents))
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRunEvents.runId, runId),
|
|
gt(heartbeatRunEvents.seq, afterSeq),
|
|
conversationOnly ? conversationEventCondition(runId) : undefined,
|
|
),
|
|
)
|
|
.orderBy(asc(heartbeatRunEvents.seq))
|
|
.limit(Math.max(1, Math.min(limit, 1000))),
|
|
|
|
getRetryExhaustedReason: async (runId: string) => {
|
|
const row = await db
|
|
.select({
|
|
message: heartbeatRunEvents.message,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRunEvents.runId, runId),
|
|
eq(heartbeatRunEvents.eventType, "lifecycle"),
|
|
sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`,
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRunEvents.id))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
return row?.message ?? null;
|
|
},
|
|
|
|
getRunIssueSummary: async (runId: string) => {
|
|
const [run] = await db
|
|
.select(heartbeatRunIssueSummaryColumns)
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.limit(1);
|
|
return run ?? null;
|
|
},
|
|
|
|
getRunIssueControl: async (runId: string) => {
|
|
const [run] = await db.select(heartbeatRunIssueControlColumns).from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId)).limit(1);
|
|
return run ?? null;
|
|
},
|
|
|
|
getActiveRunIssueControlForAgent: async (agentId: string) => {
|
|
const [run] = await db.select(heartbeatRunIssueControlColumns).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "running")))
|
|
.orderBy(desc(heartbeatRuns.startedAt)).limit(1);
|
|
return run ?? null;
|
|
},
|
|
|
|
getActiveRunForAgent: async (agentId: string) => {
|
|
const [run] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.agentId, agentId),
|
|
eq(heartbeatRuns.status, "running"),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.startedAt))
|
|
.limit(1);
|
|
return run ?? null;
|
|
},
|
|
|
|
getActiveRunIssueSummaryForAgent: async (agentId: string) => {
|
|
const [run] = await db
|
|
.select(heartbeatRunIssueSummaryColumns)
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.agentId, agentId),
|
|
eq(heartbeatRuns.status, "running"),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.startedAt))
|
|
.limit(1);
|
|
return run ?? null;
|
|
},
|
|
};
|
|
}
|