diff --git a/doc/observability.md b/doc/observability.md index 94103a3a5a2..8719e36c144 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -223,6 +223,11 @@ and launcher preparation, run output initialization, workspace validation, runtime services, instruction files, and runtime selection. Database and HTTP instrumentation can attach child spans during each awaited operation. +Early context preparation also records `agent_read`, `chat_completion`, +`conversation`, `runtime_state`, `issue_context`, `run_identity`, `trust_preset`, +and `task_context` beneath `heartbeat.prepare`. These time the existing awaited +operations before environment selection; they do not add reads or record content. + Execution configuration also records separate phases for mentioned skills, GitHub identity selection, secret resolution, managed AI runtime preparation, skill inventory, connector assignments, connector skill preparation and delivery, @@ -336,8 +341,9 @@ attempt or owning phase: | `workspace_sync.source_root`, `.repositories`, `.ignore_rules`, `.snapshot_git`, `.snapshot_baseline` | Physical source capture, managed repositories, ignore rules, Git metadata and baseline content snapshot. | | `workspace_sync.pack`, `.git_clone`, `.git_archive`, `.overlay_copy`, `.overlay_archive`, `.deleted_manifest` | Temporary shallow Git clone, its archive callback, selected working-tree overlay copy/archive, and the deleted-file manifest. The archive is inside the clone's lifetime; do not sum parent and child. | | `workspace_sync.seed_generation`, `.seed_lookup`, `.seed_replay`, `.seed_persist`, `.seed_publish`, `.seed_verify`, `.seed_extract`, `.seed_snapshot`, `.seed_cleanup` | Seed identity, cache lookup, replay, durable copies, publication, extraction/re-snapshot verification and temporary cleanup. Cache hits omit cold clone and verification work. | -| `workspace_sync.seed_cache_copy`, `.seed_cache_sync`, `.seed_cache_hash` | Archive linking/copying, filesystem durability, and checksum work inside cache publication. These are children of seed publication, not additional elapsed time to add to that parent. | +| `workspace_sync.seed_cache_copy`, `.seed_cache_sync`, `.seed_cache_hash` | Archive linking/copying, disk flush, and checksum work inside cache publication. Recovery-backed archives retain their existing flush, including when a cache link shares the recovery inode. Only a separate cache-only Git seed omits the flush; its bytes are rebuilt on a cache miss. Every reader verifies checksums. These are children of seed publication, not additional elapsed time to add to that parent. | | `workspace_sync.seed_cache_capacity`, `.seed_cache_evict`, `.seed_cache_locked_publish` | Quota inventory, eviction planning/application, and the publication body after the company lock is acquired. Capacity may run before and inside the lock. A gap after seed verification and before locked publication can include lock acquisition; locked publication itself excludes that wait. Copy/sync/hash and the locked quota/eviction work are nested children. | +| `workspace_sync.seed_read_copy`, `.seed_read_hash`, `.seed_read_touch` | Private cache materialization, verification of the exact reused bytes, and last-used bookkeeping including company-lock acquisition. All are nested in seed lookup. A long lookup alone does not identify copy, hashing, or lock contention as its cause. | | `workspace_sync.git_index_refresh` | Fully awaited stat-metadata refresh after restoring a fresh OpenCode sandbox checkout. It shares the inbound barrier and command timeout; dirty working-tree content stays unstaged. Recovery and retained sandboxes skip it. | | `workspace_sync.inbound_barrier`, `.stage_workspace`, `.transfer` | The inbound scheduler barrier, whole workspace task, and provider `syncIn` call (including provider guards, upload and post-upload extraction). Parallel tasks overlap; provider transfer/exec children give the finer transport split when available. | | `workspace_sync.progress` | Awaited progress/status reporting, including the existing lifecycle run-log writes. These are not file transfer work. | diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 8396a13371f..ffef06f4cd3 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1516,7 +1516,7 @@ export async function prepareSandboxManagedRuntime(input: { await withTempDir("paperclip-sandbox-sync-", async (tempDir) => { const cachedSeed = seedGeneration ? await runStepSpan("workspace.seed_lookup", () => readWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, - { companyDirectory: input.workspaceSeedCacheCompanyDirectory, privateCopyDirectory: tempDir })) : null; + { companyDirectory: input.workspaceSeedCacheCompanyDirectory, privateCopyDirectory: tempDir, runtimeSpan: runStepSpan })) : null; const replaySeed = workspaceInboundMode === "durable_seed" ? input.workspaceDurableSeed : cachedSeed; const preservedNames = new Set([ ".paperclip-runtime", @@ -1740,9 +1740,10 @@ export async function prepareSandboxManagedRuntime(input: { const gitArchivePath = gitSnapshot || repositories.length ? (sharedSeed ? gitSeedTarPath : seed.gitArchivePath) : null; await runStepSpan("workspace.seed_publish", () => publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, - { ...seed, gitArchivePath }, { + { ...seed, gitArchivePath: gitSnapshot || repositories.length ? seed.gitArchivePath : null }, { companyDirectory: input.workspaceSeedCacheCompanyDirectory, scope: input.workspaceSeedCacheScope, + ...(sharedSeed && gitArchivePath ? { cacheOnlyGitArchivePath: gitArchivePath } : {}), runtimeSpan: runStepSpan, verify: () => runStepSpan("seed.verify", async () => { const verifiedRoot = path.join(tempDir, "verify-seed"); diff --git a/packages/adapter-utils/src/workspace-seed-cache.test.ts b/packages/adapter-utils/src/workspace-seed-cache.test.ts index 6f85a587829..0d2bc4e1de6 100644 --- a/packages/adapter-utils/src/workspace-seed-cache.test.ts +++ b/packages/adapter-utils/src/workspace-seed-cache.test.ts @@ -26,6 +26,81 @@ async function fixture() { } const generation = (index: number) => index.toString(16).padStart(64, "0"); +it("does not wait for a cache-only Git archive flush", async () => { + const f = await fixture(); + const recovery = path.join(f.root, "recovery-git.tar"), cacheOnly = path.join(f.root, "cache-git.tar"); + await fs.writeFile(recovery, "task branch recovery"); + await fs.writeFile(cacheOnly, "branch-neutral reusable seed"); + const blocked = deferred<"flush_wait">(), release = deferred(); + const open = fs.open.bind(fs); + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const file = await open(...args); + if (String(args[0]).includes(".pending-") && path.basename(String(args[0])) === "git.tar") { + vi.spyOn(file, "sync").mockImplementation(async () => { + blocked.resolve("flush_wait"); + await release.promise; + }); + } + return file; + }); + const publishing = publishWorkspaceSeedGeneration(f.workspace, generation(1), + { ...f.seed, gitArchivePath: recovery }, { cacheOnlyGitArchivePath: cacheOnly }); + try { + expect(await Promise.race([publishing, blocked.promise])).toBe(true); + const cached = await readWorkspaceSeedGeneration(f.workspace, generation(1)); + expect(await fs.readFile(cached!.workspaceArchivePath, "utf8")).toBe("run recovery bytes"); + expect(await fs.readFile(cached!.gitArchivePath!, "utf8")).toBe("branch-neutral reusable seed"); + expect(await fs.readFile(recovery, "utf8")).toBe("task branch recovery"); + expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes"); + } finally { + release.resolve(); + await publishing; + } +}); + +it.each(["same path", "hardlink"])("rejects a cache-only Git archive that shares recovery bytes through %s", async kind => { + const f = await fixture(); + const recovery = path.join(f.root, "recovery-git.tar"), cacheOnly = path.join(f.root, "cache-git.tar"); + await fs.writeFile(recovery, "task branch recovery"); + if (kind === "hardlink") await fs.link(recovery, cacheOnly); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), + { ...f.seed, gitArchivePath: recovery }, { cacheOnlyGitArchivePath: kind === "hardlink" ? cacheOnly : recovery })).toBe(false); + expect(await fs.readFile(recovery, "utf8")).toBe("task branch recovery"); +}); + +it("rejects a cache-only archive replaced with a recovery hardlink during verification", async () => { + const f = await fixture(); + const recovery = path.join(f.root, "recovery-git.tar"), cacheOnly = path.join(f.root, "cache-git.tar"); + await fs.writeFile(recovery, "task branch recovery"); + await fs.writeFile(cacheOnly, "branch-neutral reusable seed"); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), + { ...f.seed, gitArchivePath: recovery }, { cacheOnlyGitArchivePath: cacheOnly, verify: async () => { + await fs.rm(cacheOnly); + await fs.link(recovery, cacheOnly); + return true; + } })).toBe(false); + expect(await readWorkspaceSeedGeneration(f.workspace, generation(1))).toBeNull(); + expect(await fs.readFile(recovery, "utf8")).toBe("task branch recovery"); +}); + +it.each(["workspace.tar", "git.tar"])("keeps storage flush failures fatal to publication of recovery-backed %s", async archive => { + const f = await fixture(); + const git = path.join(f.root, "recovery-git.tar"); + await fs.writeFile(git, "task branch recovery"); + const open = fs.open.bind(fs); + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const file = await open(...args); + if (String(args[0]).includes(".pending-") && path.basename(String(args[0])) === archive) { + vi.spyOn(file, "sync").mockRejectedValue(Object.assign(new Error("storage flush failed"), { code: "EIO" })); + } + return file; + }); + expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), { ...f.seed, gitArchivePath: git })).toBe(false); + expect(await readWorkspaceSeedGeneration(f.workspace, generation(1))).toBeNull(); + expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes"); + expect(await fs.readFile(git, "utf8")).toBe("task branch recovery"); +}); + it("returns verified private copies that survive subsequent cache changes", async () => { const f = await fixture(); const git = path.join(f.root, "git.tar"); await fs.writeFile(git, "git recovery bytes"); diff --git a/packages/adapter-utils/src/workspace-seed-cache.ts b/packages/adapter-utils/src/workspace-seed-cache.ts index 880ee8ca39e..5adc5c12691 100644 --- a/packages/adapter-utils/src/workspace-seed-cache.ts +++ b/packages/adapter-utils/src/workspace-seed-cache.ts @@ -29,7 +29,9 @@ async function digest(file: string): Promise { /** A privateCopyDirectory must be caller-owned staging; its caller owns cleanup. */ export async function readWorkspaceSeedGeneration(root: string, generation: string, - options: { companyDirectory?: string; privateCopyDirectory?: string } = {}): Promise { + options: { companyDirectory?: string; privateCopyDirectory?: string; runtimeSpan?: RuntimeSpanRunner } = {}): Promise { + const measure = (name: string, work: () => Promise): Promise => + options.runtimeSpan ? options.runtimeSpan(name, work) : work(); const directory = path.join(root, generation); let privateCopy: string | null = null; let accepted = false; @@ -56,16 +58,16 @@ export async function readWorkspaceSeedGeneration(root: string, generation: stri if (!stat.isFile() || stat.isSymbolicLink()) return null; // Hash the exact private bytes staging will consume. This avoids a // second source hash and remains valid if the shared cache changes later. - if (privateCopy) await fs.copyFile(source, file, constants.COPYFILE_FICLONE | constants.COPYFILE_EXCL); - if (await digest(file) !== expected) return null; + if (privateCopy) await measure("workspace.seed_read_copy", () => fs.copyFile(source, file, constants.COPYFILE_FICLONE | constants.COPYFILE_EXCL)); + if (await measure("workspace.seed_read_hash", () => digest(file)) !== expected) return null; } - const used = await withDirectoryMergeLock(path.resolve(options.companyDirectory ?? root), async () => { + const used = await measure("workspace.seed_read_touch", () => withDirectoryMergeLock(path.resolve(options.companyDirectory ?? root), async () => { const current = await fs.lstat(receipt).catch(() => null); if (!current || current.ino !== receiptStat.ino || current.dev !== receiptStat.dev) return false; const usedAt = new Date(); await fs.utimes(receipt, usedAt, usedAt); return true; - }); + })); if (!used) return null; accepted = true; return { workspaceArchivePath, workspaceArchiveSha256: metadata.workspaceSha256, @@ -185,6 +187,7 @@ export async function publishWorkspaceSeedGeneration( root: string, generation: string, seed: WorkspaceDurableSeedPaths, options: { companyDirectory?: string; limits?: Partial; eviction?: Partial; scope?: "workspace" | "company"; now?: () => number; verify?: () => Promise; + cacheOnlyGitArchivePath?: string; runtimeSpan?: RuntimeSpanRunner } = {}, ): Promise { const measure = (name: string, work: () => Promise): Promise => @@ -203,6 +206,18 @@ export async function publishWorkspaceSeedGeneration( await fs.mkdir(company, { recursive: true, mode: 0o700 }); const companyStat = await fs.lstat(company); if (!companyStat.isDirectory() || companyStat.isSymbolicLink()) return false; + const cacheSeed = options.cacheOnlyGitArchivePath + ? { ...seed, gitArchivePath: options.cacheOnlyGitArchivePath } : seed; + if (options.cacheOnlyGitArchivePath) { + if (!seed.gitArchivePath) return false; + const [cached, recovery] = await Promise.all([ + fs.lstat(options.cacheOnlyGitArchivePath), fs.lstat(seed.gitArchivePath), + ]); + // Only a separate, rebuildable archive may omit the existing flush. + // A pathname alone cannot distinguish it from a recovery hardlink. + if (!cached.isFile() || cached.isSymbolicLink() || !recovery.isFile() || recovery.isSymbolicLink() + || (cached.dev === recovery.dev && cached.ino === recovery.ino)) return false; + } const capacity = async (): Promise<"existing" | "available" | false> => { const currentCompany = await fs.lstat(company); if (!currentCompany.isDirectory() || currentCompany.isSymbolicLink() @@ -224,7 +239,7 @@ export async function publishWorkspaceSeedGeneration( } if (workspaceExists && await fs.lstat(path.join(root, generation)).then(() => true, () => false)) return "existing"; let incomingBytes = 4096; - for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) { + for (const archive of [cacheSeed.workspaceArchivePath, cacheSeed.gitArchivePath]) { if (!archive) continue; const stat = await fs.lstat(archive); if (!stat.isFile() || stat.isSymbolicLink()) return false; @@ -240,7 +255,7 @@ export async function publishWorkspaceSeedGeneration( const initialCapacity = await measure("workspace.seed_cache_capacity", capacity); if (initialCapacity === "existing") return true; let incomingBytes = 4096; - for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) if (archive) incomingBytes += (await fs.lstat(archive)).size; + for (const archive of [cacheSeed.workspaceArchivePath, cacheSeed.gitArchivePath]) if (archive) incomingBytes += (await fs.lstat(archive)).size; if (incomingBytes > configuredLimits.workspaceBytes) return false; const plan = { generation, incomingBytes, limits, eviction, now: options.now?.() ?? Date.now() }; if (initialCapacity === false && !await measure("workspace.seed_cache_evict", () => evictSeedGenerations(company, root, { ...plan, apply: false }))) return false; @@ -256,17 +271,24 @@ export async function publishWorkspaceSeedGeneration( const temporary = path.join(root, `.pending-${randomUUID()}`); await fs.mkdir(temporary, { mode: 0o700 }); try { - const copy = async (source: string, name: string) => { + const copy = async (source: string, name: string, flush = true) => { const target = path.join(temporary, name); await measure("workspace.seed_cache_copy", () => fs.link(source, target).catch(() => fs.copyFile(source, target))); - await measure("workspace.seed_cache_sync", async () => { + if (!flush && seed.gitArchivePath) { + const [cached, recovery] = await Promise.all([fs.lstat(target), fs.lstat(seed.gitArchivePath)]); + if (cached.dev === recovery.dev && cached.ino === recovery.ino) throw new Error("workspace_seed_cache_recovery_alias"); + } + if (flush) await measure("workspace.seed_cache_sync", async () => { const file = await fs.open(target, "r"); try { await file.sync(); } finally { await file.close(); } }); return measure("workspace.seed_cache_hash", () => digest(target)); }; const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar"); - const gitSha256 = seed.gitArchivePath ? await copy(seed.gitArchivePath, "git.tar") : null; + // Shared Git seeds have no task branch and are not recovery archives. + // Missing or corrupt cache-only bytes are rejected by the reader. + const gitSha256 = cacheSeed.gitArchivePath + ? await copy(cacheSeed.gitArchivePath, "git.tar", !options.cacheOnlyGitArchivePath) : null; await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 })); await fs.rename(temporary, path.join(root, generation)); return true; diff --git a/server/src/__tests__/run-trust-preset.test.ts b/server/src/__tests__/run-trust-preset.test.ts index a5d72d70877..4a528d504fb 100644 --- a/server/src/__tests__/run-trust-preset.test.ts +++ b/server/src/__tests__/run-trust-preset.test.ts @@ -53,14 +53,14 @@ it("dispatch retains raw trust before workspace and broker setup", () => { "utf8", ); const start = heartbeat.indexOf( - "const retainedTrust = await resolveAndRetainRunTrustPreset", + "resolveAndRetainRunTrustPreset(db, {", ); const end = heartbeat.indexOf( - "const config = parseObject(agent.adapterConfig);", - start, + "resolveExecutionWorkspaceMode({", ); - const dispatch = heartbeat.slice(start, end); expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + const dispatch = heartbeat.slice(start, end); expect(dispatch).toContain( "executionWorkspacePolicy: taskProjectContext.executionWorkspacePolicy", ); @@ -71,10 +71,7 @@ it("dispatch retains raw trust before workspace and broker setup", () => { "context.executionPolicy = retainedTrust.executionPolicy", ); expect(start).toBeLessThan( - heartbeat.indexOf("const resolvedExecutionWorkspaceMode =", start), - ); - expect(start).toBeLessThan( - heartbeat.indexOf('scope: "github_credentials"', start), + heartbeat.indexOf('scope: "github_credentials"'), ); }); diff --git a/server/src/services/execution-preparation-timing.test.ts b/server/src/services/execution-preparation-timing.test.ts index fc89d789ed0..c267d1ee68d 100644 --- a/server/src/services/execution-preparation-timing.test.ts +++ b/server/src/services/execution-preparation-timing.test.ts @@ -46,7 +46,8 @@ it.each([undefined, "", " "])("does not consult tracing for endpoint %s", async await expect(createRunPreparationTiming("run", "native.controller")("backend_setup", () => 42)).resolves.toBe(42); await expect(withRunWorkspaceFinalizationTiming("run", () => 42)).resolves.toBe(42); for (const phase of ["execution.usage_startup", "execution.mcp_handoff", "execution.dispatch_admission", "execution.budget_reservation", - "skills.inventory", "skills.inventory.wait", "skills.cache.acquire", "skills.version.materialize", "skills.consume"] as const) { + "skills.inventory", "skills.inventory.wait", "skills.cache.acquire", "skills.version.materialize", "skills.consume", + "workspace_sync.seed_read_copy", "workspace_sync.seed_read_hash", "workspace_sync.seed_read_touch"] as const) { const result = {}; const work = vi.fn(() => { recordRepositoryObjectCacheDetail({ outcome: "hit", referenceSupplied: true }); diff --git a/server/src/services/execution-preparation-timing.ts b/server/src/services/execution-preparation-timing.ts index fe352bd55a8..9041b76f8f3 100644 --- a/server/src/services/execution-preparation-timing.ts +++ b/server/src/services/execution-preparation-timing.ts @@ -4,6 +4,8 @@ import { createHash } from "node:crypto"; import { isSpanContextValid, ROOT_CONTEXT, SpanStatusCode, trace, type Context } from "@opentelemetry/api"; type PreparationPhase = "environment_selection" | "execution_configuration" | "session" + | "agent_read" | "chat_completion" | "conversation" | "runtime_state" | "issue_context" + | "run_identity" | "trust_preset" | "task_context" | "workspace" | "github_environment" | "github_launchers" | "run_output" | "workspace_validation" | "runtime_services" | "instructions" | "runtime_selection" | "configuration_mentions" | "configuration_github_identity" | "configuration_secrets" @@ -84,6 +86,7 @@ export type ExecutionPhase = | "workspace_sync.seed_generation" | "workspace_sync.seed_lookup" | "workspace_sync.seed_publish" | "workspace_sync.seed_cache_copy" | "workspace_sync.seed_cache_sync" | "workspace_sync.seed_cache_hash" | "workspace_sync.seed_cache_capacity" | "workspace_sync.seed_cache_evict" | "workspace_sync.seed_cache_locked_publish" + | "workspace_sync.seed_read_copy" | "workspace_sync.seed_read_hash" | "workspace_sync.seed_read_touch" | "workspace_sync.git_clone" | "workspace_sync.git_archive" | "workspace_sync.seed_replay" | "workspace_sync.seed_persist" | "workspace_sync.git_index_refresh" | "workspace_sync.overlay_copy" | "workspace_sync.overlay_archive" | "workspace_sync.deleted_manifest" @@ -251,6 +254,9 @@ const workspaceSyncDetailPhases = { "workspace.ignore_rules": "workspace_sync.ignore_rules", "workspace.seed_generation": "workspace_sync.seed_generation", "workspace.seed_lookup": "workspace_sync.seed_lookup", + "workspace.seed_read_copy": "workspace_sync.seed_read_copy", + "workspace.seed_read_hash": "workspace_sync.seed_read_hash", + "workspace.seed_read_touch": "workspace_sync.seed_read_touch", "workspace.seed_publish": "workspace_sync.seed_publish", "workspace.seed_cache_copy": "workspace_sync.seed_cache_copy", "workspace.seed_cache_sync": "workspace_sync.seed_cache_sync", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 52bd4476d49..de0f082c8eb 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1977,8 +1977,8 @@ export function heartbeatService( let identityRedactor = createAgentIdentityRedactor(); try { - let agent = await getAgent(run.agentId); - if (!agent) { + const loadedAgent = await timePreparation("agent_read", () => getAgent(run.agentId)); + if (!loadedAgent) { await setRunStatus(runId, "failed", { error: "Agent not found", errorCode: "agent_not_found", @@ -1992,6 +1992,7 @@ export function heartbeatService( if (failedRun) await releaseIssueExecutionAndPromote(failedRun); return; } + let agent = loadedAgent; // The claimed adapter identity is immutable recovery evidence. Do not // execute a newly selected adapter under a previous adapter's claim. @@ -2011,8 +2012,8 @@ export function heartbeatService( await finalizeAgentStatus(agent.id, "cancelled"); return; } - run = await prepareChatCompletionTurn(db, run); - const preparedConversation = await prepareConversationTurn(db, run); + run = await timePreparation("chat_completion", () => prepareChatCompletionTurn(db, run)); + const preparedConversation = await timePreparation("conversation", () => prepareConversationTurn(db, run)); run = { ...run, contextSnapshot: preparedConversation.context }; if (preparedConversation.reset) { const contextSnapshot = { ...preparedConversation.context, conversationReset: true }; @@ -2025,7 +2026,7 @@ export function heartbeatService( await finalizeAgentStatus(agent.id, "succeeded"); return; } - const runtime = await ensureRuntimeState(agent); + const runtime = await timePreparation("runtime_state", () => ensureRuntimeState(agent)); const context = parseObject(run.contextSnapshot); const authorizeFailedChatRetryExecution = () => db.transaction((tx) => @@ -2103,7 +2104,7 @@ export function heartbeatService( } } let issueContext = issueId - ? await getIssueExecutionContext(agent.companyId, issueId) + ? await timePreparation("issue_context", () => getIssueExecutionContext(agent.companyId, issueId)) : null; const issueDependencyReadiness = issueId ? await issuesSvc @@ -2351,7 +2352,7 @@ export function heartbeatService( issueContext, routineEnvContext, }); - const identityContext = await initializeRunIdentity(db, { + const identityContext = await timePreparation("run_identity", () => initializeRunIdentity(db, { companyId: agent.companyId, runId: run.id, responsibleUserId, @@ -2385,7 +2386,7 @@ export function heartbeatService( readNonEmptyString(context.executionIdentityCause) ?? readNonEmptyString(context.wakeReason) ?? "dispatch", - }); + })); // Initialization has persisted the active context, including an explicit // absence of identity inherited from an automatic continuation. responsibleUserId = identityContext.responsibleUserId; @@ -2443,7 +2444,7 @@ export function heartbeatService( parsedIssueExecutionWorkspaceSettings, runtimeWorkspaceSelectionEnabled, ); - const retainedTrust = await resolveAndRetainRunTrustPreset(db, { + const retainedTrust = await timePreparation("trust_preset", () => resolveAndRetainRunTrustPreset(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id, @@ -2463,7 +2464,7 @@ export function heartbeatService( executionPolicy: issueContext.executionPolicy, } : null, - }); + })); const trustPreset = retainedTrust.trustPreset; if (retainedTrust.executionPolicy !== undefined) { // Later launch-context writes must preserve the boundary already made @@ -2603,12 +2604,12 @@ export function heartbeatService( }).where(eq(heartbeatRuns.id, run.id)); const { continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext, - } = await prepareHeartbeatTaskContext(db, { + } = await timePreparation("task_context", () => prepareHeartbeatTaskContext(db, { run, agent, task: { issueRef, issueContext, wakeCommentContext, context, taskSession }, policy: { trustPreset, experimentalInstanceSettings, recovering: Boolean(runOptions.nativeRestartRecovery || runOptions.nativeLeaseOwner) }, services: { issuesSvc, getPinnedSkillTestContext }, - }); + })); const sourceWorkspaceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: config, projectPolicy: projectExecutionWorkspacePolicy,