mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +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>
204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL,
|
|
DUPLEX_COUNTER_FALLBACK_TOTAL,
|
|
DUPLEX_COUNTER_LOSS_TOTAL,
|
|
DUPLEX_SPAN_CHANNEL_OPEN,
|
|
DUPLEX_SPAN_REQUEST,
|
|
DUPLEX_TRANSPORT_EVENT,
|
|
} from "@paperclipai/adapter-utils/duplex-observability";
|
|
import { runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
|
import {
|
|
createHostDuplexObservabilityRecorder,
|
|
foldDuplexCounterMetric,
|
|
type DuplexObservabilitySpan,
|
|
type DuplexObservabilityTracer,
|
|
} from "../services/duplex-observability-recorder.js";
|
|
|
|
// A recording tracer that captures each span's name, attributes, and end time.
|
|
function createRecordingTracer(): {
|
|
tracer: DuplexObservabilityTracer;
|
|
spans: Array<{
|
|
name: string;
|
|
startTime?: number;
|
|
parentContext?: unknown;
|
|
attributes: Record<string, string | number | boolean>;
|
|
endTime?: number;
|
|
}>;
|
|
} {
|
|
const spans: Array<{
|
|
name: string;
|
|
startTime?: number;
|
|
parentContext?: unknown;
|
|
attributes: Record<string, string | number | boolean>;
|
|
endTime?: number;
|
|
}> = [];
|
|
const tracer: DuplexObservabilityTracer = {
|
|
startSpan(name, options, parentContext) {
|
|
const record = {
|
|
name,
|
|
startTime: options?.startTime,
|
|
parentContext,
|
|
attributes: {} as Record<string, string | number | boolean>,
|
|
endTime: undefined as number | undefined,
|
|
};
|
|
spans.push(record);
|
|
const span: DuplexObservabilitySpan = {
|
|
setAttribute(key, value) {
|
|
record.attributes[key] = value;
|
|
},
|
|
end(endTime) {
|
|
record.endTime = endTime;
|
|
},
|
|
};
|
|
return span;
|
|
},
|
|
};
|
|
return { tracer, spans };
|
|
}
|
|
|
|
describe("createHostDuplexObservabilityRecorder", () => {
|
|
it("records the channel-open span with only the closed dimension keys", () => {
|
|
const { tracer, spans } = createRecordingTracer();
|
|
const recorder = createHostDuplexObservabilityRecorder({
|
|
tracer,
|
|
incrementCounter: () => {},
|
|
emitTransportEvent: () => {},
|
|
now: () => 1_000,
|
|
});
|
|
|
|
recorder.recordSpan({
|
|
name: DUPLEX_SPAN_CHANNEL_OPEN,
|
|
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
|
|
});
|
|
|
|
expect(spans).toHaveLength(1);
|
|
expect(spans[0].name).toBe(DUPLEX_SPAN_CHANNEL_OPEN);
|
|
expect(spans[0].attributes).toEqual({
|
|
provider: "daytona",
|
|
transport: "duplex",
|
|
outcome: "ok",
|
|
});
|
|
// The channel-open span carries no latency, so it opens and ends at the same instant.
|
|
expect(spans[0].startTime).toBe(1_000);
|
|
expect(spans[0].endTime).toBe(1_000);
|
|
});
|
|
|
|
it("makes the request span duration equal the measured latency", () => {
|
|
const { tracer, spans } = createRecordingTracer();
|
|
const recorder = createHostDuplexObservabilityRecorder({
|
|
tracer,
|
|
incrementCounter: () => {},
|
|
emitTransportEvent: () => {},
|
|
now: () => 5_000,
|
|
});
|
|
|
|
recorder.recordSpan({
|
|
name: DUPLEX_SPAN_REQUEST,
|
|
dimensions: { provider: "other", transport: "duplex", outcome: "error" },
|
|
latencyMs: 250,
|
|
});
|
|
|
|
expect(spans[0].startTime).toBe(4_750);
|
|
expect(spans[0].endTime).toBe(5_000);
|
|
});
|
|
|
|
it("parents asynchronous duplex spans to the active native step", () => {
|
|
const { tracer, spans } = createRecordingTracer();
|
|
const activeParent = { traceId: "native-run" };
|
|
const recorder = createHostDuplexObservabilityRecorder({
|
|
tracer,
|
|
incrementCounter: () => {},
|
|
emitTransportEvent: () => {},
|
|
});
|
|
|
|
runWithRuntimeParent(activeParent, () => {
|
|
recorder.recordSpan({
|
|
name: DUPLEX_SPAN_CHANNEL_OPEN,
|
|
dimensions: {
|
|
provider: "daytona",
|
|
transport: "duplex",
|
|
outcome: "ok",
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(spans[0].parentContext).toBe(activeParent);
|
|
});
|
|
|
|
it("folds the discriminating dimension into the counter metric name", () => {
|
|
expect(
|
|
foldDuplexCounterMetric({
|
|
metric: DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL,
|
|
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
|
|
}),
|
|
).toBe(DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL);
|
|
expect(
|
|
foldDuplexCounterMetric({
|
|
metric: DUPLEX_COUNTER_FALLBACK_TOTAL,
|
|
dimensions: {
|
|
provider: "daytona",
|
|
transport: "file",
|
|
outcome: "error",
|
|
fallback_reason: "gate_off",
|
|
},
|
|
}),
|
|
).toBe(`${DUPLEX_COUNTER_FALLBACK_TOTAL}.gate_off`);
|
|
expect(
|
|
foldDuplexCounterMetric({
|
|
metric: DUPLEX_COUNTER_LOSS_TOTAL,
|
|
dimensions: {
|
|
provider: "daytona",
|
|
transport: "duplex",
|
|
outcome: "error",
|
|
loss_class: "post_dispatch",
|
|
},
|
|
}),
|
|
).toBe(`${DUPLEX_COUNTER_LOSS_TOTAL}.post_dispatch`);
|
|
});
|
|
|
|
it("forwards the counter through the guarded sink with the folded metric", () => {
|
|
const metrics: string[] = [];
|
|
const recorder = createHostDuplexObservabilityRecorder({
|
|
tracer: createRecordingTracer().tracer,
|
|
incrementCounter: (metric) => metrics.push(metric),
|
|
emitTransportEvent: () => {},
|
|
});
|
|
|
|
recorder.incrementCounter({
|
|
metric: DUPLEX_COUNTER_FALLBACK_TOTAL,
|
|
dimensions: {
|
|
provider: "daytona",
|
|
transport: "file",
|
|
outcome: "error",
|
|
fallback_reason: "ready_timeout",
|
|
},
|
|
});
|
|
|
|
expect(metrics).toEqual([`${DUPLEX_COUNTER_FALLBACK_TOTAL}.ready_timeout`]);
|
|
});
|
|
|
|
it("forwards the transport event with its name and dimensions", () => {
|
|
const events: Array<{ name: string; dimensions: Record<string, unknown> }> =
|
|
[];
|
|
const recorder = createHostDuplexObservabilityRecorder({
|
|
tracer: createRecordingTracer().tracer,
|
|
incrementCounter: () => {},
|
|
emitTransportEvent: (event) => events.push(event),
|
|
});
|
|
|
|
recorder.emitEvent({
|
|
name: DUPLEX_TRANSPORT_EVENT,
|
|
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
|
|
});
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
name: DUPLEX_TRANSPORT_EVENT,
|
|
dimensions: { provider: "daytona", transport: "duplex", outcome: "ok" },
|
|
},
|
|
]);
|
|
});
|
|
});
|