Files
PaperClipAI/server/src/services/duplex-observability-recorder.ts
T
DottaandDev Agent 25cf079ec5 feat(runner): add Codex-native application integration (#12591)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package is useful only when the application can start,
observe, and recover a native Codex run safely.
> - Existing direct adapters must keep their current execution and
finalization paths.
> - The application boundary therefore needs additive persistence,
authorization, coordination, and recovery behind an explicit
experimental adapter.
> - This pull request adds that Codex-only boundary without activating
generalized providers, remote environments, or the later task/SDK
surfaces.

## Linked Issues or Issue Description

**Subsystem affected**

Shared contracts, database persistence, adapter utilities, server
native-runtime services, and the experimental Paperclip Runner adapter.

**Problem or motivation**

The already-landed runner package has a qualified Codex path, but the
application needs durable native-run state, guarded runtime selection,
authenticated coordination, tool security, finalization, and recovery
before the experimental adapter can be exercised safely.

**Proposed solution**

Add a Codex-only `paperclip_runner` application path behind the existing
default-off native-runner setting. Bind native state and coordination to
company/run identity, preserve persisted-run recovery, and leave every
direct adapter on its existing legacy execution path.

**Alternatives considered**

The earlier stack boundary introduced a generalized executor and
remote-environment lifecycle here. That made this PR depend on
implementations in higher PRs and changed reusable sandbox behavior
globally. Those pieces are now deferred together to #12592.

**Roadmap alignment**

ROADMAP.md does not list a conflicting native-runner integration
project. This change adds the application boundary for the existing
Runner architecture.

## What Changed

- Added native run/result/finalization/provider-trace persistence,
shared validators, and idempotent migration/replay coverage.
- Added guarded Codex-only runtime selection, authenticated PRP
coordination, recovery, finalization, and interaction services.
- Added run/company-bound tool-gateway authorization, credential
redaction, SSRF protections, and replay-safe behavior.
- Added the explicit `paperclip_runner` adapter behind the default-off
rollout setting.
- Preserved legacy answered-question wake projection and direct-adapter
execution/finalization paths.
- Hardened cancellation so only owned in-memory child processes are
signaled; persisted recycled PIDs/process groups are never trusted.
- Retained the narrow Claude ACPX isolated-context security follow-up
discovered after #12590.
- Deferred the generalized executor, provider ingress, remote lifecycle,
SDK/lab/eval work, release-process changes, and lockfile.

## Verification

- Changed-file delta against `master`: 133 files.
- GitHub Actions is the authoritative verification environment for this
PR.
- Full CI, security, and Greptile review will run on this lowest
unmerged stack PR.
- Local tests/build/typecheck were not run because this checkout is
resource constrained.
- Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged.

## Risks

- This touches central heartbeat and agent-route code, so legacy
compatibility is the primary risk.
- Runtime selection remains Codex-only and explicit; direct Codex,
Claude, OpenCode, process, HTTP, and plugin adapters remain on their
existing paths.
- Fresh native starts fail closed while the rollout flag is off;
persisted native records remain readable and recoverable.
- Cancellation, company/run binding, tool calls, status decisions, and
completion writes are guarded or replay-safe.

> For core feature work, check [ROADMAP.md](ROADMAP.md) first and
discuss it in #dev before opening the PR. Feature PRs that overlap with
planned core work may need to be redirected.

## Model Used

OpenAI Codex, GPT-5.6, with repository tools, code execution, and
parallel agent review.

## 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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass — GitHub Actions is
authoritative for this resource-constrained checkout
- [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 risks above
- [ ] All Paperclip CI and security gates are green
- [ ] Greptile is 5/5 with no open actionable findings
- [x] I will address all Greptile and reviewer comments before merge

## Stack

- Position: 3 of 5 overall; lowest of 3 currently unmerged
- Base: `master`
- Previous:
[#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified
Claude ACPX runtime — merged
- Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592),
generalized Codex executor, task experience, and developer SDKs

---------

Co-authored-by: Dev Agent <dev@paperclip.ing>
2026-08-31 14:38:38 -05:00

138 lines
5.1 KiB
TypeScript

import {
DUPLEX_DIMENSION_KEYS,
DUPLEX_SPAN_REQUEST,
type DuplexObservabilityCounterRecord,
type DuplexObservabilityDimensions,
type DuplexObservabilityEventRecord,
type DuplexObservabilityRecorder,
type DuplexObservabilitySpanRecord,
} from "@paperclipai/adapter-utils/duplex-observability";
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
/**
* The host binding for the fixed duplex telemetry surface. This module maps each
* recorder call to a real host sink: the span to the OTel tracer, the counter to
* the guarded counter store in `tool-runtime-metrics.ts`, and the transport event
* to the run-event path. The mapping is the single boundary, so the closed
* dimension keys and the counter folding stay in one place.
*
* The recorder never throws. The facade in `duplex-observability.ts` wraps each
* synchronous call in a swallow, but an async sink can still reject after the
* call returns. Each async sink here runs fire-and-forget with its own catch, so
* a sink failure never breaks the request path.
*/
/** The minimal span the recorder opens. A real OTel span satisfies it; the
* no-op tracer's span satisfies it too. */
export interface DuplexObservabilitySpan {
setAttribute(key: string, value: string | number | boolean): void;
end(endTime?: number): void;
}
/** The minimal tracer surface the recorder calls. `getStartupTracer` returns a
* real or a no-op implementation that satisfies it. The optional second argument
* carries an explicit start time, so the request span duration equals the
* measured latency. */
export interface DuplexObservabilityTracer {
startSpan(
name: string,
options?: { startTime?: number },
context?: unknown,
): DuplexObservabilitySpan;
}
export interface HostDuplexObservabilityRecorderInput {
/** The OTel tracer for the two duplex spans. */
tracer: DuplexObservabilityTracer;
/**
* Increment one guarded host counter by its metric name. The caller binds it
* to `incrementToolRuntimeMetricCounter` with the company id, inside a swallow.
*/
incrementCounter(metric: string): void;
/**
* Emit one transport event to the run-event path. The caller binds it to the
* run-events bridge, inside a swallow.
*/
emitTransportEvent(event: {
name: string;
dimensions: DuplexObservabilityDimensions;
}): void;
/** Resolve the currently active native/sandbox step when a span is emitted. */
parentContext?: () => unknown;
/** The wall clock. The default is `Date.now`. Tests inject a fixed clock. */
now?: () => number;
}
/**
* Fold the discriminating dimension into the counter metric name. The guarded
* counter store keys on `(companyId, metric)` with no label column, so the
* fallback reason or the loss class rides the metric name to keep the analytic
* breakdown. Both dimension sets are closed and low-cardinality, so the metric
* cardinality stays bounded. A record with neither dimension uses the base name.
*/
export function foldDuplexCounterMetric(
record: DuplexObservabilityCounterRecord,
): string {
if (record.dimensions.fallback_reason) {
return `${record.metric}.${record.dimensions.fallback_reason}`;
}
if (record.dimensions.loss_class) {
return `${record.metric}.${record.dimensions.loss_class}`;
}
return record.metric;
}
/**
* Build the host duplex telemetry recorder. The recorder receives only
* already-mapped dimensions, so a raw provider key never reaches a sink. Every
* span attribute uses only the closed dimension keys.
*/
export function createHostDuplexObservabilityRecorder(
input: HostDuplexObservabilityRecorderInput,
): DuplexObservabilityRecorder {
const now = input.now ?? Date.now;
const setDimensionAttributes = (
span: DuplexObservabilitySpan,
dimensions: DuplexObservabilityDimensions,
): void => {
for (const key of DUPLEX_DIMENSION_KEYS) {
const value = dimensions[key];
if (typeof value === "string") {
span.setAttribute(key, value);
}
}
};
return {
recordSpan(record: DuplexObservabilitySpanRecord): void {
// The request span carries a latency, so start it in the past and end it
// now, so the span duration equals the measured latency. The channel-open
// span carries no latency, so it opens and ends at the same instant.
const end = now();
const latencyMs =
record.name === DUPLEX_SPAN_REQUEST &&
typeof record.latencyMs === "number" &&
Number.isFinite(record.latencyMs)
? Math.max(0, record.latencyMs)
: 0;
const span = input.tracer.startSpan(
record.name,
{ startTime: end - latencyMs },
input.parentContext?.() ?? getActiveStepContext()?.parentContext,
);
setDimensionAttributes(span, record.dimensions);
span.end(end);
},
incrementCounter(record: DuplexObservabilityCounterRecord): void {
input.incrementCounter(foldDuplexCounterMetric(record));
},
emitEvent(record: DuplexObservabilityEventRecord): void {
input.emitTransportEvent({
name: record.name,
dimensions: record.dimensions,
});
},
};
}