perf: avoid flushing separate Git cache seed archives (#18308)

## Thinking Path

> - Paperclip manages work done by AI agents.
> - Native sandbox runs prepare a workspace before the agent can
respond.
> - A shared seed cache avoids repeated Git copies and archive
preparation.
> - Cache publication also flushes a branch-neutral Git seed that is
separate from recovery archives.
> - This cache can be rebuilt. Each reader checks the archive bytes
before reuse.
> - This change omits that separate cache file flush and measures the
remaining preparation gaps.
> - The benefit is less work before agent execution and clearer timing
evidence.

## Linked Issues or Issue Description

Refs #18240.

**What existing behavior does this improve?**

Workspace seed cache publication and latency diagnostics for native
sandbox preparation.

**Current behavior**

Publication flushes both cache archives to disk under the company lock.
Slow storage can delay the publishing run and cache readers. A cache hit
also combines private copying, hashing, and last-used bookkeeping in one
span. Early controller preparation has gaps before environment
selection.

**Proposed behavior**

Keep the existing flush for recovery-backed workspace and Git archives.
Omit the flush only for a separate branch-neutral Git cache archive.
Reject an alternate cache file that is the recovery file or its
hardlink. Keep archive checksums, private copies, atomic publication,
quotas, and company locking. Measure cache read steps and the existing
early preparation operations.

## What Changed

- Skip the flush only for the separate branch-neutral Git seed used by
the company cache.
- Preserve the existing flush for recovery-backed archives, including
hardlink storage. Reject cache-only files that alias recovery bytes
before or during publication.
- Add a regression with a blocked cache-only Git flush. The deployed
code waits; the new code publishes verified cache bytes without waiting.
Test storage failure for both recovery-backed archives and same-path,
hardlink, and replacement alias cases.
- Add cache copy, hash, and bookkeeping spans beneath seed lookup.
- Add timing around existing agent, conversation, runtime, issue,
identity, trust, and task context preparation. Keep their execution
order.
- Update the trust-order guard to recognize the timed call while
continuing to assert trust resolution precedes workspace selection and
credential setup.
- Document the cache contract and the new spans. Keep the old flush span
in the allowlist for compatibility with older observations.

## Verification

- The blocked-flush regression fails on the old code and passes with
this change.
- Focused tests (`vitest run` on the six affected cache, timing, and
native-sync suites) cover cache corruption, private-copy integrity,
quota and eviction races, branch binding, per-run recovery, native
workspace synchronization, trace parentage, and tracing disabled or
failing.
- 115 focused tests and both typechecks pass after rebase on current
master. The tests include recovery storage failures and an archive
replaced with a recovery hardlink during verification.
- All seven trust tests pass with the actual service and migrations,
using a fresh database on an existing local PostgreSQL server. The local
embedded server could not allocate host semaphores, so only its test
startup helper was substituted; assertions and service code were
unchanged.
- A real SDK export with a retained public seed verifies that cache-read
detail spans have the lookup as their parent. The endpoint-off control
produces no spans.
- Current revision `28dea63432d462cad160906f8dfc2d21c81d3446`: all 53
checks pass, two optional checks are skipped, Greptile is 5/5, and no
review threads are open.
- No new staging latency claim is made by this PR. Hosted measurement
remains necessary after delivery.

## Risks

A crash or power loss can discard the cache-only Git seed. A later read
rejects missing or corrupt cache bytes and prepares the workspace again.
Recovery-backed archives retain their previous flush behavior. The
change adds no flush to warm per-run persistence. Hashes and
private-copy checks remain required. The new spans use the existing
opt-in exporter and closed names; they record no task content.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tool use, and code
execution. The session does not expose the exact model SKU or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-30 09:13:08 -05:00
1 parent 65554ee596
commit 6da112ab40
8 files changed
+143 -34

No files matched your search

+7 -1
View File
@@ -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. |
@@ -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");
@@ -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<void>();
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");
@@ -29,7 +29,9 @@ async function digest(file: string): Promise<string> {
/** 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<WorkspaceDurableSeedPaths | null> {
options: { companyDirectory?: string; privateCopyDirectory?: string; runtimeSpan?: RuntimeSpanRunner } = {}): Promise<WorkspaceDurableSeedPaths | null> {
const measure = <T>(name: string, work: () => Promise<T>): Promise<T> =>
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<SeedCacheLimits>; eviction?: Partial<SeedCacheEviction>;
scope?: "workspace" | "company"; now?: () => number; verify?: () => Promise<boolean>;
cacheOnlyGitArchivePath?: string;
runtimeSpan?: RuntimeSpanRunner } = {},
): Promise<boolean> {
const measure = <T>(name: string, work: () => Promise<T>): Promise<T> =>
@@ -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;
@@ -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"'),
);
});
@@ -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 });
@@ -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",
+13 -12
View File
@@ -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,