mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
138 lines
5.1 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|
|
}
|