diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 4b25d88f5de..0afd307559a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1838,6 +1838,21 @@ binding, then applies the existing run and operation permission checks. existing public helpers. Keep session policy changes separate from run orchestration changes. +Explicit continuation admission reads only task/comment identity and dispatch +evidence from the saved context. Its preflight reads the historical adapter type +and workspace sync receipt. The coordinator and run locks retain their order. +The locked reread retains the complete Runner profile because suspended-session +and quarantined-cleanup proofs parse the exact saved execution input. Other run +and dispatch paths retain their existing reads. + +Measure these two admission queries with +`pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-continuation-run-reads.ts --baseline-ref=`. +The synthetic PostgreSQL fixture compares the admission result and reports exact +text-format DataRow frame bytes, SQL samples, and execution plans. It excludes +TLS and protocol metadata. To use an isolated external database, set +`PAPERCLIP_CONTINUATION_BENCHMARK_DATABASE_URL` to a disposable database ending +in `_test`. The harness applies migrations there and adds synthetic fixtures. + Retry scheduling is in `server/src/services/heartbeat/retries.ts`. It owns bounded retry schedules, connection and workspace contention deferrals, shared-workspace holder checks, due retry promotion, and retry-now requests. `createHeartbeatRetries` diff --git a/scripts/benchmark-continuation-run-reads.ts b/scripts/benchmark-continuation-run-reads.ts new file mode 100644 index 00000000000..cf4ed19607d --- /dev/null +++ b/scripts/benchmark-continuation-run-reads.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { agents, applyPendingMigrations, companies, createDb, heartbeatRuns, issueComments, issueRecoveryActions, issues, + nativeRunFinalizations, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js"; +import { admitExplicitNativeContinuation } from "../server/src/services/explicit-native-continuation.js"; +import { resolveExternalTestDatabaseUrl } from "../server/src/__tests__/helpers/external-test-database.js"; + +type Query = { sql: string; params: unknown[] }; +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +const baselineRef = process.argv.find(arg => arg.startsWith("--baseline-ref="))?.slice(15); +assert.ok(baselineRef, "Pass a pinned pre-fix --baseline-ref"); +const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { cwd: repoRoot, encoding: "utf8" }).trim(); +const treatmentRevision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim(); +const treatmentDiff = execFileSync("git", ["diff", "HEAD", "--", "server/src/services/explicit-native-continuation.ts"], { cwd: repoRoot, encoding: "utf8" }); +const keepAlive = setInterval(() => {}, 1000); +let database: Awaited> | undefined; +let db: Db | undefined; +let baselineDir: string | undefined; +try { + const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_CONTINUATION_BENCHMARK_DATABASE_URL"); + if (externalUrl) { + await applyPendingMigrations(externalUrl); + database = { connectionString: externalUrl, cleanup: async () => {} }; + } else database = await startEmbeddedPostgresTestDatabase("continuation-read-benchmark-"); + db = createDb(database.connectionString); + const original = new URL("../server/src/services/explicit-native-continuation.ts", import.meta.url); + baselineDir = await fs.mkdtemp(path.join(fileURLToPath(new URL("../server/scripts/", import.meta.url)), ".continuation-baseline-")); + const baselineSource = execFileSync("git", ["show", `${baselineRevision}:server/src/services/explicit-native-continuation.ts`], { cwd: repoRoot, encoding: "utf8" }); + await fs.writeFile(path.join(baselineDir, "continuation.ts"), baselineSource.replace(/(from |import\()"(\.{1,2}\/[^\"]+)"/g, + (_match, prefix: string, specifier: string) => `${prefix}${JSON.stringify(fileURLToPath(new URL(specifier, original)))}`)); + const baseline = await import(pathToFileURL(path.join(baselineDir, "continuation.ts")).href) as { admitExplicitNativeContinuation: typeof admitExplicitNativeContinuation }; + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(), commentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Synthetic continuation", issuePrefix: `B${companyId.replaceAll("-", "")}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "paperclip_runner" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Continue", status: "blocked", assigneeAgentId: agentId }); + const contextBytes = randomBytes(144 * 1024).toString("base64"); + const profileBytes = randomBytes(72 * 1024).toString("base64"); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, + status: "failed", runtimeMode: "native", processPid: 999999999, + finishedAt: new Date("2020-01-01T00:00:00Z"), contextSnapshot: { issueId, prompt: contextBytes }, + runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner", adapterConfig: { instructions: profileBytes } }, + nativeExecutionInput: { task: { description: profileBytes } } }, + }); + await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "terminal_failure", attempt: 3 }); + await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId, kind: "active_run_watchdog", + cause: "uncertain_external_action", fingerprint: runId, status: "resolved", outcome: "blocked", nextAction: "Inspect", + evidence: { runId, automaticRecovery: { replay: "blocked", actionOutcome: "unknown" } } }); + await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorType: "user", authorUserId: "board", + body: "Continue with new instructions.", createdAt: new Date("2020-01-01T01:00:00Z") }); + await db.$client.unsafe("ANALYZE"); + async function capture(invoke: typeof admitExplicitNativeContinuation) { + const queries: Query[] = []; + const reads: Array>> = []; + const recorded = new Proxy(db!, { get(target, key) { + if (key !== "select") return Reflect.get(target, key); + return (...fields: Parameters) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const query = from(...args), execute = query.execute.bind(query); + if (args[0] === heartbeatRuns) query.execute = async (...executeArgs: Parameters) => { + const statement = query.toSQL(); + const admissionRead = statement.sql.includes('"id"::text') || statement.sql.endsWith("for update"); + if (admissionRead) queries.push(statement); + const rows = await execute(...executeArgs); + if (admissionRead) reads.push(rows); + return rows; + }; + return query; + }) as typeof builder.from; + return builder; + }; + }}); + const blocked: string[] = []; + const result = await invoke({ db: recorded, companyId, agentId, issueId, commentId, + actorType: "user", actorId: "board", reason: "issue_commented", successorRunId: randomUUID(), dryRun: true, + onBlocked: reason => blocked.push(reason) }); + assert.equal(queries.length, 2); + return { queries, reads, result, blocked }; + } + const before = await capture(baseline.admitExplicitNativeContinuation), after = await capture(admitExplicitNativeContinuation); + assert.ok(before.result); + assert.deepEqual(after.result, before.result); + assert.deepEqual(after.blocked, before.blocked); + for (let index = 0; index < before.reads.length; index++) { + assert.equal(after.reads[index].length, before.reads[index].length); + for (const [key, value] of Object.entries(after.reads[index][0])) { + if (key === "contextSnapshot") assert.deepEqual(value, { issueId }); + else if (key === "runnerProfileJson" && index === 0) assert.deepEqual(value, { adapterDispatch: { adapterType: "paperclip_runner" } }); + else assert.deepEqual(value, before.reads[index][0][key], `Changed selected column ${key}`); + } + } + const measurements = []; + for (let index = 0; index < 2; index++) { + const pair = [before.queries[index], after.queries[index]]; + const samples: number[][] = [[], []]; + const bytes: number[] = []; + for (let iteration = 0; iteration < 9; iteration++) { + for (const variant of iteration % 2 ? [1, 0] : [0, 1]) { + const query = pair[variant], start = performance.now(); + const rows = await db.$client.unsafe(query.sql, query.params as never[]).raw(); + if (iteration >= 2) samples[variant].push(performance.now() - start); + bytes[variant] = rows.reduce((sum, row) => sum + 7 + row.reduce((n: number, value: Buffer | null) => n + 4 + (value?.length ?? 0), 0), 0); + } + } + const plans = []; + for (const query of pair) { + const [plan] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params as never[]); + plans.push(plan["QUERY PLAN"]); + } + const median = (values: number[]) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; + measurements.push({ name: index === 0 ? "preflight" : "locked", baseline: pair[0], treatment: pair[1], + baselineDataRowBytes: bytes[0], treatmentDataRowBytes: bytes[1], reduction: 1 - bytes[1] / bytes[0], + baselineMedianMs: median(samples[0]), treatmentMedianMs: median(samples[1]), samples, plans }); + } + const baselineBytes = measurements.reduce((sum, row) => sum + row.baselineDataRowBytes, 0); + const treatmentBytes = measurements.reduce((sum, row) => sum + row.treatmentDataRowBytes, 0); + assert.ok(treatmentBytes < baselineBytes / 2, "The fixture must remove more than half of the two reads' DataRow bytes"); + console.log(JSON.stringify({ baselineRevision, treatmentRevision, treatmentDiff, + byteBoundary: "Exact text-format DataRow frames; excludes RowDescription, command metadata, TLS and TCP", + acceptance: { equalSelectedColumns: true, equalAdmission: true, lessThanHalfDataRowBytes: true }, + postgres: await db.$client.unsafe("select version()"), + fixture: { contextBytes: Buffer.byteLength(contextBytes), profileCopies: 2, profileCopyBytes: Buffer.byteLength(profileBytes) }, + equalAdmission: true, measurements }, null, 2)); +} finally { + try { + await db?.$client.end({ timeout: 0 }); await database?.cleanup(); + if (baselineDir) await fs.rm(baselineDir, { recursive: true, force: true }); + } finally { clearInterval(keepAlive); } +} diff --git a/server/src/services/conversation-continuation.ts b/server/src/services/conversation-continuation.ts index cd0c54a2dcd..75ff2463f94 100644 --- a/server/src/services/conversation-continuation.ts +++ b/server/src/services/conversation-continuation.ts @@ -43,7 +43,8 @@ function conversationRunPredicate() { } /** Recovery must not infer the old adapter from the agent's mutable settings. */ -export async function historicalAdapterType(db: Db, run: typeof heartbeatRuns.$inferSelect): Promise { +export async function historicalAdapterType(db: Db, run: Pick): Promise { const selected = claimedAdapterType(run); if (selected) return selected; const [invocation] = await db.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents) diff --git a/server/src/services/explicit-native-continuation.test.ts b/server/src/services/explicit-native-continuation.test.ts index 9224609f475..4608ebac4da 100644 --- a/server/src/services/explicit-native-continuation.test.ts +++ b/server/src/services/explicit-native-continuation.test.ts @@ -9,7 +9,7 @@ import * as nativeExecutor from "./native-runtime/native-session-executor.js"; import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js"; import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_REQUESTED } from "./native-local-process-stop.js"; import { remoteTerminationReceipt } from "./remote-execution-termination.js"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,7 +18,7 @@ import { beforeAll, afterAll, describe, it, expect, vi } from "vitest"; import { applyPendingMigrations, approvals, issueApprovals, issueThreadInteractions, chatConversations, chatEndpoints, toolApplications, toolConnections, agentWakeupRequests, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issueRecoveryActions, - issues, nativeRunFinalizations, nativeRunResults, completionContracts, environmentLeases, environments, issueRelations, issueTreeHolds, issueTreeHoldMembers, + issues, nativeRunFinalizations, nativeRunResults, completionContracts, environmentLeases, environments, issueRelations, issueTreeHolds, issueTreeHoldMembers, type Db, } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from "../__tests__/helpers/embedded-postgres.js"; import { resolveExternalTestDatabaseUrl } from "../__tests__/helpers/external-test-database.js"; @@ -65,6 +65,67 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes actorType: "user", actorId: "board", reason: "issue_commented" }; } type Fixture = Awaited>; + async function readAdmission(f: Fixture, full: boolean) { + const reads: Array>> = []; + const recorded = new Proxy(db, { get(target, key) { + if (key !== "select") return Reflect.get(target, key); + return (...fields: Parameters) => { + const builder = target.select(...fields), from = builder.from.bind(builder); + builder.from = ((...args: Parameters) => { + const admissionRead = args[0] === heartbeatRuns && Object.hasOwn(fields[0] ?? {}, "runnerInstanceId"); + const query = admissionRead && full ? target.select().from(heartbeatRuns) : from(...args); + const execute = query.execute.bind(query); + if (args[0] === heartbeatRuns) query.execute = async (...executeArgs: Parameters) => { + const rows = await execute(...executeArgs), statement = query.toSQL().sql; + if (statement.includes('"id"::text') || statement.endsWith("for update")) reads.push(rows); + return rows; + }; + return query; + }) as typeof builder.from; + return builder; + }; + }}); + const blocked: string[] = []; + const result = await admitExplicitNativeContinuation({ ...f, db: recorded, dryRun: true, + onBlocked: reason => blocked.push(reason) }); + return { result, blocked, reads }; + } + it("keeps admission and the complete locked session profile while omitting prompt data", async () => { + const f = await seed(); + const payload = randomBytes(128 * 1024).toString("base64"); + const profile = { adapterDispatch: { adapterType: "paperclip_runner", adapterConfig: { instructions: payload } }, + nativeExecutionInput: { task: { description: payload } }, sessionCheckpoint: { providerSessionId: "retained" }, + nativeToolContractFingerprint: "retained-fingerprint" }; + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, prompt: payload }, + runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.sourceRunId)); + const baseline = await readAdmission(f, true), projected = await readAdmission(f, false); + expect(projected.result).toEqual(baseline.result); + expect(projected.result).toMatchObject({ previousRunId: f.sourceRunId, commentId: f.commentId }); + expect(projected.blocked).toEqual(baseline.blocked); + expect(projected.reads).toHaveLength(2); + expect(projected.reads.map(rows => rows[0].contextSnapshot)).toEqual([{ issueId: f.issueId }, { issueId: f.issueId }]); + expect(projected.reads[0][0].runnerProfileJson).toEqual({ adapterDispatch: { adapterType: "paperclip_runner" } }); + expect(projected.reads[1][0].runnerProfileJson).toEqual(profile); + expect(Buffer.byteLength(JSON.stringify(projected.reads))).toBeLessThan(Buffer.byteLength(JSON.stringify(baseline.reads)) / 2); + const [stored] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)); + expect(stored.contextSnapshot).toEqual({ issueId: f.issueId, prompt: payload }); + expect(stored.runnerProfileJson).toEqual(profile); + }); + it.each([null, [], "scalar", 42, {}, { issueId: 42, wakeCommentId: false, wakeCommentIds: [null, 42], + source: null, adapterDispatch: { adapterType: 42 }, nativeWorkspaceSync: [] }].map(value => ({ value })))( + "preserves a controller hold with missing or malformed JSON ($value)", async ({ value }) => { + const f = await seed(); + await db.update(heartbeatRuns).set({ contextSnapshot: value as Record | null, + runnerProfileJson: value as Record | null }).where(eq(heartbeatRuns.id, f.sourceRunId)); + await db.update(nativeRunFinalizations).set({ leaseOwner: "still-owned" }).where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + const baseline = await readAdmission(f, true), projected = await readAdmission(f, false); + expect(projected.result).toEqual(baseline.result); + expect(projected.blocked).toEqual(baseline.blocked); + expect(projected.blocked).toEqual(["controller_settling"]); + expect(projected.reads).toHaveLength(2); + expect(projected.reads[1][0].runnerProfileJson).toEqual(baseline.reads[1][0].runnerProfileJson); + }, + ); it.each(["new", "saved"])("delivers a reassignment's saved user question once after the native run stopped (%s)", async origin => { const f = await seed(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, f.issueId)); diff --git a/server/src/services/explicit-native-continuation.ts b/server/src/services/explicit-native-continuation.ts index 4ede5b10afc..fccbe652853 100644 --- a/server/src/services/explicit-native-continuation.ts +++ b/server/src/services/explicit-native-continuation.ts @@ -30,7 +30,42 @@ import { activeIssueInteractionCondition } from "./issue-question-context.js"; import { historicalAdapterType, isConversationAdapter } from "./conversation-continuation.js"; import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js"; -type Run = typeof heartbeatRuns.$inferSelect; +type Run = Pick; +const continuationContextKeys = ["issueId", "wakeCommentId", "wakeCommentIds", "source", + "connectionIntentResolution", "interactionKind", "interactionStatus", "mutation", "interactionId"]; +const continuationContext = sql`case + when ${heartbeatRuns.contextSnapshot} is null then null else ( + select coalesce(jsonb_object_agg(field.key, field.value), '{}'::jsonb) + from jsonb_each(case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object' + then ${heartbeatRuns.contextSnapshot} else '{}'::jsonb end) as field(key, value) + where field.key in (${sql.join(continuationContextKeys.map(key => sql`${key}`), sql`, `)}) + ) end`; +const preflightProfile = sql`case + when ${heartbeatRuns.runnerProfileJson} is null then null else ( + select coalesce(jsonb_object_agg(field.key, case when field.key = 'adapterDispatch' + then jsonb_build_object('adapterType', field.value->'adapterType') else field.value end), '{}'::jsonb) + from jsonb_each(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' + then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) as field(key, value) + where field.key in ('adapterDispatch', 'nativeWorkspaceSync') + ) end`; +const continuationRunFields = { + id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, issueId: heartbeatRuns.issueId, nativeIssueId: heartbeatRuns.nativeIssueId, + nativeSessionId: heartbeatRuns.nativeSessionId, runnerInstanceId: heartbeatRuns.runnerInstanceId, + runtimeMode: heartbeatRuns.runtimeMode, runtimeModeResolvedAt: heartbeatRuns.runtimeModeResolvedAt, + errorCode: heartbeatRuns.errorCode, error: heartbeatRuns.error, resultJson: heartbeatRuns.resultJson, + contextSnapshot: continuationContext, runnerProfileJson: heartbeatRuns.runnerProfileJson, + processPid: heartbeatRuns.processPid, processGroupId: heartbeatRuns.processGroupId, + processStartedAt: heartbeatRuns.processStartedAt, sessionIdAfter: heartbeatRuns.sessionIdAfter, + controllerBootId: heartbeatRuns.controllerBootId, controllerLeaseExpiresAt: heartbeatRuns.controllerLeaseExpiresAt, + executionStage: heartbeatRuns.executionStage, +}; const terminal = ["failed", "interrupted", "timed_out", "cancelled"]; export function isUserCommentContinuationReason(reason: string | null | undefined): boolean { @@ -205,7 +240,7 @@ export async function admitExplicitNativeContinuation(input: { const runId = action.evidence.runId ?? action.evidence.sourceRunId; if (typeof runId !== "string") return blocked("source_missing", "The stopped run could not be identified. Your message is saved."); // Text comparison keeps malformed historical evidence a hold, not a UUID cast error. - let [run] = await db.select().from(heartbeatRuns).where(and( + let [run] = await db.select({ ...continuationRunFields, runnerProfileJson: preflightProfile }).from(heartbeatRuns).where(and( eq(heartbeatRuns.companyId, companyId), sql`${heartbeatRuns.id}::text = ${runId}`, )); if (!run || run.agentId !== agentId || !terminal.includes(run.status) || @@ -251,7 +286,7 @@ export async function admitExplicitNativeContinuation(input: { )).for("update"); // Same lock order as the native claim. Re-read the run while holding both // locks before accepting the never-claimed startup proof. - const [lockedRun] = await db.select().from(heartbeatRuns).where(and( + const [lockedRun] = await db.select(continuationRunFields).from(heartbeatRuns).where(and( eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, run.id), )).for("update"); if (!lockedRun || lockedRun.status !== run.status || lockedRun.agentId !== run.agentId || diff --git a/server/src/services/native-local-process-stop.ts b/server/src/services/native-local-process-stop.ts index 07ff696ec59..1ce70349175 100644 --- a/server/src/services/native-local-process-stop.ts +++ b/server/src/services/native-local-process-stop.ts @@ -87,7 +87,9 @@ export async function hasNativeLocalProcessStop(db: Db, companyId: string, runId * user turn only, never permission to replay the old run or infer its outcomes. * The caller must hold the run/controller locks and verify local lease cleanup. */ -export async function hasHistoricalSuspendedNativeSession(db: Db, run: typeof heartbeatRuns.$inferSelect) { +export async function hasHistoricalSuspendedNativeSession(db: Db, run: Pick) { if (run.runtimeMode !== "native" || run.processPid || run.processGroupId || !run.nativeSessionId || !run.runnerInstanceId || !run.nativeIssueId) return false; const [modernProcessEvidence] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and( diff --git a/server/src/services/native-runtime/native-failed-workspace-recovery.ts b/server/src/services/native-runtime/native-failed-workspace-recovery.ts index 6e6792d6b72..205bc4b20db 100644 --- a/server/src/services/native-runtime/native-failed-workspace-recovery.ts +++ b/server/src/services/native-runtime/native-failed-workspace-recovery.ts @@ -11,7 +11,9 @@ import { hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js" import { logger } from "../../middleware/logger.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js"; -export async function preserveNativeFailedWorkspace(db: Db, selected: typeof heartbeatRuns.$inferSelect, options: { allowPreterminal?: boolean; restartRecovery?: NativeRestartRecoveryClaim } = {}) { +export async function preserveNativeFailedWorkspace(db: Db, selected: Pick, + options: { allowPreterminal?: boolean; restartRecovery?: NativeRestartRecoveryClaim } = {}) { if (readNativeWorkspaceSyncReference(selected.runnerProfileJson?.nativeWorkspaceSync)?.state !== "prepared") return; const issueId = selected.nativeIssueId ?? selected.issueId ?? selected.contextSnapshot?.issueId; if (typeof issueId !== "string") return; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index d01ed4e2431..8466a74eef6 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -2787,7 +2787,8 @@ export function rebaseRetainedNativeCleanupProviderHome( */ export async function verifyStoppedNativeSessionForContinuation( db: Db, - run: typeof heartbeatRuns.$inferSelect, + run: Pick, ): Promise<{ evidence: Record; retire: () => boolean } | null> { try { if (run.runtimeMode !== "native" || !["failed", "cancelled", "interrupted", "timed_out"].includes(run.status) ||