mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source control plane for companies of AI agents. > - Native runner agents need governed tools, durable runtime state, and useful execution evidence. > - A first activity trace waited 53.467 seconds even though tool activity took 6.274 seconds; provider input arrived before the server API call executed. > - Native agents also need a safe way to hire teammates without asking the model to rebuild runtime configuration. > - This pull request separates the observed ACP input-stream window from the actual server `tool.execute` span and adds a server-owned native hire contract. > - The benefit is clearer latency evidence and safer native teammates with existing approval, auth, and company boundaries preserved. ## Linked Issues or Issue Description Related Daytona provenance work is in [#13814](https://github.com/paperclipai/paperclip/pull/13814). No duplicate public PR was found for this combined timing and native-hire change. **What existing behavior does this improve?** Native runner agents can use governed tools and request hires. The server did not expose a safe native hire operation that reused the caller's validated runtime settings. First-activity traces also mixed provider input timing with server tool execution timing. **Current behavior** A native hire must construct a separate runner configuration. Full configuration copying could expose paths, instructions, secrets, or sessions. Timing evidence could make a provider or MCP identity join appear proven when the trace did not contain that join. **Proposed behavior** The native `hire_agent` operation accepts identity and persona inputs. The server sends `adapterType: "paperclip_runner"` with `inheritRuntimeFrom: "caller"`, then copies only validated provider, model, permission, lifecycle, and bounded execution settings. It inherits and validates the default environment, derives the managed AI binding through existing normalization, preserves approval and permissions, and creates fresh child instructions. Caller secrets, paths, prompts, and sessions are excluded. Provider events now include the optional boolean `inputUpdated`, with Rust forwarding support. Timing evidence separately records the ACP input-stream window and the actual server `tool.execute` activity. It does not claim a provider or MCP join without matching evidence. **Reason and benefit** Native agents can hire teammates that start with the caller's approved execution policy. Operators retain company boundaries, auth rules, approval gates, and requalification. Reviewers can distinguish provider streaming time from server API execution time when diagnosing first-activity delays. **Breaking changes** None for existing hires or tool calls. `inheritRuntimeFrom` is optional and only applies to same-company native agent callers. Conflicting explicit runtime settings are rejected. The provider event field is optional for existing producers. ## What Changed - Added the native `hire_agent` protocol action, catalog entry, API contract, and runner authority checks. - Added `inheritRuntimeFrom: "caller"` validation and a closed native runtime inheritance allowlist. - Preserved managed AI binding normalization, default-environment validation, approval snapshots, permissions, requalification, and fresh child instructions. - Added provider `inputUpdated` schema support and Rust forwarding. - Added first-activity and server tool timing evidence with conservative identity-join handling. - Added route, authority, provider-event, sidecar, API, catalog, and Rust-focused tests. - Kept private Honeycomb links, raw traces, and local result paths out of this description. ## Verification Focused checks passed: - 458 timing/session checks. - 61 native hire inheritance checks. - 20 hire authority checks. - 1,741 API checks. - 106 catalog checks. - 54 provider sidecar checks. - 12 Rust provider checks. Live R2 and R3 each passed 45 checks across 6 runs (361,135 ms for R2). R1 stopped at missing Docker image setup. The final trace is available at https://ui.honeycomb.io/paperclip/environments/test/datasets/paperclip/result/BiMypLNvmiB?tab=traces. Latest-head CI passed all required build, typecheck, Rust, static, Vitest, serialized-server, workspace, chat, and E2E jobs. The focused local checks listed above passed; the broad local suite was not run before the live evaluation, while CI provides the full repository verification. ## Risks - Timing fields describe separate observed windows. They do not prove a provider or MCP owner without a valid trace join. - The inheritance allowlist must stay synchronized with native runner configuration fields. - Approval snapshots include resolved safe inherited settings and should be reviewed when native configuration fields change. - The focused local suite is narrower than the full repository suite; latest-head CI covers the broader repository checks. > Roadmap review: `ROADMAP.md` places this work within Paperclip's bring-your-own-agent direction. It extends existing native runner hiring and observability behavior. ## Model Used OpenAI GPT-6 (exact serving model ID is not exposed), with extended reasoning and repository tool use; GPT-5.6 Luna assisted with focused implementation and verification work. ## 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 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>
553 lines
17 KiB
TypeScript
553 lines
17 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import { createHash } from "node:crypto";
|
|
|
|
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 = 2;
|
|
|
|
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.run",
|
|
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.run",
|
|
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.run",
|
|
startedAtMs: input.runCreatedAtMs,
|
|
endedAtMs: Math.max(input.runCreatedAtMs, input.runStartedAtMs),
|
|
},
|
|
{
|
|
name: "heartbeat.prepare_before_environment",
|
|
parentName: "task.run",
|
|
// 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.run",
|
|
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 {
|
|
rootSpan = tracing.tracer.startSpan("task.run", {
|
|
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,
|
|
},
|
|
});
|
|
} 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.run", context: rootContext };
|
|
};
|
|
|
|
const parentFor = (
|
|
parentName?: string,
|
|
): { name: string; context: unknown } => {
|
|
if (parentName && parentName !== "task.run") {
|
|
const named = namedScopes.get(parentName);
|
|
if (named) return { name: named.name, context: named.context };
|
|
return currentParent();
|
|
}
|
|
if (parentName === "task.run")
|
|
return { name: "task.run", 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.run",
|
|
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,
|
|
);
|
|
} 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,
|
|
});
|
|
try {
|
|
if (outcome === "failed") state.span.setStatus({ code: 2 });
|
|
state.span.end(endedAtMs);
|
|
} catch {
|
|
// Tracing is diagnostic-only and must never change runner control flow.
|
|
}
|
|
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,
|
|
);
|
|
rootSpan.setAttribute("paperclip.task.run.outcome", outcome);
|
|
if (outcome === "failed") rootSpan.setStatus({ code: 2 });
|
|
rootSpan.end(endedAtMs);
|
|
} catch {
|
|
// Tracing is diagnostic-only and must never change runner control flow.
|
|
}
|
|
// Preserve the historical run-log record without emitting a duplicate
|
|
// full-width child span into the OpenTelemetry waterfall.
|
|
await emitCompletedSpanEvent({
|
|
name: "task.run.measured",
|
|
parentName: "task.run",
|
|
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.run", 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.
|
|
}
|
|
}
|