mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server prepares an execution environment before it starts an agent. > - Operators need to distinguish preparation, provider wait, and response persistence. > - Existing preparation phases start separate traces, and the native session trace ends before the final response is saved. > - This pull request adds an execution-attempt trace and detailed spans at the existing work boundaries. > - The benefit is a measurable startup and settlement path without changing execution policy. ## Linked Issues or Issue Description **What existing behavior does this improve?** Operator-configured OpenTelemetry tracing for agent preparation, startup, and durable response storage. **Subsystem affected** Server orchestration, native Runner startup, and the Honeycomb run link. **Current behavior** Preparation phases start independent roots. Workspace lock waits, authorization groups, runtime asset work, provider-pack fallback, and the gap after provider completion are hard to attribute. **Proposed behavior** Use a server-owned async context for each execution attempt. Parent nested work to that attempt. Link the separately sampled native session because its ingress and queue history can begin earlier. Record transaction entry, lock waits, grouped authorization, asset preparation, artifact fallback, copyback, and response commit. **Reason and benefit** Operators can identify expensive work from recorded boundaries instead of inferring the cause from elapsed time. Nested and concurrent durations remain visible and must not be added together. **Breaking changes** No API or execution-policy change. New Observability span names and closed count attributes are documented. The existing local run-performance schema also records the additional provider-pack and harness measurements. Refs: #16178, #16205, #16330. ## What Changed - Add `task.execution_attempt` with same-run async ownership, independent root sampling, and native-session links. - Measure workspace transaction entry and settlement, advisory and row waits, source scans, reader groups, notifications, and writes. - Measure runtime selection and each concurrent instructions, skills, and MCP branch. Separate manifest, cache, materialization, and verification work. - Measure provider-pack discovery, verified reuse or upload, final verification, and remote harness state checks. - Measure workspace settlement, accounting, liveness, response authorization, comment commit, and publication enqueue. - Preserve the silent endpoint-off path, including when another library has registered a tracer. Bound repeated detail spans to 16 per name per attempt. - Extend the Honeycomb run link and document the actual coverage and remaining gaps. ## Verification - After resolving the conflict with retained-reader batching on `b98cdaea531c8447637cd4dcc19c61b3f5f41844`, all 61 targeted query, authorization, workspace-preparation and trace-boundary tests passed. The server typecheck passed. Reader tests still bound query count, avoid loading retained run payloads and enforce fresh project grants on every publication attempt. - Before that base integration, the tracing/exporter group passed 855 tests with 2 optional-SDK skips across 10 files and no inherited OTLP endpoint. It covers real PostgreSQL waits/rollback, durable response commit, assembled heartbeat preparation, concurrent parentage, sampling and tracer failure isolation. Native workspace/history tests passed 23 cases. These results remain supporting evidence for the unchanged tracing paths. - Full workspace typecheck and build passed during implementation. Full sharded CI, release validation, E2E and Apex 5/5 passed on prior head `8cebad80f3856dbcff6f3a6d5d34880ff77a20f2`. Fresh CI, security and Apex review are required on the current head before merge. - `pnpm check:token-gates` passed. No styles changed. - Earlier CI runs exposed exact-source-expression assertions changed by wrappers, missing run hashes on transaction-boundary spans, and sampling fixtures without an enabled endpoint. The fixes preserve the calls, add the declared hash and span cap, and enable tracing explicitly in the sampling fixtures. Focused repairs passed; the full prior-head CI passed after those repairs. Earlier failed run: https://github.com/paperclipai/paperclip/actions/runs/38729131366. - The full local `pnpm test:run` was stopped after the base advanced during that process. Its mixed-version results are not final-head evidence. A full local-suite pass is not claimed. - The old-head Superagent scan stalled at 35%. The current head has a fresh scan; no security pass is inferred from the old pending result. - No deployed Honeycomb acceptance is claimed by this code change. ## Risks - Enabled tracing creates more spans. Repeated detail spans are bounded, and inventories use group spans. - The attempt and native session remain linked traces. Historical queue time can precede the attempt, and nested intervals overlap. - A committed comment proves durable server storage. Publication enqueue and realtime dispatch do not prove external delivery or browser paint. - New custom attributes use only the existing run hash and validated file/byte counts. No SQL, paths, permissions, content, or exception text is added. ## Model Used OpenAI GPT-6 through Codex. The serving model ID and context window size are not exposed in this session. The agent used reasoning, tools, code editing, and code execution. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
549 lines
17 KiB
TypeScript
549 lines
17 KiB
TypeScript
import { getRunExecutionTraceContext } from "../execution-preparation-timing.js";
|
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import { createHash } from "node:crypto";
|
|
import { isSpanContextValid, ROOT_CONTEXT, trace } from "@opentelemetry/api";
|
|
|
|
import { runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
import type { AdapterRuntimeEvent } from "../../adapters/index.js";
|
|
import {
|
|
getStartupTraceContext,
|
|
type StartupTraceContextHandle,
|
|
} from "../../instrumentation.js";
|
|
|
|
export const NATIVE_RUN_SPAN_EVENT_TYPE = "run.performance.span";
|
|
export const NATIVE_RUN_TRACE_SCHEMA_VERSION = 3;
|
|
|
|
export type NativeRunSpanOutcome = "ok" | "failed";
|
|
|
|
export interface NativeRunHistoricalSpan {
|
|
name: string;
|
|
parentName?: string;
|
|
startedAtMs: number;
|
|
endedAtMs: number;
|
|
outcome?: NativeRunSpanOutcome;
|
|
attributes?: Record<string, string | number | boolean>;
|
|
}
|
|
|
|
export function isNativeRunRootHistoricalSpan(name: string): boolean {
|
|
return (
|
|
name === "heartbeat.queue" ||
|
|
name === "comment.to_run_created" ||
|
|
name === "question_response.to_run_created"
|
|
);
|
|
}
|
|
|
|
/** Current causal ingress, not the older source comment kept for provenance. */
|
|
export function buildNativeWakeIngressSpan(input: {
|
|
runCreatedAtMs: number;
|
|
wakeComments: readonly unknown[];
|
|
/** Attempt-local output of committed server authorization, never a wake marker. */
|
|
attestedQuestionResponseAtMs: number | null;
|
|
}): NativeRunHistoricalSpan | null {
|
|
const answeredAt = input.attestedQuestionResponseAtMs;
|
|
if (answeredAt !== null && Number.isFinite(answeredAt) && answeredAt >= 0) {
|
|
return {
|
|
name: "question_response.to_run_created",
|
|
parentName: "task.provider_session",
|
|
startedAtMs: answeredAt,
|
|
endedAtMs: Math.max(answeredAt, input.runCreatedAtMs),
|
|
};
|
|
}
|
|
const createdAt = input.wakeComments
|
|
.map((comment) => {
|
|
const value =
|
|
comment && typeof comment === "object" && !Array.isArray(comment)
|
|
? (comment as Record<string, unknown>).createdAt
|
|
: null;
|
|
return typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
})
|
|
.filter(Number.isFinite)
|
|
.sort((a, b) => a - b)[0];
|
|
return createdAt === undefined
|
|
? null
|
|
: {
|
|
name: "comment.to_run_created",
|
|
parentName: "task.provider_session",
|
|
startedAtMs: createdAt,
|
|
endedAtMs: Math.max(createdAt, input.runCreatedAtMs),
|
|
};
|
|
}
|
|
|
|
export function buildNativeHeartbeatPreparationSpans(input: {
|
|
runCreatedAtMs: number;
|
|
runStartedAtMs: number;
|
|
attemptStartedAtMs: number;
|
|
environmentAcquireStartedAtMs: number;
|
|
environmentRealizeEndedAtMs: number;
|
|
nativeDispatchAtMs: number;
|
|
}): NativeRunHistoricalSpan[] {
|
|
return [
|
|
{
|
|
name: "heartbeat.queue",
|
|
parentName: "task.provider_session",
|
|
startedAtMs: input.runCreatedAtMs,
|
|
endedAtMs: Math.max(input.runCreatedAtMs, input.runStartedAtMs),
|
|
},
|
|
{
|
|
name: "heartbeat.prepare_before_environment",
|
|
parentName: "task.provider_session",
|
|
// A same-run resume retains startedAt for wall-time accounting; it is
|
|
// not the beginning of this dispatch attempt's preparation.
|
|
startedAtMs: input.attemptStartedAtMs,
|
|
endedAtMs: Math.max(
|
|
input.attemptStartedAtMs,
|
|
input.environmentAcquireStartedAtMs,
|
|
),
|
|
},
|
|
{
|
|
name: "heartbeat.prepare_after_environment",
|
|
parentName: "task.provider_session",
|
|
startedAtMs: Math.min(
|
|
input.nativeDispatchAtMs,
|
|
input.environmentRealizeEndedAtMs,
|
|
),
|
|
endedAtMs: input.nativeDispatchAtMs,
|
|
},
|
|
];
|
|
}
|
|
|
|
export function nativeRunPreparationStarts(
|
|
spans: NativeRunHistoricalSpan[],
|
|
nowMs: number,
|
|
): { runStartedAtMs: number; preparationStartedAtMs: number } {
|
|
const runStartedAtMs = spans.reduce(
|
|
(earliest, span) => Math.min(earliest, span.startedAtMs),
|
|
nowMs,
|
|
);
|
|
// Queue and accepted-comment latency remain run-level history. Including
|
|
// them in task.prepare would charge previous attempts and recovery delays
|
|
// to every resumed attempt, even if its actual startup took milliseconds.
|
|
const preparationStartedAtMs = spans
|
|
.filter((span) => !isNativeRunRootHistoricalSpan(span.name))
|
|
.reduce((earliest, span) => Math.min(earliest, span.startedAtMs), nowMs);
|
|
return { runStartedAtMs, preparationStartedAtMs };
|
|
}
|
|
|
|
export interface NativeRunSpanScope {
|
|
readonly name: string;
|
|
readonly parentName: string;
|
|
readonly startedAtMs: number;
|
|
}
|
|
|
|
type NativeRunTraceSink = (event: AdapterRuntimeEvent) => Promise<void>;
|
|
|
|
type SpanHandle = ReturnType<StartupTraceContextHandle["tracer"]["startSpan"]>;
|
|
|
|
interface NativeRunSpanScopeState extends NativeRunSpanScope {
|
|
span: SpanHandle;
|
|
context: unknown;
|
|
attributes: Record<string, string | number | boolean>;
|
|
ended: boolean;
|
|
}
|
|
|
|
const NOOP_SPAN: SpanHandle = {
|
|
setAttribute() {},
|
|
setStatus() {},
|
|
end() {},
|
|
};
|
|
|
|
const SAFE_NATIVE_RUN_SPAN_ATTRIBUTE_KEYS = new Set([
|
|
"adapter",
|
|
"bytesTransferred",
|
|
"connectionOwner",
|
|
"driver",
|
|
"duration_ms",
|
|
"eventKind",
|
|
"harness",
|
|
"identityPresent",
|
|
"lifecycleMode",
|
|
"mode",
|
|
"outcome",
|
|
"operation",
|
|
"provider",
|
|
"reason",
|
|
"runtime",
|
|
"stateSource",
|
|
"strategy",
|
|
"target",
|
|
]);
|
|
|
|
function finiteMs(value: number): number {
|
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
}
|
|
|
|
function hashedId(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
}
|
|
|
|
function safeAttributes(
|
|
attributes: Record<string, string | number | boolean> | undefined,
|
|
): Record<string, string | number | boolean> {
|
|
if (!attributes) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(attributes)
|
|
.filter(
|
|
([key, value]) =>
|
|
SAFE_NATIVE_RUN_SPAN_ATTRIBUTE_KEYS.has(key) &&
|
|
(typeof value === "boolean" ||
|
|
(typeof value === "number" && Number.isFinite(value)) ||
|
|
(typeof value === "string" && value.length <= 120)),
|
|
)
|
|
.slice(0, 16),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run-scoped tracing for the native Paperclip runner path.
|
|
*
|
|
* A scope owns a real OpenTelemetry parent context. While work runs inside a
|
|
* scope, nested native measurements and the existing sandbox/provider tracing
|
|
* seams inherit that context. The persisted run-event copy retains the same
|
|
* semantic parent name for developer installs without an OTLP collector.
|
|
*/
|
|
export function createNativeRunTrace(input: {
|
|
runId: string;
|
|
startedAtMs?: number;
|
|
onEvent?: NativeRunTraceSink;
|
|
traceContext?: StartupTraceContextHandle;
|
|
}) {
|
|
const tracing =
|
|
input.traceContext ?? getStartupTraceContext("paperclip.native-runner");
|
|
const traceStartedAtMs = finiteMs(input.startedAtMs ?? Date.now());
|
|
const runIdHash = hashedId(input.runId);
|
|
let rootSpan: SpanHandle;
|
|
try {
|
|
const owned = getRunExecutionTraceContext(input.runId);
|
|
const trigger = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()
|
|
? (owned ? trace.getSpan(owned) : trace.getActiveSpan())?.spanContext()
|
|
: undefined;
|
|
rootSpan = tracing.tracer.startSpan("task.provider_session", {
|
|
startTime: traceStartedAtMs,
|
|
attributes: {
|
|
"paperclip.task.run.run_id": runIdHash,
|
|
"paperclip.task.run.runtime": "paperclip_runner",
|
|
"paperclip.task.run.trace_schema_version":
|
|
NATIVE_RUN_TRACE_SCHEMA_VERSION,
|
|
},
|
|
...(trigger && isSpanContextValid(trigger) ? { links: [{ context: trigger }] } : {}),
|
|
}, ROOT_CONTEXT);
|
|
} catch {
|
|
rootSpan = NOOP_SPAN;
|
|
}
|
|
let rootContext: unknown;
|
|
try {
|
|
rootContext = tracing.contextWithSpan(rootSpan);
|
|
} catch {
|
|
rootContext = undefined;
|
|
}
|
|
|
|
const scopeStorage = new AsyncLocalStorage<
|
|
NativeRunSpanScopeState | undefined
|
|
>();
|
|
const scopes = new WeakSet<NativeRunSpanScope>();
|
|
const openScopes = new Set<NativeRunSpanScopeState>();
|
|
const namedScopes = new Map<string, NativeRunSpanScopeState>();
|
|
let runtimeParentScope: NativeRunSpanScopeState | undefined;
|
|
let ended = false;
|
|
|
|
const scopeState = (scope: NativeRunSpanScope): NativeRunSpanScopeState => {
|
|
if (!scopes.has(scope)) throw new Error("native_run_trace_scope_invalid");
|
|
return scope as NativeRunSpanScopeState;
|
|
};
|
|
|
|
const currentParent = (): { name: string; context: unknown } => {
|
|
const active = scopeStorage.getStore();
|
|
return active && !active.ended
|
|
? { name: active.name, context: active.context }
|
|
: runtimeParentScope && !runtimeParentScope.ended
|
|
? { name: runtimeParentScope.name, context: runtimeParentScope.context }
|
|
: { name: "task.provider_session", context: rootContext };
|
|
};
|
|
|
|
const parentFor = (
|
|
parentName?: string,
|
|
): { name: string; context: unknown } => {
|
|
if (parentName && parentName !== "task.provider_session") {
|
|
const named = namedScopes.get(parentName);
|
|
if (named) return { name: named.name, context: named.context };
|
|
return currentParent();
|
|
}
|
|
if (parentName === "task.provider_session")
|
|
return { name: "task.provider_session", context: rootContext };
|
|
return currentParent();
|
|
};
|
|
|
|
const setOtelAttributes = (
|
|
span: SpanHandle,
|
|
attributes: Record<string, string | number | boolean>,
|
|
): void => {
|
|
for (const [key, value] of Object.entries(attributes)) {
|
|
try {
|
|
span.setAttribute(`paperclip.native.span.${key}`, value);
|
|
} catch {
|
|
// Tracing is diagnostic-only and must never change runner control flow.
|
|
}
|
|
}
|
|
};
|
|
|
|
const emitCompletedSpanEvent = async (
|
|
span: NativeRunHistoricalSpan,
|
|
): Promise<void> => {
|
|
const startedAtMs = finiteMs(span.startedAtMs);
|
|
const endedAtMs = Math.max(startedAtMs, finiteMs(span.endedAtMs));
|
|
const durationMs = endedAtMs - startedAtMs;
|
|
const outcome = span.outcome ?? "ok";
|
|
const attributes = safeAttributes(span.attributes);
|
|
try {
|
|
await input.onEvent?.({
|
|
eventType: NATIVE_RUN_SPAN_EVENT_TYPE,
|
|
stream: "system",
|
|
level: outcome === "failed" ? "warn" : "info",
|
|
message: `runner span: ${span.name} (${durationMs.toFixed(1)}ms)`,
|
|
payload: {
|
|
// Keep the persisted schema identifier stable; traceSchemaVersion
|
|
// describes the OTel hierarchy independently of the run-log shape.
|
|
schema: "paperclip.run-performance-span.v1",
|
|
traceSchemaVersion: NATIVE_RUN_TRACE_SCHEMA_VERSION,
|
|
span: span.name,
|
|
parentSpan: span.parentName ?? "task.provider_session",
|
|
startOffsetMs: startedAtMs - traceStartedAtMs,
|
|
durationMs,
|
|
outcome,
|
|
...attributes,
|
|
},
|
|
});
|
|
} catch {
|
|
// A diagnostic sink failure must not fail the task.
|
|
}
|
|
};
|
|
|
|
const start = (
|
|
name: string,
|
|
options: {
|
|
parentName?: string;
|
|
startedAtMs?: number;
|
|
attributes?: Record<string, string | number | boolean>;
|
|
} = {},
|
|
): NativeRunSpanScope => {
|
|
const startedAtMs = finiteMs(options.startedAtMs ?? Date.now());
|
|
const attributes = safeAttributes(options.attributes);
|
|
const parent = parentFor(options.parentName);
|
|
let span: SpanHandle;
|
|
try {
|
|
span = tracing.tracer.startSpan(
|
|
name,
|
|
{
|
|
startTime: startedAtMs,
|
|
attributes: {
|
|
"paperclip.native.span.trace_schema_version":
|
|
NATIVE_RUN_TRACE_SCHEMA_VERSION,
|
|
...Object.fromEntries(
|
|
Object.entries(attributes).map(([key, value]) => [
|
|
`paperclip.native.span.${key}`,
|
|
value,
|
|
]),
|
|
),
|
|
},
|
|
},
|
|
parent.context ?? ROOT_CONTEXT,
|
|
);
|
|
} catch {
|
|
span = NOOP_SPAN;
|
|
}
|
|
let context: unknown;
|
|
try {
|
|
context = tracing.contextWithSpan(span);
|
|
} catch {
|
|
context = undefined;
|
|
}
|
|
const state: NativeRunSpanScopeState = {
|
|
name,
|
|
parentName: parent.name,
|
|
startedAtMs,
|
|
span,
|
|
context,
|
|
attributes,
|
|
ended: false,
|
|
};
|
|
scopes.add(state);
|
|
openScopes.add(state);
|
|
namedScopes.set(name, state);
|
|
return state;
|
|
};
|
|
|
|
const annotate = (
|
|
scope: NativeRunSpanScope,
|
|
attributes: Record<string, string | number | boolean>,
|
|
): void => {
|
|
const state = scopeState(scope);
|
|
const incoming = safeAttributes(attributes);
|
|
const retained = Object.fromEntries(
|
|
Object.entries(state.attributes).slice(
|
|
0,
|
|
Math.max(0, 16 - Object.keys(incoming).length),
|
|
),
|
|
);
|
|
const safe = { ...retained, ...incoming };
|
|
state.attributes = safe;
|
|
setOtelAttributes(state.span, safe);
|
|
};
|
|
|
|
const end = async (
|
|
scope: NativeRunSpanScope,
|
|
options: {
|
|
endedAtMs?: number;
|
|
outcome?: NativeRunSpanOutcome;
|
|
attributes?: Record<string, string | number | boolean>;
|
|
} = {},
|
|
): Promise<void> => {
|
|
const state = scopeState(scope);
|
|
if (state.ended) return;
|
|
state.ended = true;
|
|
openScopes.delete(state);
|
|
const endedAtMs = Math.max(
|
|
state.startedAtMs,
|
|
finiteMs(options.endedAtMs ?? Date.now()),
|
|
);
|
|
const outcome = options.outcome ?? "ok";
|
|
const durationMs = endedAtMs - state.startedAtMs;
|
|
annotate(state, {
|
|
...options.attributes,
|
|
duration_ms: durationMs,
|
|
outcome,
|
|
});
|
|
if (outcome === "failed") { try { state.span.setStatus({ code: 2 }); } catch {} }
|
|
try { state.span.end(endedAtMs); } catch {}
|
|
await emitCompletedSpanEvent({
|
|
name: state.name,
|
|
parentName: state.parentName,
|
|
startedAtMs: state.startedAtMs,
|
|
endedAtMs,
|
|
outcome,
|
|
attributes: state.attributes,
|
|
});
|
|
};
|
|
|
|
const activate = (scope: NativeRunSpanScope): void => {
|
|
const state = scopeState(scope);
|
|
if (state.ended) return;
|
|
runtimeParentScope = state;
|
|
};
|
|
|
|
const run = <T>(scope: NativeRunSpanScope, fn: () => T): T => {
|
|
const state = scopeState(scope);
|
|
const dynamicContext = new Proxy<Record<PropertyKey, unknown>>(
|
|
{},
|
|
{
|
|
get(_target, property) {
|
|
const active = scopeStorage.getStore();
|
|
const target =
|
|
active && !active.ended
|
|
? active.context
|
|
: runtimeParentScope && !runtimeParentScope.ended
|
|
? runtimeParentScope.context
|
|
: state.context;
|
|
if (
|
|
(typeof target !== "object" || target === null) &&
|
|
typeof target !== "function"
|
|
) {
|
|
return undefined;
|
|
}
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === "function" ? value.bind(target) : value;
|
|
},
|
|
},
|
|
);
|
|
return scopeStorage.run(state, () =>
|
|
runWithRuntimeParent(dynamicContext, fn),
|
|
);
|
|
};
|
|
|
|
const record = async (span: NativeRunHistoricalSpan): Promise<void> => {
|
|
const scope = start(span.name, {
|
|
parentName: span.parentName,
|
|
startedAtMs: span.startedAtMs,
|
|
attributes: span.attributes,
|
|
});
|
|
await end(scope, {
|
|
endedAtMs: span.endedAtMs,
|
|
outcome: span.outcome,
|
|
});
|
|
};
|
|
|
|
const measure = async <T>(
|
|
name: string,
|
|
fn: () => Promise<T>,
|
|
options: {
|
|
parentName?: string;
|
|
attributes?: Record<string, string | number | boolean>;
|
|
} = {},
|
|
): Promise<T> => {
|
|
const scope = start(name, options);
|
|
let outcome: NativeRunSpanOutcome = "ok";
|
|
try {
|
|
return await run(scope, fn);
|
|
} catch (error) {
|
|
outcome = "failed";
|
|
throw error;
|
|
} finally {
|
|
await end(scope, { outcome });
|
|
}
|
|
};
|
|
|
|
const finish = async (outcome: NativeRunSpanOutcome): Promise<void> => {
|
|
if (ended) return;
|
|
ended = true;
|
|
const endedAtMs = Date.now();
|
|
for (const scope of [...openScopes].reverse()) {
|
|
await end(scope, { endedAtMs, outcome });
|
|
}
|
|
try { rootSpan.setAttribute("paperclip.task.run.wall_ms", endedAtMs - traceStartedAtMs); } catch {}
|
|
try { rootSpan.setAttribute("paperclip.task.run.outcome", outcome); } catch {}
|
|
if (outcome === "failed") { try { rootSpan.setStatus({ code: 2 }); } catch {} }
|
|
try { rootSpan.end(endedAtMs); } catch {}
|
|
// Preserve the historical run-log record without emitting a duplicate
|
|
// full-width child span into the OpenTelemetry waterfall.
|
|
await emitCompletedSpanEvent({
|
|
name: "task.provider_session.measured",
|
|
parentName: "task.provider_session",
|
|
startedAtMs: traceStartedAtMs,
|
|
endedAtMs,
|
|
outcome,
|
|
attributes: { runtime: "paperclip_runner" },
|
|
});
|
|
};
|
|
|
|
return {
|
|
traceStartedAtMs,
|
|
start,
|
|
annotate,
|
|
activate,
|
|
end,
|
|
run,
|
|
record,
|
|
measure,
|
|
finish,
|
|
};
|
|
}
|
|
|
|
export type NativeRunTrace = ReturnType<typeof createNativeRunTrace>;
|
|
|
|
/** Emit preparation failure even when execution aborts before a native session exists. */
|
|
export async function recordFailedSkillPreparation(input: {
|
|
runId: string;
|
|
startedAtMs: number;
|
|
onEvent?: NativeRunTraceSink;
|
|
traceContext?: StartupTraceContextHandle;
|
|
}): Promise<void> {
|
|
try {
|
|
const trace = createNativeRunTrace(input);
|
|
const preparation = trace.start("task.prepare", { parentName: "task.provider_session", startedAtMs: input.startedAtMs });
|
|
const endedAtMs = Date.now();
|
|
await trace.record({ name: "skills.prepare", parentName: "task.prepare", startedAtMs: input.startedAtMs, endedAtMs, outcome: "failed" });
|
|
await trace.end(preparation, { endedAtMs, outcome: "failed" });
|
|
await trace.finish("failed");
|
|
} catch {
|
|
// Diagnostics must not replace the original preparation error.
|
|
}
|
|
}
|