Fix shared workspace seed capacity and trace deferred copyback (#18181)

Apply the existing company quota to company-shared workspace seed buckets. Preserve individual seed limits and recheck size after verification.

Trace each deferred native workspace finalization check and its resumed copyback under the same hashed run ID. Keep existing admission, ownership and retry behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-29 12:44:34 -05:00
1 parent f1911a4c53
commit 9f3fc2094f
13 files changed
+178 -18

No files matched your search

+14 -4
View File
@@ -233,13 +233,22 @@ connection instructions, and agent identity. Their names are
when a later setup step fails before the provider lifecycle trace starts.
`task.execution_attempt` owns one invocation of the server executor, from its
admission reads through preparation, provider dispatch, completion, reply persistence,
and cleanup. It starts a new root trace with a link to the active trigger. It
admission reads through preparation, provider dispatch, and inline settlement.
Deferred native workspace finalization and reply persistence can finish after
this root ends. It starts a new root trace with a link to the active trigger. It
therefore follows the operator's root sampler even when the HTTP trigger was not
sampled. Async-local ownership parents same-run preparation and nested phases to
the current attempt. Concurrent runs have separate contexts. A phase invoked
without a live matching attempt still starts an independent linked root.
`task.workspace_finalization` owns each deferred native workspace finalization
check. It starts when the background worker checks the run, including its
admission and ownership checks, and ends when that check returns or fails.
Workspace restore phases nest under this root using the same hashed run ID.
It does not extend an earlier execution attempt or include time waiting for
the background worker to run. Retries have separate roots. A check that finds
no eligible work can end without a restore child.
These spans exist before provider dispatch, including setup failures. Preparation phases have
only the existing `paperclip.task.run.run_id` attribute: the first 12 hexadecimal
characters of the run UUID's SHA-256 hash. Failed operations set error status
@@ -611,8 +620,9 @@ An unsampled HTTP request therefore cannot suppress an otherwise sampled
background run. An operator who disables root sampling still gets no spans.
Without an OTLP endpoint, tracing remains silent and does not export data.
The run link in Honeycomb matches `task.provider_session` for native runs, `task.run` for legacy adapters, and
`task.execution_attempt` for server preparation and settlement. It retains the exact hashed run-ID and service filters and groups by trace ID. The URL builder uses Honeycomb’s supported `in` operator for the three root names; see the [query specification](https://github.com/honeycombio/honeycomb-mcp#query-specification-for-run_query). This changes only the query link. The operator endpoint gate and span attribute allowlist remain unchanged.
The run link in Honeycomb matches `task.provider_session` for native runs, `task.run` for legacy adapters,
`task.execution_attempt` for server preparation and settlement, and
`task.workspace_finalization` for deferred native copyback. It retains the exact hashed run-ID and service filters and groups by trace ID. The URL builder uses Honeycomb’s supported `in` operator for the four root names; see the [query specification](https://github.com/honeycombio/honeycomb-mcp#query-specification-for-run_query). This changes only the query link. The operator endpoint gate and span attribute allowlist remain unchanged.
The canonical lifecycle is:
+2 -2
View File
@@ -78,9 +78,9 @@ Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/dele
A returned sparse snapshot must describe a coherent tree before any host mutation. Every nested entry requires an immediate parent marked as a directory in that same returned snapshot, including baseline-unchanged files. Checking every directory also verifies recursive ancestry. Top-level entries use the implicit root. Invalid trees fail without overwriting retained host files and keep recovery evidence. The check uses the existing indexed manifest interface; it adds no host-path scans or unbounded in-memory inventory.
Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain their private recovery seeds. Optional cache publication is capped at 16 generations/1 GiB per bucket and 256 generations/256 cache directories/8 GiB per company. Existing idle and capacity eviction rules apply only to the optional cache. A company-scoped filesystem lock serializes final quota checks and publication across workspace roots. Optional seed reconstruction and baseline verification run outside that lock, alongside inbound upload. Preparation settles both operations before returning or removing temporary archives. Publication rechecks capacity and directory identity under the lock after verification; a competing publication can consume the remaining capacity and cause this optional cache write to be skipped. Archive and receipt bytes, corrupt entries and abandoned pending generations count against capacity; publication also preserves the manifest storage free-space reserve (256 MiB by default), accounting for a full-copy fallback. Saturation or cache failure skips publication and keeps the already-written per-run recovery seed. Existing over-capacity caches stop growing without deleting admitted evidence. This deliberately trades cache hit rate after saturation for bounded additional storage and unchanged recovery semantics; no global cross-company quota is added. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The private per-run Git seed captures the selected branch/HEAD, not unrelated refs.
Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain their private recovery seeds. Optional workspace-scoped cache publication is capped at 16 generations/1 GiB per bucket. Company-scoped buckets share the aggregate 256-generation/256-cache-directory/8 GiB company budget instead of applying one workspace’s quota to their combined contents. A single seed publication, including its receipt reserve, remains limited to 1 GiB. Existing idle and capacity eviction rules apply only to the optional cache. A company-scoped filesystem lock serializes final quota checks and publication across workspace roots. Optional seed reconstruction and baseline verification run outside that lock, alongside inbound upload. Preparation settles both operations before returning or removing temporary archives. Publication rechecks capacity and directory identity under the lock after verification; a competing publication can consume the remaining capacity and cause this optional cache write to be skipped. Archive and receipt bytes, corrupt entries and abandoned pending generations count against capacity; publication also preserves the manifest storage free-space reserve (256 MiB by default), accounting for a full-copy fallback. Saturation or cache failure skips publication and keeps the already-written per-run recovery seed. Existing over-capacity caches stop growing without deleting admitted evidence. This deliberately trades cache hit rate after saturation for bounded additional storage and unchanged recovery semantics; no global cross-company quota is added. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The private per-run Git seed captures the selected branch/HEAD, not unrelated refs.
Native host-current staging uses a company-shared `shared-v1` cache bucket. Its generation binds filesystem contents, Git HEAD, sanitized origin, and nested repository layout; branch names are applied separately. The shared Git archive has detached HEADs and no task branches. Staging copies that immutable archive and appends each selected branch/HEAD before transferring it or saving the private per-run recovery archive. Private archives therefore remain usable by the existing durable-seed replay path. Changing source content, Git HEAD, origin, or repository layout misses the cache; another company cannot read this bucket. The shared bucket keeps the existing 16-generation/1 GiB bucket limit and the existing aggregate company limits. Legacy workspace cache directories remain subject to those aggregate limits and eviction rules.
Native host-current staging uses a company-shared `shared-v1` cache bucket. Its generation binds filesystem contents, Git HEAD, sanitized origin, and nested repository layout; branch names are applied separately. The shared Git archive has detached HEADs and no task branches. Staging copies that immutable archive and appends each selected branch/HEAD before transferring it or saving the private per-run recovery archive. Private archives therefore remain usable by the existing durable-seed replay path. Changing source content, Git HEAD, origin, or repository layout misses the cache; another company cannot read this bucket. The shared bucket uses the existing aggregate company limits, with the existing individual seed byte ceiling. Recent generations from unrelated workspaces therefore do not impose a 1 GiB aggregate ceiling on this company-wide bucket. Legacy workspace cache directories remain subject to those aggregate limits and eviction rules.
Ordinary settlement remains host-durable before successful completion. Preserve export-only recovery and exact-source lease retention. Preserve the specific unsafe-export omission exception and its run-log-only diagnostic. Do not change global merge conflict semantics as a side effect.
@@ -1736,6 +1736,7 @@ export async function prepareSandboxManagedRuntime(input: {
await runStepSpan("workspace.seed_publish", () => publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration,
{ ...seed, gitArchivePath }, {
companyDirectory: input.workspaceSeedCacheCompanyDirectory,
scope: input.workspaceSeedCacheScope,
verify: () => runStepSpan("seed.verify", async () => {
const verifiedRoot = path.join(tempDir, "verify-seed");
try {
@@ -65,6 +65,70 @@ it("applies the company byte cap across workspace roots", async () => {
expect(await fs.readdir(f.company)).toEqual(["workspace"]);
});
it("admits multiple recent workspaces into the company bucket without applying one workspace's quota to their sum", async () => {
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(8192));
const options = { companyDirectory: f.company, scope: "company" as const,
limits: { workspaceGenerations: 1, workspaceBytes: 20_000, companyGenerations: 3, companyBytes: 100_000 } };
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true);
for (const index of [1, 2]) {
const cached = await readWorkspaceSeedGeneration(f.workspace, generation(index));
expect(await fs.readFile(cached!.workspaceArchivePath, "utf8")).toBe("x".repeat(8192));
}
});
it.each(["companyGenerations", "companyBytes"] as const)("keeps the shared bucket within %s and preserves recently used seeds", async limit => {
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(8192));
const options = { companyDirectory: f.company, scope: "company" as const,
limits: { workspaceGenerations: 1, workspaceBytes: 20_000, companyGenerations: 3, companyBytes: 100_000,
[limit]: limit === "companyBytes" ? 25_000 : 2 } };
for (const index of [1, 2]) expect(await publishWorkspaceSeedGeneration(f.workspace, generation(index), f.seed, options)).toBe(true);
const verify = vi.fn(async () => true);
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, { ...options, verify })).toBe(false);
expect(verify).not.toHaveBeenCalled();
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(1), generation(2)]);
expect(await fs.readFile(f.archive, "utf8")).toBe("x".repeat(8192));
});
it("evicts an old legacy workspace seed to make room for a new shared generation", async () => {
const f = await fixture(); const now = Date.now();
await fs.writeFile(f.archive, "x".repeat(8192));
const limits = { workspaceGenerations: 1, workspaceBytes: 20_000, companyGenerations: 3, companyBytes: 40_000 };
const legacy = path.join(f.company, "legacy");
const legacyArchive = path.join(f.root, "legacy.tar"); await fs.writeFile(legacyArchive, "y".repeat(15_000));
expect(await publishWorkspaceSeedGeneration(legacy, generation(0), { workspaceArchivePath: legacyArchive },
{ companyDirectory: f.company, limits, now: () => now })).toBe(true);
const receipt = path.join(legacy, generation(0), "receipt.json");
await fs.utimes(receipt, new Date(now - 2 * 60 * 60 * 1000), new Date(now - 2 * 60 * 60 * 1000));
const options = { companyDirectory: f.company, scope: "company" as const, limits, now: () => now };
for (const index of [1, 2, 3]) expect(await publishWorkspaceSeedGeneration(f.workspace, generation(index), f.seed, options)).toBe(true);
expect(await fs.readdir(f.company)).toEqual(["workspace"]);
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(1), generation(2), generation(3)]);
expect(await fs.readFile(legacyArchive, "utf8")).toBe("y".repeat(15_000));
});
it("retains the individual seed byte ceiling when the shared bucket has room", async () => {
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(8192));
const verify = vi.fn(async () => true);
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
companyDirectory: f.company, scope: "company", limits: { workspaceBytes: 8192 }, verify,
})).toBe(false);
expect(verify).not.toHaveBeenCalled();
expect(await fs.readdir(f.company)).toEqual([]);
expect(await fs.readFile(f.archive, "utf8")).toBe("x".repeat(8192));
});
it("rechecks the individual shared seed byte ceiling after verification", async () => {
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(8192));
const verify = vi.fn(async () => { await fs.writeFile(f.archive, "y".repeat(30_000)); return true; });
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
companyDirectory: f.company, scope: "company", limits: { workspaceBytes: 20_000, companyBytes: 100_000 }, verify,
})).toBe(false);
expect(verify).toHaveBeenCalledTimes(1);
expect(await fs.readdir(f.company)).toEqual([]);
expect(await fs.readFile(f.archive, "utf8")).toBe("y".repeat(30_000));
});
it("serializes concurrent archive publication against the aggregate byte cap", async () => {
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(1024));
const options = { companyDirectory: f.company, limits: { companyBytes: 5500 } };
@@ -167,12 +167,15 @@ async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ gene
export async function publishWorkspaceSeedGeneration(
root: string, generation: string, seed: WorkspaceDurableSeedPaths,
options: { companyDirectory?: string; limits?: Partial<SeedCacheLimits>; eviction?: Partial<SeedCacheEviction>;
now?: () => number; verify?: () => Promise<boolean> } = {},
scope?: "workspace" | "company"; now?: () => number; verify?: () => Promise<boolean> } = {},
): Promise<boolean> {
const limits = { ...WORKSPACE_SEED_CACHE_LIMITS, ...options.limits };
const configuredLimits = { ...WORKSPACE_SEED_CACHE_LIMITS, ...options.limits };
const limits = options.scope === "company" ? { ...configuredLimits,
workspaceGenerations: configuredLimits.companyGenerations, workspaceBytes: configuredLimits.companyBytes,
} : configuredLimits;
const eviction = { ...WORKSPACE_SEED_CACHE_EVICTION, ...options.eviction };
if (!/^[a-f0-9]{64}$/.test(generation)
|| Object.values(limits).some(value => !Number.isSafeInteger(value) || value < 0)) return false;
|| Object.values(configuredLimits).some(value => !Number.isSafeInteger(value) || value < 0)) return false;
root = path.resolve(root);
const company = path.resolve(options.companyDirectory ?? root);
if (root !== company && path.dirname(root) !== company) return false;
@@ -207,6 +210,7 @@ export async function publishWorkspaceSeedGeneration(
if (!stat.isFile() || stat.isSymbolicLink()) return false;
incomingBytes += stat.size;
}
if (incomingBytes > configuredLimits.workspaceBytes) return false;
if (workspace.generations + 1 > limits.workspaceGenerations || workspace.bytes + incomingBytes > limits.workspaceBytes
|| totalGenerations + 1 > limits.companyGenerations || totalBytes + incomingBytes > limits.companyBytes
|| workspaces + (workspaceExists ? 0 : 1) > limits.companyWorkspaces) return false;
@@ -217,6 +221,7 @@ export async function publishWorkspaceSeedGeneration(
if (initialCapacity === "existing") return true;
let incomingBytes = 4096;
for (const archive of [seed.workspaceArchivePath, seed.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 evictSeedGenerations(company, root, { ...plan, apply: false })) return false;
if (options.verify && !await options.verify()) return false;
@@ -5,7 +5,7 @@ import path from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, it, vi } from "vitest";
import { prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient, type SandboxSyncOperation } from "./sandbox-managed-runtime.js";
import { readWorkspaceSeedGeneration, workspaceSeedGeneration } from "./workspace-seed-cache.js";
import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration, workspaceSeedGeneration } from "./workspace-seed-cache.js";
import { captureDirectorySnapshot, disposeDirectorySnapshot } from "./workspace-restore-merge.js";
import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js";
import { prepareAdapterExecutionTargetRuntime } from "./execution-target.js";
@@ -69,6 +69,19 @@ it.each([
await git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "Seed");
}
const company = path.join(root, "company");
const otherWorkspace = path.join(root, "other-workspace"); await fs.mkdir(otherWorkspace);
const priorGenerations = new Set<string>();
for (let index = 0; index < 16; index++) {
await fs.writeFile(path.join(otherWorkspace, "file.txt"), `Other workspace ${index}\n`);
const snapshot = await captureDirectorySnapshot(otherWorkspace);
const generation = workspaceSeedGeneration(snapshot, null, [], "company");
await disposeDirectorySnapshot(snapshot);
const archive = path.join(root, `other-${index}.tar`);
await exec("tar", ["-cf", archive, "-C", otherWorkspace, "."]);
expect(await publishWorkspaceSeedGeneration(path.join(company, "shared-v1"), generation,
{ workspaceArchivePath: archive }, { companyDirectory: company, scope: "company" })).toBe(true);
priorGenerations.add(generation);
}
const prepare = async (name: string, branch: string | null, companyDirectory = company) => {
const host = path.join(root, name, "host"), remote = path.join(root, name, "remote");
for (const relative of repositories) {
@@ -124,8 +137,10 @@ it.each([
expect(second.phases).not.toContain("workspace.git_clone");
expect(second.phases).not.toContain("workspace.seed_publish");
const generations = await fs.readdir(path.join(company, "shared-v1"));
expect(generations).toHaveLength(1);
expect(await readWorkspaceSeedGeneration(path.join(company, "shared-v1"), generations[0]!, { companyDirectory: company })).not.toBeNull();
expect(generations).toHaveLength(17);
const current = generations.filter(generation => !priorGenerations.has(generation));
expect(current).toHaveLength(1);
expect(await readWorkspaceSeedGeneration(path.join(company, "shared-v1"), current[0]!, { companyDirectory: company })).not.toBeNull();
const recovery = path.join(root, "replacement");
const recovered = await prepareSandboxManagedRuntime({
...second.input,
@@ -13,6 +13,7 @@ import { agents, companies, completionContracts, createDb, heartbeatRuns, issues
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { issueService } from "../services/issues.js";
import { nativeWorkspaceFinalizationWork } from "../services/native-runtime/native-workspace-finalization-work.js";
import { getRunExecutionTraceContext, measureRunPhase } from "../services/execution-preparation-timing.js";
import { withNativeWorkspaceFinalizationOwnership } from "../services/native-runtime/native-workspace-finalization-ownership.js";
import { resumeNativeWorkspaceAfterOwnerStop } from "../services/native-runtime/native-workspace-owner-recovery.js";
import * as platformProof from "../services/native-runtime/native-workspace-platform-proof.js";
@@ -112,6 +113,25 @@ describe("workspace finalization scheduled lifecycle", () => {
return db.select().from(workspaceOperations).where(and(eq(workspaceOperations.heartbeatRunId, runId),
eq(workspaceOperations.phase, "workspace_finalize"), eq(workspaceOperations.status, "succeeded")));
}
it.each([true, false])("binds deferred workspace tracing only when the endpoint is enabled: %s", async enabled => {
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", enabled ? "http://collector.test" : "");
try {
const binding = await seed(); await complete(binding);
let observed = false;
const attempt = vi.fn(async () => {
observed = getRunExecutionTraceContext(binding.runId) !== undefined;
await measureRunPhase("workspace_sync.restore", async () => {});
return { kind: "inactive" } as const;
});
const service = nativeWorkspaceFinalizationWork(db, { attempt, onWorkspaceSettled: settled });
const entries = await service.load({ companyId, runId: binding.runId });
expect(entries).toHaveLength(1);
await expect(service.check(entries[0])).resolves.toBeNull();
expect(attempt).toHaveBeenCalledOnce();
expect(observed).toBe(enabled);
expect(getRunExecutionTraceContext(binding.runId)).toBeUndefined();
} finally { vi.unstubAllEnvs(); }
});
it.each(["restore", "worktree"])("honors %s suppression through the public heartbeat registration", async mode => {
const binding = await seed(); await complete(binding);
const runtimeEnv = mode === "restore" ? { PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS: "true" }
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createRunPreparationTiming, measureRunPhase, recordRepositoryObjectCacheDetail, withRunExecutionTiming } from "./execution-preparation-timing.js";
import { createRunPreparationTiming, measureRunPhase, recordRepositoryObjectCacheDetail, withRunExecutionTiming, withRunWorkspaceFinalizationTiming } from "./execution-preparation-timing.js";
import { ROOT_CONTEXT, trace, SpanStatusCode, type Context } from "@opentelemetry/api";
import { createHash } from "node:crypto";
const handles = vi.hoisted(() => ({ getTracer: vi.fn(), startActiveSpan: vi.fn() }));
vi.mock("@opentelemetry/api", async importOriginal => {
@@ -41,6 +43,7 @@ it.each([undefined, "", " "])("does not consult tracing for endpoint %s", async
const error = vi.spyOn(console, "error");
try {
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) {
const result = {};
@@ -64,6 +67,38 @@ it.each([undefined, "", " "])("does not consult tracing for endpoint %s", async
}
});
it("parents concurrent deferred copyback phases to their own finalization roots and preserves failure", async () => {
const spans: Array<{ name: string; attributes: Record<string, unknown>; context: Context;
span: { end: ReturnType<typeof vi.fn>; setStatus: ReturnType<typeof vi.fn> } }> = [];
handles.startActiveSpan.mockImplementation((name, options, context, callback) => {
const span = { end: vi.fn(), setStatus: vi.fn() };
spans.push({ name, attributes: options.attributes, context, span });
return callback(span);
});
await Promise.all(["first", "second"].map(runId => withRunWorkspaceFinalizationTiming(runId, async () => {
await Promise.resolve();
await measureRunPhase("workspace_sync.restore", async () => {});
})));
for (const runId of ["first", "second"]) {
const hash = createHash("sha256").update(runId).digest("hex").slice(0, 12);
const owned = spans.filter(span => span.attributes["paperclip.task.run.run_id"] === hash);
expect(owned).toHaveLength(2);
const root = owned.find(span => span.name === "task.workspace_finalization")!;
const child = owned.find(span => span.name === "workspace_sync.restore")!;
expect(root.context).toBe(ROOT_CONTEXT);
expect(trace.getSpan(child.context)).toBe(root.span);
expect(root.span.end).toHaveBeenCalledOnce();
expect(Object.keys(root.attributes)).toEqual(["paperclip.task.run.run_id"]);
}
const original = new Error("private finalization failure");
const work = vi.fn(() => { throw original; });
await expect(withRunWorkspaceFinalizationTiming("failed", work)).rejects.toBe(original);
expect(work).toHaveBeenCalledOnce();
expect(spans.at(-1)!.span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
expect(spans.at(-1)!.span.end).toHaveBeenCalledOnce();
expect(JSON.stringify(spans)).not.toContain(original.message);
});
it("keeps cache outcomes and clone references on their owning concurrent phases", async () => {
const spans: Array<{ name: string; attributes: Record<string, unknown> }> = [];
handles.startActiveSpan.mockImplementation((name, options, _context, callback) => {
@@ -189,6 +189,10 @@ export function withRunExecutionTiming<T>(runId: string, work: () => T | Promise
return measure(runId, "task.execution_attempt", work, true);
}
export function withRunWorkspaceFinalizationTiming<T>(runId: string, work: () => T | Promise<T>): Promise<T> {
return measure(runId, "task.workspace_finalization", work, true);
}
export async function measureRunPhase<T>(phase: ExecutionPhase, work: () => T | Promise<T>, counts?: { fileCount?: number; bytes?: number }): Promise<T> {
const owned = ownership.getStore();
if (!endpointEnabled() || !owned || owned.ended) return work();
@@ -4,6 +4,7 @@ import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import { onNativeWorkspaceFinalizationRelease, type NativeWorkspaceFinalizationScope } from "../native-workspace-finalization-notifications.js";
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
import { measureRunPhase, withRunWorkspaceFinalizationTiming } from "../execution-preparation-timing.js";
import { nativeRunnerOwnershipNotHeldCondition } from "./native-runner-ownership.js";
import { attemptNativeWorkspaceFinalization, recordNativeWorkspaceFinalizationFailure } from "./native-workspace-finalizer.js";
@@ -58,7 +59,10 @@ export function nativeWorkspaceFinalizationWork(db: Db, options: {
}
throw new Error("Native workspace finalization discovery stopped");
}
async function check(current: Entry, signal?: AbortSignal): Promise<Entry | null> {
function check(current: Entry, signal?: AbortSignal): Promise<Entry | null> {
return withRunWorkspaceFinalizationTiming(current.id, () => checkWithinTrace(current, signal));
}
async function checkWithinTrace(current: Entry, signal?: AbortSignal): Promise<Entry | null> {
if (signal?.aborted) throw new Error("Native workspace finalization stopped");
const scope = { companyId: current.companyId, runId: current.id };
const [row] = await candidates(scope);
@@ -66,9 +70,9 @@ export function nativeWorkspaceFinalizationWork(db: Db, options: {
const due = entry(row);
if (due.dueAt > Date.now()) return due;
if (signal?.aborted) throw new Error("Native workspace finalization stopped");
const outcome = await (options.attempt ?? attemptNativeWorkspaceFinalization)({
const outcome = await measureRunPhase("execution.workspace_finalize", () => (options.attempt ?? attemptNativeWorkspaceFinalization)({
db, runId: current.id, environmentRuntime: options.environmentRuntime, pluginWorkerManager: options.pluginWorkerManager,
});
}));
if (outcome.kind === "retry") return { ...due, dueAt: outcome.retryAt.getTime() };
if (outcome.kind === "waiting") {
return outcome.reason === "busy" || outcome.reason === "platform_stop_pending"
@@ -18,6 +18,7 @@ import { inspectManagedGitWorktreeBranch } from "../workspace-runtime.js";
import { environmentService } from "../environments.js";
import type { EnvironmentRuntimeService } from "../environment-runtime.js";
import { resolveEnvironmentExecutionTarget } from "../environment-execution-target.js";
import { measureWorkspaceSyncPhase } from "../execution-preparation-timing.js";
import {
readNativeWorkspaceSyncReference,
resumeNativeWorkspaceSync,
@@ -212,6 +213,7 @@ export async function attemptNativeWorkspaceFinalization(input: {
assertOwnership: ownership.assertHeld,
restore: () => resumeNativeWorkspaceSync({
db: input.db, runId: input.runId, target, assertOwnership: ownership.assertHeld,
onPhase: measureWorkspaceSyncPhase,
}),
});
await ownership.assertHeld();
+1 -1
View File
@@ -31,7 +31,7 @@ describe("Honeycomb run links", () => {
expect(query.filter_combination).toBe("AND");
expect(query.filters).toEqual([
{ column: "service.name", op: "=", value: "paperclip" },
{ column: "name", op: "in", value: ["task.run", "task.provider_session", "task.execution_attempt"] },
{ column: "name", op: "in", value: ["task.run", "task.provider_session", "task.execution_attempt", "task.workspace_finalization"] },
{
column: HONEYCOMB_RUN_HASH_ATTRIBUTE,
op: "=",
+1 -1
View File
@@ -26,7 +26,7 @@ export function buildHoneycombRunQueryUrl(runIdHash: string): string {
breakdowns: ["trace.trace_id"],
filters: [
{ column: "service.name", op: "=", value: "paperclip" },
{ column: "name", op: "in", value: ["task.run", "task.provider_session", "task.execution_attempt"] },
{ column: "name", op: "in", value: ["task.run", "task.provider_session", "task.execution_attempt", "task.workspace_finalization"] },
{ column: HONEYCOMB_RUN_HASH_ATTRIBUTE, op: "=", value: runIdHash },
],
filter_combination: "AND",