feat(observability): instrument stage.sync host steps and home the agent process span (#11301)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - It runs each agent in a remote sandbox and emits OpenTelemetry spans
for the sandbox bring-up and the run.
> - A real trace showed two gaps. `stage.sync` had about 3 seconds of
unattributed host work before its `pack` span. The persistent agent
process showed a `sandbox.exec` span that outlived its parent by about
50 seconds.
> - The gaps hide real cost and make the trace read as a sequencing bug,
so an operator cannot see where startup time goes.
> - This pull request wraps the two pre-`pack` host steps in their own
spans. It also homes the long-lived process in a run-scoped
`sandbox.agentProcess` span.
> - The benefit is that startup time is fully attributed and the process
reads as a resource that overlaps the turn, not a child that outlives
its parent.

## Linked Issues or Issue Description

No public issue exists. This is an enhancement to existing telemetry. It
is described inline below, following
`.github/ISSUE_TEMPLATE/enhancement.yml`. Prior related work: the merged
PR #10999 added the run-time wrapper spans and the telemetry
data-contract section this PR extends.

**What existing behavior does this improve?**

The sandbox bring-up and run OpenTelemetry trace. It closes two
attribution gaps in that trace.

**Subsystem affected**

Observability for sandbox execution. The code lives in
`packages/adapter-utils`. The span contract lives in
`packages/shared/src/telemetry`.

**Current behavior**

`stage.sync` opens a `pack` span, but the git enumeration and the
baseline content-hash walk that run before `pack` have no span, so about
3 seconds read as a gap. On the streamed process-session path the agent
process launches fire-and-forget inside the ~2.3 second
`bridge.process-session` bring-up step, so its `sandbox.exec` span
parents to that step and then runs about 50 seconds. The child dangles
past its parent and overlaps `agent.turn`.

**Proposed behavior**

Wrap the two pre-`pack` host operations in `snapshot.git` and
`snapshot.baseline` spans under `stage.sync`. Wrap the streamed launch
in a run-scoped `sandbox.agentProcess` span that parents to the live run
root (`task.run` at launch).

**Reason and benefit**

Startup time is fully attributed. The long-lived process reads as a
resource that overlaps the sibling `agent.turn`, not a mis-parented
child.

**Breaking changes**

None. The spans are opt-in and export only when an OTLP endpoint is
configured. The span seam is a no-op when no runner is injected. No
first-party telemetry event changes.

## What Changed

- `sandbox-managed-runtime.ts`: add `snapshot.git` and
`snapshot.baseline` spans around the git enumeration and the baseline
content-hash walk, nested under `stage.sync`, through a shared
`runStepSpan` helper that `pack` now also uses.
- `execution-target.ts`: wrap the fire-and-forget streamed launch in a
run-rooted `sandbox.agentProcess` span, so it parents to the live run
root and holds the inner `sandbox.exec`. The `.then`/`.catch` chain
became try/catch inside the span callback, with identical
frame-ingestion behavior.
- `packages/shared/src/telemetry/README.md`: update the span table and
the parenting prose. Add `snapshot.git`, `snapshot.baseline`, `pack`,
and `sandbox.agentProcess`, and document the intended
`sandbox.agentProcess` / `agent.turn` overlap.
- Tests: update the executor span-tree test (`childNames` and parent
assertions), update the `sandbox-managed-runtime` span-set and nesting
tests, and add two `execution-target-sandbox` tests (the launch opens
`sandbox.agentProcess`; it parents to the run root, not the bring-up
step).

## Verification

- Run `npx vitest run` on the three affected test files. Result: 174
tests pass. This includes the updated executor span-tree test and the
new `sandbox.agentProcess` open and parenting tests.
- Run `tsc --noEmit` in `packages/adapter-utils`. Result: no errors in
the changed source or test files.
- The full 37-test streamed process-session suite passes unchanged. This
confirms the try/catch restructure preserves frame delivery and
exit/error behavior.
- Pre-existing and unrelated to this PR (present on `master`): `tsc`
errors in `execute.ts` / `execute.test.ts` /
`remote-spawn-smoke.test.ts` (`onAgentStderr` / `spawnCwd`), and a
`check:forbidden-tokens` failure from internal `PAP-###` ids in
`ui/src/components/IssueRecoveryActionCard.test.tsx`. This PR does not
touch those files, and its own diff is token-clean.

## Risks

Low. The change adds instrumentation on the opt-in span path and does
not change control flow on the default path. The one production
restructure is the streamed launch, which stays fire-and-forget, so
bring-up does not block on it. Only the streamed path gains
`sandbox.agentProcess`; the legacy poll path launches the process
detached and has no host-side long-lived span to home.

## Model Used

Anthropic Claude Opus 4.8 (`claude-opus-4-8`), about 200K-token context,
agentic tool use through Claude Code. The trace was reviewed through the
Honeycomb MCP. The code was written and tested with the model.

## 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: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nicky Leach 2026-08-12 16:06:05 -07:00 committed by GitHub
parent 61a5b7c6f9
commit 04bf7a6ab5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 368 additions and 52 deletions

View File

@ -3272,8 +3272,10 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
// A codex bring-up over the remote sandbox lane crosses all 7 boundaries.
// Each boundary span parents to the sandbox bring-up span, not to the run
// root or the turn span. The `stage.sync` step also opens one host `pack`
// span around the workspace tarball build, so it nests one level deeper.
// root or the turn span. The `stage.sync` step also opens three host
// sub-step spans — `snapshot.git`, `snapshot.baseline`, and `pack` — around
// its git enumeration, baseline content-hash walk, and workspace tarball
// build, so those nest one level deeper.
const childNames = spans
.filter((span) => span !== runRootSpan && span !== startupSpan && span !== turnSpan)
.map((span) => span.name)
@ -3286,26 +3288,35 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
"codex-home.seed",
"pack",
"skills.reconcile",
"snapshot.baseline",
"snapshot.git",
"stage.sync",
"workspace.resolve",
],
);
// The host `pack` span nests under the `stage.sync` step span (the host
// tarball build runs inside that step), not directly under the bring-up
// span.
// The three host sub-step spans nest under the `stage.sync` step span (that
// host work runs inside the step), not directly under the bring-up span.
const stageSyncSpan = spans.find((span) => span.name === "stage.sync");
const packSpan = spans.find((span) => span.name === "pack");
const snapshotGitSpan = spans.find((span) => span.name === "snapshot.git");
const snapshotBaselineSpan = spans.find((span) => span.name === "snapshot.baseline");
expect(stageSyncSpan).toBeTruthy();
expect(packSpan).toBeTruthy();
expect(snapshotGitSpan).toBeTruthy();
expect(snapshotBaselineSpan).toBeTruthy();
expect(packSpan!.parent).toBe(stageSyncSpan);
expect(snapshotGitSpan!.parent).toBe(stageSyncSpan);
expect(snapshotBaselineSpan!.parent).toBe(stageSyncSpan);
expect(packSpan!.ended).toBe(true);
// Every boundary step span parents to the sandbox bring-up span and ends.
// The `pack` span is the one exception: it parents to `stage.sync` above.
// The three `stage.sync` sub-step spans are the exceptions: they parent to
// `stage.sync` above.
const stageSyncChildren = new Set([packSpan, snapshotGitSpan, snapshotBaselineSpan]);
for (const span of spans) {
if (span === runRootSpan || span === startupSpan || span === turnSpan) continue;
if (span === packSpan) continue;
if (stageSyncChildren.has(span)) continue;
expect(span.parent, `span "${span.name}" must parent to the startup span`).toBe(startupSpan);
expect(span.ended, `span "${span.name}" must end`).toBe(true);
}

View File

@ -23,13 +23,55 @@ import {
startAdapterExecutionTargetPaperclipBridge,
type AdapterSandboxExecutionTarget,
} from "./execution-target.js";
import { getActiveStepContext } from "./acpx-engine/startup-timing.js";
import {
createRuntimeSpanRunner,
getActiveStepContext,
type StartupSpan,
type StartupTraceContext,
type StartupTracer,
} from "./acpx-engine/startup-timing.js";
import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js";
import { runChildProcess } from "./server-utils.js";
import { shellQuote } from "./ssh.js";
const execFileAsync = promisify(execFile);
type RecordedSpan = { name: string; parentName: string | null; ended: boolean };
/**
* A structural tracer that records each opened span's name, parent, and end
* state, so a test can assert the trace shape a runtime span runner produces.
* Mirrors the recorder used for the `pack`/`stage.sync` nesting tests.
*/
function createRecordingTraceContext(): {
traceContext: StartupTraceContext;
spans: RecordedSpan[];
} {
const spans: RecordedSpan[] = [];
const byHandle = new WeakMap<StartupSpan, RecordedSpan>();
const tracer: StartupTracer = {
startSpan(name, _options, context) {
const parent = context as RecordedSpan | undefined;
const record: RecordedSpan = { name, parentName: parent?.name ?? null, ended: false };
spans.push(record);
const handle: StartupSpan = {
setAttribute() {},
setStatus() {},
end() {
record.ended = true;
},
};
byHandle.set(handle, record);
return handle;
},
};
const traceContext: StartupTraceContext = {
tracer,
contextWithSpan: (span) => byHandle.get(span),
};
return { traceContext, spans };
}
describe("sandbox adapter execution targets", () => {
const cleanupDirs: string[] = [];
@ -950,6 +992,187 @@ describe("sandbox adapter execution targets", () => {
}
});
it("wraps the long-lived streamed launch in a sandbox.agentProcess span", async () => {
// The streamed launch is fire-and-forget and lives for the whole run, so
// its span must open under the live run root (not the ephemeral bring-up
// step) and stay open around the launch. Record the opened span names and
// prove `sandbox.agentProcess` is among them, and that a normal exchange
// still works through the wrap.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-span-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "echo-acp-child.mjs");
await writeFile(
childPath,
[
"process.stdin.on('data', (chunk) => {",
" process.stdout.write('out:' + chunk.toString());",
"});",
].join("\n"),
"utf8",
);
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const spanNames: string[] = [];
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stream-span",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
streamOutputViaSession: true,
// Record each wrapper span name, then run the wrapped work.
runtimeSpan: async (name, work) => {
spanNames.push(name);
return work();
},
});
expect(bridge).not.toBeNull();
try {
// The launch span opens synchronously as the bridge starts, before any
// frame flows, so it is observable as soon as the handle resolves.
expect(spanNames).toContain("sandbox.agentProcess");
const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
expect(result.code).toBe(0);
expect(result.stdout).toBe("out:hello\n");
} finally {
await bridge?.stop();
}
});
it("parents the sandbox.agentProcess span to the live run root, not the bring-up step", async () => {
// The launch runs for the whole run, so its span must parent to the live
// run root (here a stand-in `task.run`) rather than the ephemeral
// `bridge.process-session` bring-up step — otherwise it dangles past its
// parent and overlaps `agent.turn`. Build the real run-rooted runner from a
// recording trace context and assert the recorded parent.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-parent-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "noop-acp-child.mjs");
await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8");
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
const { traceContext, spans } = createRecordingTraceContext();
// The run root stands in for `task.run` — the parent the run-rooted runner
// resolves at launch time, since no turn has started yet.
const runRoot = traceContext.tracer.startSpan("task.run", undefined, undefined);
const runRootContext = traceContext.contextWithSpan(runRoot);
const runtimeSpan = createRuntimeSpanRunner(traceContext, () => runRootContext);
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stream-parent",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
streamOutputViaSession: true,
runtimeSpan,
});
expect(bridge).not.toBeNull();
try {
const agentProcess = spans.find((span) => span.name === "sandbox.agentProcess");
expect(agentProcess).toBeDefined();
expect(agentProcess!.parentName).toBe("task.run");
} finally {
await bridge?.stop();
}
});
it("ends the sandbox.agentProcess span at stop() even when the process lingers", async () => {
// The span must not outlive the run root. When the remote process lingers
// past bridge teardown (`execute` has no cancel), the span still has to end
// at `stop()`, which the caller awaits before it ends `task.run`. Use a
// child that ignores stdin and never exits on its own, so the launch
// command stays pending across `stop()`, and prove the span ends anyway.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-linger-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "linger-acp-child.mjs");
await writeFile(
childPath,
[
"process.stdin.on('data', () => {});",
// Stay alive well past the assertions, then self-exit so the test
// leaves no lingering process.
"setTimeout(() => process.exit(0), 3000);",
].join("\n"),
"utf8",
);
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
};
// Track when each wrapper span's work settles (i.e. when its span ends).
const spanRecords: Array<{ name: string; ended: boolean }> = [];
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stream-linger",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 10,
onLog: async () => {},
streamOutputViaSession: true,
runtimeSpan: (name, work) => {
const record = { name, ended: false };
spanRecords.push(record);
const promise = work();
void promise.then(
() => {
record.ended = true;
},
() => {
record.ended = true;
},
);
return promise;
},
});
expect(bridge).not.toBeNull();
const record = spanRecords.find((span) => span.name === "sandbox.agentProcess");
expect(record).toBeDefined();
// The launch command is still running, so the span is still open.
expect(record!.ended).toBe(false);
// Teardown ends the span promptly, without waiting for the lingering command.
await bridge!.stop();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(record!.ended).toBe(true);
});
it("buffers streamed output until the local proxy connects", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-buffer-"));
cleanupDirs.push(rootDir);

View File

@ -1514,6 +1514,13 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
let socket: net.Socket | null = null;
let stopping = false;
// Resolves when `stop()` tears the bridge down. The streamed `sandbox.agentProcess`
// span races its work against this, so the span ends at teardown at the latest
// even when the remote process lingers, and never outlives the run root span.
let signalStopped: () => void = () => {};
const stopped = new Promise<void>((resolve) => {
signalStopped = resolve;
});
let stdinSeq = 0;
let pollTimer: NodeJS.Timeout | null = null;
const pendingRemoteEvents: Array<{
@ -1742,39 +1749,63 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// persistent session so the provider streams the wrapper stdout back through
// `onLog`. On resolve, the terminal re-parse fills any frames the live stream
// missed; on reject, deliver one error frame so the local proxy fails loud.
void runner
.execute({
command: shellCommand,
args: shellCommandArgs(`node ${shellQuote(remoteScriptPath)}`),
cwd: target.remoteCwd,
env: {
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: streamCommandPayload,
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
},
timeoutMs,
useSession: true,
onLog: async (stream, chunk) => {
if (stream === "stdout") ingestStreamChunk(chunk);
},
})
.then((result) => {
ingestFinalText(result.stdout);
if (!sawTerminal && !stopping) {
deliverRemoteEvent({
type: "exit",
code: typeof result.exitCode === "number" ? result.exitCode : null,
//
// Wrap the launch in a `sandbox.agentProcess` span. `runRuntimeWork` parents
// it to the LIVE RUN root (`task.run` at launch time — no turn has started
// yet), not to the ephemeral `bridge.process-session` bring-up step, and it
// stays open for the whole process lifetime. The inner `sandbox.exec` nests
// under it. Without the wrapper the raw exec's span inherits the ~2.28s
// bring-up step as its parent and then dangles ~50s past it, overlapping
// `agent.turn` — a child outliving its parent. As a run-scoped span it reads
// instead as a resource that OVERLAPS the sibling `agent.turn`, which is the
// correct shape (the persistent process hosts the turn; it is not a child of
// it, and on multi-turn runs one process spans several turns). `runRuntimeWork`
// is voided, not awaited, so bring-up never blocks on the long-lived command,
// and it defaults to a no-op parent when no span runner is injected.
//
// The span is bounded to the bridge lifecycle: it ends when the command
// settles OR when `stop()` runs, whichever comes first. `stop()` runs during
// run teardown, before the caller ends the `task.run` root span, so the span
// never outlives the run root even if the remote process lingers past
// teardown (`execute` has no cancel, so a lingering process cannot be forced
// to resolve). The command promise keeps running after the span ends so its
// frame handlers still deliver; they no-op once `stopping` is set.
void runRuntimeWork("sandbox.agentProcess", async () => {
const commandSettled = (async () => {
try {
const result = await runner.execute({
command: shellCommand,
args: shellCommandArgs(`node ${shellQuote(remoteScriptPath)}`),
cwd: target.remoteCwd,
env: {
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: streamCommandPayload,
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
},
timeoutMs,
useSession: true,
onLog: async (stream, chunk) => {
if (stream === "stdout") ingestStreamChunk(chunk);
},
});
ingestFinalText(result.stdout);
if (!sawTerminal && !stopping) {
deliverRemoteEvent({
type: "exit",
code: typeof result.exitCode === "number" ? result.exitCode : null,
});
}
} catch (error) {
if (!stopping) {
deliverRemoteEvent({
type: "error",
message: error instanceof Error ? error.message : String(error),
});
}
}
})
.catch((error) => {
if (!stopping) {
deliverRemoteEvent({
type: "error",
message: error instanceof Error ? error.message : String(error),
});
}
});
})();
await Promise.race([commandSettled, stopped]);
});
} else {
schedulePoll();
}
@ -1783,6 +1814,9 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
agentCommand,
stop: async () => {
stopping = true;
// End the `sandbox.agentProcess` span now, before the caller ends the run
// root span, even if the remote command has not resolved yet.
signalStopped();
if (pollTimer) clearTimeout(pollTimer);
for (const liveSocket of liveSockets) liveSocket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve())).catch(() => undefined);

View File

@ -2029,8 +2029,9 @@ describe("sandbox managed runtime", () => {
await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8");
// Record every span name the runner opens and run the wrapped work, so the
// test proves the host opens exactly one `pack` span around the tarball
// build for the usual (plain) workspace sync.
// test proves the host opens a span around each host-side staging sub-step
// for the usual (plain) workspace sync: the git enumeration, the baseline
// content-hash walk, and the tarball build, in that order.
const openedSpans: string[] = [];
const runtimeSpan: RuntimeSpanRunner = async (name, work) => {
openedSpans.push(name);
@ -2052,7 +2053,7 @@ describe("sandbox managed runtime", () => {
runtimeSpan,
});
expect(openedSpans).toEqual(["pack"]);
expect(openedSpans).toEqual(["snapshot.git", "snapshot.baseline", "pack"]);
// The tarball build still lands the workspace inside the span, so the wrap
// changes no staging behavior.
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("workspace body\n");
@ -2120,5 +2121,14 @@ describe("sandbox managed runtime", () => {
// The `pack` span parents to `stage.sync`, not to the root span, so it nests
// under the step in a real trace.
expect(packSpan!.parentName).toBe("stage.sync");
// The two pre-`pack` staging sub-steps nest under `stage.sync` the same way,
// so the previously hidden gap at the head of the step is now attributed.
for (const name of ["snapshot.git", "snapshot.baseline"]) {
const span = spans.find((candidate) => candidate.name === name);
expect(span, name).toBeDefined();
expect(span!.ended).toBe(true);
expect(span!.parentName).toBe("stage.sync");
}
});
});

View File

@ -714,7 +714,23 @@ export async function prepareSandboxManagedRuntime(input: {
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
const syncWorkspace = input.syncWorkspace !== false;
const gitSnapshot = syncWorkspace ? await readGitWorkspaceSnapshot(input.workspaceLocalDir) : null;
// Wrap a host-side staging sub-step in its own span when the caller injects a
// runtime span runner. Mirrors the `pack` span below: the runner defaults to a
// no-op, so a caller with no injected runner keeps the current control flow,
// and a throwing runner never changes it (see `createRuntimeSpanRunner`). Each
// span parents under the `stage.sync` step, so the two pre-`pack` operations —
// the git enumeration and the baseline content-hash walk — stop showing up as
// a hidden gap at the head of the step.
const runStepSpan = <T>(name: string, work: () => Promise<T>): Promise<T> =>
input.runtimeSpan ? input.runtimeSpan(name, work) : work();
// The git enumeration (`git status --ignored`, the HEAD diffs, `ls-files`).
// It reads git's own bookkeeping to decide what to include/exclude, so it is
// usually fast, but on a large working tree the `--ignored` walk is not free.
const gitSnapshot = syncWorkspace
? await runStepSpan("snapshot.git", () => readGitWorkspaceSnapshot(input.workspaceLocalDir))
: null;
const gitIgnoredExcludes = gitSnapshot?.ignoredPaths;
const workspaceArchiveExclude = mergeExcludes(
SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
@ -730,8 +746,14 @@ export async function prepareSandboxManagedRuntime(input: {
input.workspaceExclude,
gitIgnoredExcludes,
);
// The baseline "before" snapshot: a recursive walk of the whole workspace that
// `lstat`s every entry and SHA-256-hashes every file's bytes. This is the
// dominant cost in the pre-`pack` window — it reads the content of every
// non-excluded file, serially — so it earns its own span.
const baselineSnapshot = syncWorkspace
? await captureDirectorySnapshot(input.workspaceLocalDir, { exclude: restoreExclude })
? await runStepSpan("snapshot.baseline", () =>
captureDirectorySnapshot(input.workspaceLocalDir, { exclude: restoreExclude }),
)
: null;
// Every inbound staging step delegates to the provider through `client.syncIn`:
@ -849,9 +871,7 @@ export async function prepareSandboxManagedRuntime(input: {
// span, so `pack` measures only the host tar-build cost. The runner
// defaults to a no-op, so a caller with no injected runner keeps the
// current behavior and control flow.
const runPackSpan = <T>(work: () => Promise<T>): Promise<T> =>
input.runtimeSpan ? input.runtimeSpan("pack", work) : work();
await runPackSpan(async () => {
await runStepSpan("pack", async () => {
// 1. git-history tar (git-backed workspace only). Both tar targets live under
// `runtimeRootDir` (`.paperclip-runtime/<adapterKey>`). The git extract
// wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,

View File

@ -90,21 +90,39 @@ absent, never a misleading `0`.
| `codex-home.seed` | Managed-home seed step. | `sandbox.startup` |
| `skills.reconcile` | Skills reconcile step. | `sandbox.startup` |
| `stage.sync` | Workspace stage-sync step. | `sandbox.startup` |
| `snapshot.git` | Host-side git workspace enumeration inside `stage.sync` (`git status --ignored`, the HEAD diffs, `ls-files`). | `stage.sync` |
| `snapshot.baseline` | Host-side baseline workspace content-hash walk inside `stage.sync`, kept for restore. | `stage.sync` |
| `pack` | Host-side workspace tarball build inside `stage.sync`. | `stage.sync` |
| `bridge.paperclip` | Paperclip bridge start step. | `sandbox.startup` |
| `bridge.process-session` | Process-session bridge start step. | `sandbox.startup` |
| `acp.handshake` | ACP session handshake step. | `sandbox.startup` |
| `sandbox.agentSession.sendInput` | One outbound ACP message to the agent — the socket handler's one `writeTextFile` exec. | the active run span |
| `sandbox.agentSession.pollOutput` | One 100 ms poll tick — `list`, then `read`+`remove` per file found (`1 + 2n` execs). | the active run span |
| `sandbox.callbackBridge.relayRequest` | One Paperclip-API callback request — read the request, write the response, remove it. | the active run span |
| `sandbox.agentProcess` | The persistent streamed agent process the process-session bridge launches; open until the process settles or the bridge tears down, whichever comes first. | the active run span |
| `sandbox.exec` | One host-to-sandbox execution. | the active step or wrapper span |
A step span name is the step name. The `sandbox.exec` span parents to the step
span that runs the execution, so each execution nests under its step. A run-time
span that runs the execution, so each execution nests under its step. Within
`stage.sync`, the host-side sub-steps `snapshot.git`, `snapshot.baseline`, and
`pack` open as child spans of the step, so the host work at the head of the step
is attributed rather than showing as a gap. A run-time
`sandbox.exec` span parents instead to the run-time wrapper span that runs it
(`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`, or
`sandbox.callbackBridge.relayRequest`). Each run-time wrapper span parents to the
live run span (`agent.turn` during the turn, `task.run` otherwise). With no
active trace context the exec span opens unparented.
(`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`,
`sandbox.callbackBridge.relayRequest`, or `sandbox.agentProcess`). Each run-time
wrapper span parents to the live run span (`agent.turn` during the turn,
`task.run` otherwise). With no active trace context the exec span opens
unparented.
`sandbox.agentProcess` wraps the persistent streamed agent process. The
process-session bridge launches it during `bridge.process-session`, so it opens
under `task.run` — no turn has started yet. It therefore overlaps the sibling
`agent.turn` rather than nesting under it or dangling off the short-lived bring-up
step. The span ends when the process settles or when the bridge tears down,
whichever comes first. The bridge tears down before the run root span ends, so
the span never outlives `task.run` even when the process lingers past teardown
(the sandbox `execute` has no cancel, so a lingering process cannot be forced to
resolve).
The root span sets the error status when the bring-up fails. Each step span sets
the error status when its step fails. The `sandbox.exec` span sets the error