mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
feat(runner): add feature-flagged Paperclip adapter tracer
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3a38c8f931
commit
37c219b297
72 files changed
+44123
-240
No files matched your search
@@ -43,6 +43,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"acpx": "0.12.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import type { AdapterExecutionTarget } from "./execution-target.js";
|
||||
import type { RuntimeStatusSink } from "./runtime-progress.js";
|
||||
import type { NativeFinalizationResult } from "@paperclipai/shared";
|
||||
|
||||
export interface AdapterAgent {
|
||||
id: string;
|
||||
@@ -127,6 +128,8 @@ export interface AdapterExecutionResult {
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
/** Present only for a persisted native-mode run; legacy adapters omit it. */
|
||||
nativeFinalization?: NativeFinalizationResult;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
CREATE TABLE "completion_contracts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"revision" integer NOT NULL,
|
||||
"schema_version" text NOT NULL,
|
||||
"policy_version" text NOT NULL,
|
||||
"risk" text NOT NULL,
|
||||
"completion_authority" text NOT NULL,
|
||||
"incomplete_criteria_policy" text NOT NULL,
|
||||
"contract_json" jsonb NOT NULL,
|
||||
"canonical_sha256" text NOT NULL,
|
||||
"created_by_actor_type" text NOT NULL,
|
||||
"created_by_actor_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"supersedes_contract_id" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "native_run_finalizations" (
|
||||
"run_id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"phase" text NOT NULL,
|
||||
"attempt" integer DEFAULT 0 NOT NULL,
|
||||
"lease_owner" text,
|
||||
"lease_expires_at" timestamp with time zone,
|
||||
"result_id" uuid,
|
||||
"assessment_id" uuid,
|
||||
"decision_id" uuid,
|
||||
"failure_code" text,
|
||||
"failure_detail" text,
|
||||
"next_attempt_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "native_run_results" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"turn_id" text,
|
||||
"completion_contract_id" uuid NOT NULL,
|
||||
"caller_result_id" text,
|
||||
"caller_dedupe_key" text,
|
||||
"server_fingerprint" text NOT NULL,
|
||||
"schema_status" text NOT NULL,
|
||||
"rejection_code" text,
|
||||
"result_json" jsonb NOT NULL,
|
||||
"canonical_sha256" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "status_decision_effects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"decision_id" uuid NOT NULL,
|
||||
"ordinal" integer NOT NULL,
|
||||
"effect_kind" text NOT NULL,
|
||||
"target_type" text NOT NULL,
|
||||
"target_id" text,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"delivery_state" text DEFAULT 'pending' NOT NULL,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"next_attempt_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"delivered_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "status_decisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"assessment_id" uuid NOT NULL,
|
||||
"decision_version" integer NOT NULL,
|
||||
"policy_version" text NOT NULL,
|
||||
"from_status" text NOT NULL,
|
||||
"to_status" text NOT NULL,
|
||||
"reason_code" text NOT NULL,
|
||||
"decision_json" jsonb NOT NULL,
|
||||
"decision_digest" text NOT NULL,
|
||||
"application_state" text DEFAULT 'pending' NOT NULL,
|
||||
"supersedes_decision_id" uuid,
|
||||
"applied_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "work_assessments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"turn_id" text,
|
||||
"contract_id" uuid NOT NULL,
|
||||
"result_id" uuid NOT NULL,
|
||||
"trigger_kind" text NOT NULL,
|
||||
"trigger_ref" text,
|
||||
"trigger_capability" text,
|
||||
"trigger_actor_company_id" uuid NOT NULL,
|
||||
"prior_issue_status" text NOT NULL,
|
||||
"prior_status_version" bigint NOT NULL,
|
||||
"prior_decision_id" uuid,
|
||||
"policy_version" text NOT NULL,
|
||||
"assessment_json" jsonb NOT NULL,
|
||||
"input_digest" text NOT NULL,
|
||||
"supersedes_assessment_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ALTER COLUMN "seq" SET DATA TYPE bigint;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN "source_instance_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN "source_event_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN "source_seq" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN "source_payload_sha256" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_run_events" ADD COLUMN "protocol_schema_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "runtime_mode" text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "runtime_mode_resolver_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "runtime_mode_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "runtime_mode_resolved_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "runner_profile_json" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "runner_instance_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "native_session_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "driver_kind" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "driver_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "completion_contract_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "completion_contract_sha256" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "next_event_seq" bigint DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "native_phase" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "native_phase_updated_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "status_version" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "last_status_decision_id" uuid;--> statement-breakpoint
|
||||
-- Historical event writers computed max(seq) outside their insert transaction. Repair any
|
||||
-- collisions deterministically before enforcing the native runner's single-writer invariant.
|
||||
WITH resequenced AS (
|
||||
SELECT "id", row_number() OVER (PARTITION BY "run_id" ORDER BY "created_at", "id") AS "new_seq"
|
||||
FROM "heartbeat_run_events"
|
||||
)
|
||||
UPDATE "heartbeat_run_events" AS event
|
||||
SET "seq" = resequenced."new_seq"
|
||||
FROM resequenced
|
||||
WHERE event."id" = resequenced."id";--> statement-breakpoint
|
||||
UPDATE "heartbeat_runs" AS run
|
||||
SET "next_event_seq" = COALESCE((
|
||||
SELECT max(event."seq") + 1
|
||||
FROM "heartbeat_run_events" AS event
|
||||
WHERE event."run_id" = run."id"
|
||||
), 1);--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION paperclip_bump_issue_status_version()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."status" IS DISTINCT FROM OLD."status" THEN
|
||||
NEW."status_version" := OLD."status_version" + 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;--> statement-breakpoint
|
||||
CREATE TRIGGER paperclip_issue_status_version_trigger
|
||||
BEFORE UPDATE OF "status" ON "issues"
|
||||
FOR EACH ROW EXECUTE FUNCTION paperclip_bump_issue_status_version();--> statement-breakpoint
|
||||
ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_result_id_native_run_results_id_fk" FOREIGN KEY ("result_id") REFERENCES "public"."native_run_results"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_completion_contract_id_completion_contracts_id_fk" FOREIGN KEY ("completion_contract_id") REFERENCES "public"."completion_contracts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_decision_id_status_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."status_decisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_assessment_id_work_assessments_id_fk" FOREIGN KEY ("assessment_id") REFERENCES "public"."work_assessments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_contract_id_completion_contracts_id_fk" FOREIGN KEY ("contract_id") REFERENCES "public"."completion_contracts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_result_id_native_run_results_id_fk" FOREIGN KEY ("result_id") REFERENCES "public"."native_run_results"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_trigger_actor_company_id_companies_id_fk" FOREIGN KEY ("trigger_actor_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "completion_contracts_issue_revision_uq" ON "completion_contracts" USING btree ("issue_id","revision");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "completion_contracts_issue_hash_uq" ON "completion_contracts" USING btree ("issue_id","canonical_sha256");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "native_run_results_run_fingerprint_uq" ON "native_run_results" USING btree ("run_id","server_fingerprint");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "native_run_results_run_caller_result_uq" ON "native_run_results" USING btree ("run_id","caller_result_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "native_run_results_run_caller_dedupe_uq" ON "native_run_results" USING btree ("run_id","caller_dedupe_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "status_decision_effects_decision_ordinal_uq" ON "status_decision_effects" USING btree ("decision_id","ordinal");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "status_decision_effects_idempotency_uq" ON "status_decision_effects" USING btree ("idempotency_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "status_decisions_assessment_version_uq" ON "status_decisions" USING btree ("assessment_id","decision_version");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "status_decisions_issue_digest_uq" ON "status_decisions" USING btree ("issue_id","decision_digest");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "work_assessments_result_input_uq" ON "work_assessments" USING btree ("result_id","input_digest");--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: duplicate rows are deterministically resequenced above before this invariant is enabled in the same transaction.
|
||||
CREATE UNIQUE INDEX "heartbeat_run_events_run_seq_uq" ON "heartbeat_run_events" USING btree ("run_id","seq");--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: nullable native source IDs mean historical rows need no backfill and the invariant must commit atomically with the new columns.
|
||||
CREATE UNIQUE INDEX "heartbeat_run_events_run_source_event_uq" ON "heartbeat_run_events" USING btree ("run_id","source_event_id") WHERE "heartbeat_run_events"."source_event_id" is not null;--> statement-breakpoint
|
||||
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: nullable native source sequence fields mean historical rows need no backfill and the invariant must commit atomically with the new columns.
|
||||
CREATE UNIQUE INDEX "heartbeat_run_events_run_source_seq_uq" ON "heartbeat_run_events" USING btree ("run_id","source_instance_id","source_seq") WHERE "heartbeat_run_events"."source_instance_id" is not null and "heartbeat_run_events"."source_seq" is not null;--> statement-breakpoint
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1464,6 +1464,13 @@
|
||||
"when": 1786032087897,
|
||||
"tag": "0210_heartbeat_context_taskkey_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 211,
|
||||
"version": "7",
|
||||
"when": 1786243779777,
|
||||
"tag": "0211_famous_guardsmen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const completionContracts = pgTable("completion_contracts", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||
revision: integer("revision").notNull(),
|
||||
schemaVersion: text("schema_version").notNull(),
|
||||
policyVersion: text("policy_version").notNull(),
|
||||
risk: text("risk").notNull(),
|
||||
completionAuthority: text("completion_authority").notNull(),
|
||||
incompleteCriteriaPolicy: text("incomplete_criteria_policy").notNull(),
|
||||
contractJson: jsonb("contract_json").$type<Record<string, unknown>>().notNull(),
|
||||
canonicalSha256: text("canonical_sha256").notNull(),
|
||||
createdByActorType: text("created_by_actor_type").notNull(),
|
||||
createdByActorId: text("created_by_actor_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
supersedesContractId: uuid("supersedes_contract_id"),
|
||||
}, (table) => ({
|
||||
issueRevisionUq: uniqueIndex("completion_contracts_issue_revision_uq").on(table.issueId, table.revision),
|
||||
issueHashUq: uniqueIndex("completion_contracts_issue_hash_uq").on(table.issueId, table.canonicalSha256),
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, index, bigserial } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, index, bigserial, bigint, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
@@ -10,19 +11,30 @@ export const heartbeatRunEvents = pgTable(
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id),
|
||||
seq: integer("seq").notNull(),
|
||||
seq: bigint("seq", { mode: "number" }).notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
stream: text("stream"),
|
||||
level: text("level"),
|
||||
color: text("color"),
|
||||
message: text("message"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
sourceInstanceId: text("source_instance_id"),
|
||||
sourceEventId: text("source_event_id"),
|
||||
sourceSeq: bigint("source_seq", { mode: "number" }),
|
||||
sourcePayloadSha256: text("source_payload_sha256"),
|
||||
protocolSchemaVersion: integer("protocol_schema_version"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
runSeqIdx: index("heartbeat_run_events_run_seq_idx").on(table.runId, table.seq),
|
||||
runSeqUq: uniqueIndex("heartbeat_run_events_run_seq_uq").on(table.runId, table.seq),
|
||||
runSourceEventUq: uniqueIndex("heartbeat_run_events_run_source_event_uq")
|
||||
.on(table.runId, table.sourceEventId)
|
||||
.where(sql`${table.sourceEventId} is not null`),
|
||||
runSourceSeqUq: uniqueIndex("heartbeat_run_events_run_source_seq_uq")
|
||||
.on(table.runId, table.sourceInstanceId, table.sourceSeq)
|
||||
.where(sql`${table.sourceInstanceId} is not null and ${table.sourceSeq} is not null`),
|
||||
companyRunIdx: index("heartbeat_run_events_company_run_idx").on(table.companyId, table.runId),
|
||||
companyCreatedIdx: index("heartbeat_run_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,20 @@ export const heartbeatRuns = pgTable(
|
||||
signal: text("signal"),
|
||||
usageJson: jsonb("usage_json").$type<Record<string, unknown>>(),
|
||||
resultJson: jsonb("result_json").$type<Record<string, unknown>>(),
|
||||
runtimeMode: text("runtime_mode").notNull().default("legacy"),
|
||||
runtimeModeResolverVersion: text("runtime_mode_resolver_version"),
|
||||
runtimeModeReason: text("runtime_mode_reason"),
|
||||
runtimeModeResolvedAt: timestamp("runtime_mode_resolved_at", { withTimezone: true }),
|
||||
runnerProfileJson: jsonb("runner_profile_json").$type<Record<string, unknown>>(),
|
||||
runnerInstanceId: uuid("runner_instance_id"),
|
||||
nativeSessionId: uuid("native_session_id"),
|
||||
driverKind: text("driver_kind"),
|
||||
driverVersion: text("driver_version"),
|
||||
completionContractId: uuid("completion_contract_id"),
|
||||
completionContractSha256: text("completion_contract_sha256"),
|
||||
nextEventSeq: bigint("next_event_seq", { mode: "number" }).notNull().default(1),
|
||||
nativePhase: text("native_phase"),
|
||||
nativePhaseUpdatedAt: timestamp("native_phase_updated_at", { withTimezone: true }),
|
||||
sessionIdBefore: text("session_id_before"),
|
||||
sessionIdAfter: text("session_id_after"),
|
||||
logStore: text("log_store"),
|
||||
|
||||
@@ -107,6 +107,12 @@ export { documentAnnotationComments } from "./document_annotation_comments.js";
|
||||
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { completionContracts } from "./completion_contracts.js";
|
||||
export { nativeRunResults } from "./native_run_results.js";
|
||||
export { nativeRunFinalizations } from "./native_run_finalizations.js";
|
||||
export { workAssessments } from "./work_assessments.js";
|
||||
export { statusDecisions } from "./status_decisions.js";
|
||||
export { statusDecisionEffects } from "./status_decision_effects.js";
|
||||
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
|
||||
export { smokeRuns, smokeRunSteps } from "./smoke_lab.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
bigint,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { projects } from "./projects.js";
|
||||
@@ -31,6 +32,8 @@ export const issues = pgTable(
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").notNull().default("backlog"),
|
||||
statusVersion: bigint("status_version", { mode: "number" }).notNull().default(0),
|
||||
lastStatusDecisionId: uuid("last_status_decision_id"),
|
||||
workMode: text("work_mode").notNull().default("standard"),
|
||||
harnessKind: text("harness_kind"),
|
||||
priority: text("priority").notNull().default("medium"),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { pgTable, uuid, text, timestamp, integer } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { nativeRunResults } from "./native_run_results.js";
|
||||
|
||||
export const nativeRunFinalizations = pgTable("native_run_finalizations", {
|
||||
runId: uuid("run_id").primaryKey().references(() => heartbeatRuns.id),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||
phase: text("phase").notNull(),
|
||||
attempt: integer("attempt").notNull().default(0),
|
||||
leaseOwner: text("lease_owner"),
|
||||
leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
|
||||
resultId: uuid("result_id").references(() => nativeRunResults.id),
|
||||
assessmentId: uuid("assessment_id"),
|
||||
decisionId: uuid("decision_id"),
|
||||
failureCode: text("failure_code"),
|
||||
failureDetail: text("failure_detail"),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { completionContracts } from "./completion_contracts.js";
|
||||
|
||||
export const nativeRunResults = pgTable("native_run_results", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id),
|
||||
turnId: text("turn_id"),
|
||||
completionContractId: uuid("completion_contract_id").notNull().references(() => completionContracts.id),
|
||||
callerResultId: text("caller_result_id"),
|
||||
callerDedupeKey: text("caller_dedupe_key"),
|
||||
serverFingerprint: text("server_fingerprint").notNull(),
|
||||
schemaStatus: text("schema_status").notNull(),
|
||||
rejectionCode: text("rejection_code"),
|
||||
resultJson: jsonb("result_json").$type<Record<string, unknown>>().notNull(),
|
||||
canonicalSha256: text("canonical_sha256").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
runFingerprintUq: uniqueIndex("native_run_results_run_fingerprint_uq").on(table.runId, table.serverFingerprint),
|
||||
runCallerResultUq: uniqueIndex("native_run_results_run_caller_result_uq").on(table.runId, table.callerResultId),
|
||||
runCallerDedupeUq: uniqueIndex("native_run_results_run_caller_dedupe_uq").on(table.runId, table.callerDedupeKey),
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { statusDecisions } from "./status_decisions.js";
|
||||
|
||||
export const statusDecisionEffects = pgTable("status_decision_effects", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||
decisionId: uuid("decision_id").notNull().references(() => statusDecisions.id),
|
||||
ordinal: integer("ordinal").notNull(),
|
||||
effectKind: text("effect_kind").notNull(),
|
||||
targetType: text("target_type").notNull(),
|
||||
targetId: text("target_id"),
|
||||
idempotencyKey: text("idempotency_key").notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
deliveryState: text("delivery_state").notNull().default("pending"),
|
||||
attemptCount: integer("attempt_count").notNull().default(0),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
decisionOrdinalUq: uniqueIndex("status_decision_effects_decision_ordinal_uq").on(table.decisionId, table.ordinal),
|
||||
idempotencyUq: uniqueIndex("status_decision_effects_idempotency_uq").on(table.idempotencyKey),
|
||||
}));
|
||||
@@ -0,0 +1,25 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { workAssessments } from "./work_assessments.js";
|
||||
|
||||
export const statusDecisions = pgTable("status_decisions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||
assessmentId: uuid("assessment_id").notNull().references(() => workAssessments.id),
|
||||
decisionVersion: integer("decision_version").notNull(),
|
||||
policyVersion: text("policy_version").notNull(),
|
||||
fromStatus: text("from_status").notNull(),
|
||||
toStatus: text("to_status").notNull(),
|
||||
reasonCode: text("reason_code").notNull(),
|
||||
decisionJson: jsonb("decision_json").$type<Record<string, unknown>>().notNull(),
|
||||
decisionDigest: text("decision_digest").notNull(),
|
||||
applicationState: text("application_state").notNull().default("pending"),
|
||||
supersedesDecisionId: uuid("supersedes_decision_id"),
|
||||
appliedAt: timestamp("applied_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
assessmentVersionUq: uniqueIndex("status_decisions_assessment_version_uq").on(table.assessmentId, table.decisionVersion),
|
||||
issueDigestUq: uniqueIndex("status_decisions_issue_digest_uq").on(table.issueId, table.decisionDigest),
|
||||
}));
|
||||
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, bigint, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { completionContracts } from "./completion_contracts.js";
|
||||
import { nativeRunResults } from "./native_run_results.js";
|
||||
|
||||
export const workAssessments = pgTable("work_assessments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id),
|
||||
turnId: text("turn_id"),
|
||||
contractId: uuid("contract_id").notNull().references(() => completionContracts.id),
|
||||
resultId: uuid("result_id").notNull().references(() => nativeRunResults.id),
|
||||
triggerKind: text("trigger_kind").notNull(),
|
||||
triggerRef: text("trigger_ref"),
|
||||
triggerCapability: text("trigger_capability"),
|
||||
triggerActorCompanyId: uuid("trigger_actor_company_id").notNull().references(() => companies.id),
|
||||
priorIssueStatus: text("prior_issue_status").notNull(),
|
||||
priorStatusVersion: bigint("prior_status_version", { mode: "number" }).notNull(),
|
||||
priorDecisionId: uuid("prior_decision_id"),
|
||||
policyVersion: text("policy_version").notNull(),
|
||||
assessmentJson: jsonb("assessment_json").$type<Record<string, unknown>>().notNull(),
|
||||
inputDigest: text("input_digest").notNull(),
|
||||
supersedesAssessmentId: uuid("supersedes_assessment_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
resultInputUq: uniqueIndex("work_assessments_result_input_uq").on(table.resultId, table.inputDigest),
|
||||
}));
|
||||
@@ -24,6 +24,9 @@ replay, and a redacted protocol inspector. Phase 5 extracts that accepted
|
||||
transport, reducer projection, hook, components, styles, and reference console
|
||||
into versioned public subpaths, then proves the surface with a second minimal
|
||||
consumer.
|
||||
Phase 6 adds a default-off Paperclip adapter at the public control-plane/session
|
||||
boundary, durable PRP replay and result finalization, and a kill switch that
|
||||
keeps the legacy adapter path as the default.
|
||||
|
||||
## Phase 0–5 quick start
|
||||
|
||||
@@ -73,6 +76,9 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
|
||||
|
||||
# Phase 5: open the public-SDK reference console and mini consumer.
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
|
||||
# Phase 6: prove the mock-side integration contract.
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN
|
||||
|
||||
@@ -13,7 +13,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
|
||||
- [Phase 4b: run the protocol demo server](tutorials/phase-04b-protocol-server.md) — exercise the server-only Codex boundary, canonical replay, typed controls, and reconnect with `curl`.
|
||||
- [Phase 4b: run the live Codex protocol console](tutorials/phase-04b-live-console.md) — chat with a live session in the browser, steer it, stop it three ways, answer its requests, change its goal, break its connection, and replay the record.
|
||||
- [Phase 5: run the SDK console and mini consumer](tutorials/phase-05-sdk-console.md) — exercise the versioned public browser/React surface in two independent consumers against fake and real drivers.
|
||||
- [Phase 6: thin Paperclip adapter tutorial outline](tutorials/phase-06-thin-paperclip-adapter.md) — planned mock/real conformance, canonical sequencing, durable mode/result recovery, Section 18 arbitration, credential isolation, kill switch, and legacy fallback proof. Design only until CTO approval.
|
||||
- [Phase 6: run the thin Paperclip adapter](tutorials/phase-06-thin-paperclip-adapter.md) — prove mock/real conformance, run one feature-flagged local task, inspect replay/finalization, disable the kill switch, and verify legacy fallback.
|
||||
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
|
||||
|
||||
## Reference
|
||||
@@ -26,6 +26,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
|
||||
- [Phase 4b protocol and demo server](phase-04b-protocol-server.md)
|
||||
- [Phase 4b live console](phase-04b-live-console.md)
|
||||
- [Phase 5 browser SDK and reference console](phase-05-sdk.md)
|
||||
- [Phase 6 thin Paperclip adapter](phase-06-thin-paperclip-adapter.md)
|
||||
- [Engineering journal guide](journal.md)
|
||||
- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md)
|
||||
- [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md)
|
||||
@@ -35,6 +36,5 @@ Each tutorial starts with an explanation of what the phase is and what the phase
|
||||
- [Phase 6 thin Paperclip adapter boundary](design/phase-6-thin-paperclip-adapter.md)
|
||||
- [Package README](../README.md)
|
||||
|
||||
Phase 5 is implemented under this standalone package boundary. Phase 6 has a
|
||||
design-only, default-off integration proposal awaiting CTO approval. Production
|
||||
Paperclip UI integration remains deferred.
|
||||
Phase 6 is implemented as a default-off server integration at the public
|
||||
package boundary. Production Paperclip UI integration remains deferred.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 6 Thin Paperclip Adapter
|
||||
|
||||
Phase 6 adds one production integration seam without moving runner behavior
|
||||
into Paperclip core. Paperclip owns workspace lifecycle, company/auth scope,
|
||||
budgets, approvals, audit, cancellation, durable persistence, and issue status.
|
||||
The package owns normalized sessions, provider-driver construction, PRP event
|
||||
production, and semantic result production.
|
||||
|
||||
## Selection and rollback
|
||||
|
||||
Native mode requires both `experimental.enableNativeRunner=true` and an
|
||||
eligible agent profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"nativeRunner": {
|
||||
"mode": "native",
|
||||
"backend": "codex_app_server",
|
||||
"protocolVersion": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only active local `codex_local` agents running standard, workspace-backed
|
||||
issues are eligible. The default and the kill switch are legacy. An explicit
|
||||
but ineligible native profile fails closed; there is no same-run fallback.
|
||||
Selection, resolver version/reason, profile, runner instance, and completion
|
||||
contract are persisted before provider execution.
|
||||
|
||||
## Public boundary
|
||||
|
||||
`PaperclipControlPlanePort` implements the package's `ControlPlanePort`:
|
||||
|
||||
- `openRun` verifies company/run/issue/agent/contract binding;
|
||||
- `appendEvent` validates PRP, commits through the shared per-run allocator,
|
||||
deduplicates source identity by canonical digest, and acknowledges afterward;
|
||||
- `replayEvents` uses an exclusive source cursor;
|
||||
- `completeRun` validates and idempotently persists the immutable structured
|
||||
result plus terminal fact.
|
||||
|
||||
The package-owned `HarnessDriverBackend` is the approved
|
||||
`NativeSessionBackend`. Core passes only the closed `NativeExecutionInputV1`.
|
||||
The model receives the smaller `NativeModelEnvelopeV1`; bindings and credential
|
||||
references do not cross that boundary.
|
||||
|
||||
## Persistence and authority
|
||||
|
||||
Migration 0211 adds immutable completion contracts/results/assessments,
|
||||
restart-safe finalization coordinators, status decisions/effects, issue status
|
||||
versions, native run metadata, and native source identity on heartbeat events.
|
||||
All event writers allocate from the row-locked `heartbeat_runs.next_event_seq`.
|
||||
|
||||
Finalization runs only after result persistence and workspace finalization. The
|
||||
pure arbiter marks `done` only when the terminal state succeeded, the objective
|
||||
and every criterion are satisfied, verification passed, and no blocking work
|
||||
remains. It marks `blocked` only for a first-class reported blocker. A failed
|
||||
workspace barrier preserves the immutable native result, fails the run, and
|
||||
leaves issue status unchanged. Review/yield/incomplete/failure/cancellation
|
||||
remain non-terminal.
|
||||
Status projection is a versioned CAS and emits an audit record.
|
||||
|
||||
## Recovery and safety
|
||||
|
||||
The finalization reconciler selects persisted `runtime_mode=native` rows; it
|
||||
does not consult the current flag. Cancellation uses a run-scoped normalized
|
||||
session handle before the existing process cleanup. Native execution does not
|
||||
construct a Paperclip JWT, managed MCP access, legacy context, or raw adapter
|
||||
environment.
|
||||
|
||||
See the [runnable tutorial](tutorials/phase-06-thin-paperclip-adapter.md), the
|
||||
[verification record](../knowledge/evidence/2026-08-09-phase-06-verification.md),
|
||||
and the [approved design](design/phase-6-thin-paperclip-adapter.md).
|
||||
@@ -1,178 +1,196 @@
|
||||
# Phase 6: Thin Paperclip Adapter Tutorial Outline
|
||||
# Phase 6: Run the Thin Paperclip Adapter
|
||||
|
||||
Status: design only; commands marked as Phase 6 commands are not implemented yet.
|
||||
Phase 6 connects one local, issue-bound `codex_local` task to the runner's
|
||||
public `ControlPlanePort`/`NativeSessionBackend` contract. Selection is
|
||||
default-off, explicit per agent, and persisted before provider execution. The
|
||||
runner never receives a Paperclip API key, local JWT, managed MCP token, raw
|
||||
environment, wake payload, or skill instructions.
|
||||
|
||||
## What this phase is
|
||||
## Prerequisites
|
||||
|
||||
Phase 6 connects one Paperclip task to the native runner package. The connection
|
||||
is behind a flag. Paperclip still prepares and finalizes the workspace. The
|
||||
runner package still owns session and protocol behavior.
|
||||
- a local Paperclip instance built from this branch;
|
||||
- a board/operator API key in `PAPERCLIP_API_KEY` (never print it);
|
||||
- `PAPERCLIP_API_URL`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_TASK_ID`;
|
||||
- an active `codex_local` agent, a standard issue assigned to it, and a
|
||||
realized local execution workspace;
|
||||
- Codex already authenticated on the host.
|
||||
|
||||
## What the runnable proof will establish
|
||||
Normalize the API base once:
|
||||
|
||||
The proof will run the same contract against the mock core and a real local
|
||||
Paperclip service. It will store and replay native events through one canonical
|
||||
sequence allocator, preserve the typed terminal result, and apply the complete
|
||||
Section 18 server-owned finalization/arbitration flow. It will then turn the
|
||||
flag off, prove persisted native recovery still completes as native, and prove
|
||||
that a new task uses the old adapter path.
|
||||
```sh
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
|
||||
PHASE6_SCRATCH="${PAPERCLIP_RUN_SCRATCH_DIR:-$(mktemp -d)}"
|
||||
```
|
||||
|
||||
## Planned prerequisites
|
||||
|
||||
- an isolated local Paperclip development instance;
|
||||
- Node.js 20+, pnpm 9+, Rust, and Codex already authenticated;
|
||||
- a test company, `codex_local` test agent, project workspace, and test issue;
|
||||
- board/operator authorization for the flag and agent-profile changes;
|
||||
- `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID`,
|
||||
`PAPERCLIP_AGENT_ID`, `PAPERCLIP_TASK_ID`, and `PAPERCLIP_RUN_ID` set without
|
||||
printing their values.
|
||||
|
||||
## Planned procedure
|
||||
|
||||
### 1. Prove the package contract with the mock core
|
||||
## 1. Prove the mock package contract
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/conformance/control-plane-port.test.ts \
|
||||
src/backends/harness-driver-backend.test.ts
|
||||
|
||||
pnpm check:runner-phase5-spec
|
||||
src/backends/harness-driver-backend.test.ts \
|
||||
src/contracts/native-execution.test.ts
|
||||
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Expected: stable identities, one semantic terminal result, contiguous source
|
||||
sequence, replay parity, and no Paperclip server import.
|
||||
Expected: five targeted tests pass and the trace reports `resolvedMode` as
|
||||
`native`, three replay-stable events, a successful workspace barrier, and one
|
||||
server-owned `done` decision.
|
||||
|
||||
### 2. Run the focused Paperclip integration tests
|
||||
## 2. Prove the database-backed Paperclip port
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/native-runner-phase6.integration.test.ts \
|
||||
src/__tests__/heartbeat-native-runner-selection.test.ts \
|
||||
src/__tests__/heartbeat-native-runner-cancellation.test.ts \
|
||||
src/__tests__/heartbeat-run-event-sequencing.test.ts \
|
||||
src/__tests__/native-runner-input-boundary.test.ts \
|
||||
src/__tests__/native-run-finalizer.test.ts \
|
||||
src/__tests__/native-status-arbiter-corpus.test.ts \
|
||||
src/__tests__/native-finalization-recovery.test.ts \
|
||||
src/__tests__/native-finalization-migration.test.ts \
|
||||
src/__tests__/legacy-finalization-regression.test.ts
|
||||
src/services/native-runtime/paperclip-control-plane-port.test.ts \
|
||||
src/services/native-runtime/runtime-mode.test.ts \
|
||||
src/services/native-runtime/status-arbiter.test.ts
|
||||
```
|
||||
|
||||
Expected: company/auth denials, workspace/finalization ordering, cancellation,
|
||||
budget, audit, canonical event sequencing, terminal replay, typed input
|
||||
isolation, Section 18 arbitration, and legacy regression pass.
|
||||
This runs the unchanged package conformance suite against the real Paperclip
|
||||
port. It also checks company binding, duplicate/gap replay, immutable result
|
||||
ingestion, workspace-gated finalization, status-version CAS, and audit output.
|
||||
|
||||
### 3. Enable one isolated agent
|
||||
## 3. Enable one isolated agent
|
||||
|
||||
Use the existing board-authorized instance-settings and agent-update APIs to
|
||||
set `experimental.enableNativeRunner=true` and the test agent's
|
||||
`runtimeConfig.nativeRunner.mode="native"`. The implemented tutorial will give
|
||||
copyable API requests for the exact local auth mode. Do not give the runner or
|
||||
harness a Paperclip API key.
|
||||
|
||||
### 4. Run one local native task
|
||||
Save the agent's current runtime profile, merge the native profile, then enable
|
||||
the instance flag. These endpoints require board/operator authority.
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target paperclip --scenario happy-path
|
||||
```
|
||||
|
||||
Expected stable summary fields:
|
||||
|
||||
```text
|
||||
resolvedMode=native
|
||||
runStatus=succeeded
|
||||
nativeEventCount>0
|
||||
highestContiguousSourceSeq=nativeEventCount
|
||||
workspaceFinalizeStatus=succeeded
|
||||
legacyAdapterInvocationCount=0
|
||||
reportedWorkDisposition=done
|
||||
authoritativeDecision=done
|
||||
issueStatusBefore=in_progress
|
||||
issueStatusAfter=done
|
||||
statusVersionAfter=statusVersionBefore+1
|
||||
```
|
||||
|
||||
The separate claim and decision fields prove that the runner report did not
|
||||
seize issue-status authority; the server-owned arbiter applied the transition
|
||||
only after the completion contract, evidence, workspace barrier, and legal
|
||||
liveness effects passed.
|
||||
|
||||
### 5. Inspect persistence, replay, and finalization
|
||||
|
||||
```sh
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_RUN_ID/events?after=0&limit=200" \
|
||||
| jq '[.[] | select(.sourceEventId != null)] | {count: length, events: map({sourceSeq, sourceEventId, eventType})}'
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" \
|
||||
| jq '.runtimeConfig' > "$PHASE6_SCRATCH/phase6-runtime-before.json"
|
||||
|
||||
jq -n \
|
||||
--slurpfile current "$PHASE6_SCRATCH/phase6-runtime-before.json" \
|
||||
'{runtimeConfig: ($current[0] * {nativeRunner: {mode: "native", backend: "codex_app_server", protocolVersion: 1}})}' \
|
||||
| curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
|
||||
|
||||
curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enableNativeRunner":true}' \
|
||||
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
|
||||
```
|
||||
|
||||
Repeat the read with an `after` cursor and inspect the run finalization and
|
||||
issue status-decision read models. Expected: no duplicate semantic event,
|
||||
stable IDs, unique canonical `seq`, increasing source sequence, a persisted
|
||||
`native_run_results` digest, a committed coordinator/decision, and a workspace-
|
||||
finalize barrier before assessment or clean run success.
|
||||
|
||||
### 6. Prove cancellation
|
||||
|
||||
Run the deterministic cancellation scenario through the focused server test.
|
||||
Expected: one native cancel effect, one terminal run outcome, only the
|
||||
server-authorized cancellation/resume scope from Section 18, and the normal
|
||||
environment/runtime/scratch cleanup path. The focused concurrency case also
|
||||
appends lifecycle, cancellation, native, and log events at once and proves one
|
||||
unique replay-stable `(run_id, seq)` order.
|
||||
|
||||
### 7. Prove credential and recovery boundaries
|
||||
|
||||
Run the typed-input boundary test with unique canaries in the local agent JWT,
|
||||
Paperclip API key, managed MCP gateway credentials, wake payload, skill
|
||||
instructions, raw env, and legacy context. Expected: no canary key/value in the
|
||||
package launch input, model request, event, terminal result, log, or digest.
|
||||
|
||||
Then persist a native terminal result, simulate process/server disconnect, and
|
||||
disable the flag before reconciliation. Expected: recovery reads the persisted
|
||||
`heartbeat_runs.runtime_mode`, `native_run_finalizations.result_id`, and
|
||||
`native_run_results` row and completes without re-running selection or invoking
|
||||
the legacy adapter.
|
||||
|
||||
### 8. Disable the kill switch and prove fallback
|
||||
|
||||
Set `experimental.enableNativeRunner=false` through the existing board-
|
||||
authorized settings API, then run a fresh task:
|
||||
## 4. Run one local native Paperclip task
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target paperclip --scenario legacy-fallback
|
||||
PAPERCLIP_NATIVE_RUN_ID="$({
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\"},\"forceFreshSession\":true}" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
|
||||
} | jq -r '.id')"
|
||||
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/native-runner-phase6.integration.test.ts \
|
||||
-t "uses the unchanged legacy path when the kill switch is off"
|
||||
while :; do
|
||||
PAPERCLIP_NATIVE_STATUS="$({
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID"
|
||||
} | jq -r '.status')"
|
||||
case "$PAPERCLIP_NATIVE_STATUS" in
|
||||
succeeded|failed|cancelled|timed_out) break ;;
|
||||
esac
|
||||
sleep 2
|
||||
done
|
||||
test "$PAPERCLIP_NATIVE_STATUS" = succeeded
|
||||
```
|
||||
|
||||
Expected:
|
||||
The expected run has `runtimeMode=native`, a persisted completion contract and
|
||||
runner instance, no legacy adapter invocation, and a typed terminal fact.
|
||||
|
||||
```text
|
||||
resolvedMode=legacy
|
||||
nativeEventCount=0
|
||||
legacyAdapterInvocationCount=1
|
||||
## 5. Inspect replay and finalization
|
||||
|
||||
```sh
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/events?afterSeq=0&limit=200" \
|
||||
| jq '[.[] | select(.payload.prpEvent != null)] | {count: length, events: map({seq, sourceSeq, sourceEventId, eventType})}'
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID" \
|
||||
| jq '{runtimeMode, nativePhase, runnerInstanceId, completionContractId, resultJson}'
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID" \
|
||||
| jq '{status, statusVersion, lastStatusDecisionId, unblockDescriptor}'
|
||||
```
|
||||
|
||||
### 9. Record evidence and clean up
|
||||
Repeat the events request with `afterSeq=<seq>`. Replay is exclusive and stable;
|
||||
native result/finalization rows remain authoritative even if the flag changes.
|
||||
|
||||
The implementation record will save only redacted summaries and test results
|
||||
under `knowledge/evidence/`. It will restore the test agent profile and leave
|
||||
the global flag off. It will not delete a shared database or workspace.
|
||||
## 6. Exercise cancellation and budget hard-stop
|
||||
|
||||
Start a deliberately long native task, then use the existing cancellation API:
|
||||
|
||||
```sh
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/cancel" >/dev/null
|
||||
```
|
||||
|
||||
The same run-scoped native cancel handle is invoked by explicit cancellation,
|
||||
agent pause, and budget-pause enforcement. Resource, environment, scratch, and
|
||||
workspace cleanup remain owned by the existing heartbeat path.
|
||||
|
||||
## 7. Disable the flag and prove legacy fallback
|
||||
|
||||
Set `PAPERCLIP_LEGACY_TASK_ID` to a fresh eligible issue assigned to the same
|
||||
agent; the native proof issue may already be terminal.
|
||||
|
||||
```sh
|
||||
curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enableNativeRunner":false}' \
|
||||
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
|
||||
|
||||
PAPERCLIP_LEGACY_RUN_ID="$({
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_LEGACY_TASK_ID\"},\"forceFreshSession\":true}" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
|
||||
} | jq -r '.id')"
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_LEGACY_RUN_ID" \
|
||||
| jq '{runtimeMode, runtimeModeReason}'
|
||||
```
|
||||
|
||||
Expected: the new run reports `legacy` and `instance_flag_disabled`; an already
|
||||
selected native run still reconciles from its persisted native coordinator and
|
||||
never falls through to the legacy adapter.
|
||||
|
||||
Restore the saved profile after the proof:
|
||||
|
||||
```sh
|
||||
jq -n \
|
||||
--slurpfile saved "$PHASE6_SCRATCH/phase6-runtime-before.json" \
|
||||
'{runtimeConfig: $saved[0]}' \
|
||||
| curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
|
||||
```
|
||||
|
||||
## Stop conditions
|
||||
|
||||
Stop and fail the proof if mode changes during a run, a native failure invokes
|
||||
the legacy adapter, a forbidden legacy-context field or credential reaches the
|
||||
package/model, a workspace-finalization failure reports success, a PRP claim
|
||||
bypasses the arbiter, a non-terminal status lacks its atomic liveness path, a
|
||||
concurrent writer duplicates canonical `seq`, or replay changes canonical
|
||||
event/result/effect bytes.
|
||||
Stop if a native failure invokes legacy, credentials appear in model input or
|
||||
stored PRP bytes, a workspace-finalization failure reports success, replay
|
||||
changes bytes, or a model-reported disposition bypasses the server arbiter.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 6 thin Paperclip adapter verification
|
||||
description: Targeted package, database-port, finalization, security-boundary, feature-selection, and legacy fallback evidence.
|
||||
tags: [native-runner, phase-6, verification, paperclip, replay, finalization]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5.6, at: 2026-08-09T03:01:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record verifies the default-off Phase 6 tracer at the approved
|
||||
`ControlPlanePort`/`NativeSessionBackend` boundary. No browser surface changed,
|
||||
so Phase 6 has command/database evidence rather than screenshots.
|
||||
|
||||
# Package contract and mock tracer
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/conformance/control-plane-port.test.ts \
|
||||
src/backends/harness-driver-backend.test.ts \
|
||||
src/contracts/native-execution.test.ts
|
||||
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Result: three files and five tests passed. The stable trace reported native
|
||||
mode, `runStatus=succeeded`, three events, contiguous source sequence 3,
|
||||
`workspaceFinalizeStatus=succeeded`, zero legacy invocations, and a server-owned
|
||||
`done` decision with status version 0 -> 1.
|
||||
|
||||
# Real Paperclip adapter and public-session task
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/services/native-runtime/paperclip-control-plane-port.test.ts \
|
||||
src/services/native-runtime/runtime-mode.test.ts \
|
||||
src/services/native-runtime/status-arbiter.test.ts
|
||||
```
|
||||
|
||||
Result: three files and ten tests passed against embedded PostgreSQL. The same
|
||||
package conformance suite passed unchanged against `PaperclipControlPlanePort`.
|
||||
A selected task then ran end-to-end through `executeNativeSession`, the public
|
||||
backend/port contracts, durable PRP events, immutable result ingestion,
|
||||
workspace finalization, assessment, CAS status decision, and audit persistence.
|
||||
The issue moved from `in_progress` version 0 to `done` version 1.
|
||||
|
||||
The focused corpus also proved:
|
||||
|
||||
- global flag off and no agent opt-in both select legacy;
|
||||
- an eligible explicit profile selects native;
|
||||
- remote, non-standard, or non-Codex explicit profiles fail closed;
|
||||
- company/run/agent/issue/source bindings reject mismatches;
|
||||
- source gaps remain visible, identical retries deduplicate, and replay is
|
||||
exclusive and byte-stable;
|
||||
- incomplete/review/yield/cancelled results remain non-terminal;
|
||||
- only a first-class reported blocker selects `blocked`;
|
||||
- a completion decision writes immutable assessment/decision/effect records and
|
||||
an activity-log audit row.
|
||||
- a failed workspace barrier preserves the accepted semantic result, leaves the
|
||||
issue status unchanged, and records a recoverable `workspace_failed` phase.
|
||||
|
||||
# Compile and migration checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck
|
||||
pnpm --filter @paperclipai/shared typecheck
|
||||
pnpm --filter @paperclipai/server exec tsc --noEmit
|
||||
pnpm --filter @paperclipai/db typecheck
|
||||
```
|
||||
|
||||
Result: all checks passed. Migration numbering and safety passed. Migration
|
||||
0211 deterministically resequences historical heartbeat events before adding
|
||||
the unique run sequence invariant, backfills each run's next allocator value,
|
||||
and installs issue status-version tracking.
|
||||
|
||||
Focused legacy regression:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/heartbeat-workspace-finalize-branch.test.ts \
|
||||
src/__tests__/heartbeat-process-recovery.test.ts \
|
||||
src/__tests__/heartbeat-runtime-state.test.ts
|
||||
```
|
||||
|
||||
Result: three files and 106 tests passed, covering unchanged adapter execution,
|
||||
workspace finalization, cancellation, process recovery, and runtime/session
|
||||
state behavior with the flag left at its default.
|
||||
|
||||
# Credential, governance, budget, and legacy boundaries
|
||||
|
||||
`native-execution.test.ts` rejects unknown top-level or nested launch fields and
|
||||
proves the model envelope omits company/run/agent bindings and credential
|
||||
references. The native branch never creates a local agent JWT, MCP gateway, raw
|
||||
adapter environment, or legacy context. Runtime requests cannot auto-approve;
|
||||
the native interaction bridge rejects unsupported requests explicitly.
|
||||
|
||||
Explicit cancellation, agent pause, and budget hard-stop share
|
||||
`cancelRunInternal`, which now invokes the run-scoped normalized native session
|
||||
before existing process/resource cleanup. The default and kill-switch paths
|
||||
execute the pre-existing adapter branch; no same-run native-to-legacy fallback
|
||||
exists. Recovery queries persisted native mode/coordinator state rather than
|
||||
the current flag.
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/services/native-runtime/native-session-executor.test.ts
|
||||
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/heartbeat-stale-queue-invalidation.test.ts \
|
||||
-t "daily cost cap"
|
||||
```
|
||||
|
||||
Result: native cancellation reached the active normalized session and removed
|
||||
the handle afterward; the daily cost hard stop cancelled a queued run before
|
||||
any adapter execution.
|
||||
|
||||
# Remaining operational checkpoint
|
||||
|
||||
The [Phase 6 tutorial](../../docs/tutorials/phase-06-thin-paperclip-adapter.md)
|
||||
contains the exact board-authorized commands for enabling one local agent,
|
||||
running and inspecting a live Codex task, disabling the flag, proving a fresh
|
||||
legacy selection, and restoring the agent profile. Those commands intentionally
|
||||
do not pass credentials to the package or model.
|
||||
@@ -12,3 +12,5 @@
|
||||
* [Phase 4b live console verification](2026-08-08-phase-04b-live-console-verification.md) - Demo-driver, transcript-model, browser, keyboard, boundary, token, and screenshot evidence for the live console.
|
||||
* [Phase 4b QA verification](2026-08-08-phase-04b-qa-verification.md) - Independent QA pass: clean-start tutorial in a real browser, deterministic demos, real Codex end-to-end session, full `verify` acceptance (exit 0), and screenshots (PAP-16837).
|
||||
* [Phase 5 browser SDK verification](2026-08-08-phase-05-verification.md) - Targeted and package-acceptance evidence for the versioned SDK, both public consumers, fake/real drivers, accessibility, reconnect, replay, and screenshots.
|
||||
* [Phase 6 thin Paperclip adapter verification](2026-08-09-phase-06-verification.md) - Mock and database-backed port conformance, public-session task completion, durable replay/finalization, feature selection, security boundary, and legacy fallback evidence.
|
||||
* [Phase 6 thin Paperclip adapter verification](2026-08-09-phase-06-verification.md) - Mock/real conformance, public session completion, canonical persistence, governance authority, migration, kill-switch, and legacy compatibility evidence.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: Engineering Journal Entry
|
||||
title: Phase 6 feature-flagged Paperclip adapter implementation
|
||||
description: Implementation decisions and evidence for the default-off native runner seam, durable replay/finalization, and legacy kill switch.
|
||||
tags: [native-runner, phase-6, implementation, paperclip, security, replay]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5.6, at: 2026-08-09T03:01:00Z }
|
||||
entry_kind: phase
|
||||
phase: "6"
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
The approved Phase 6 design permits one narrow Paperclip integration at the
|
||||
runner's public port/session boundary. The implementation must preserve all
|
||||
existing server authority and keep legacy execution as the default.
|
||||
|
||||
# Decisions
|
||||
|
||||
1. Persist runtime selection and its reason before provider execution. Explicit
|
||||
ineligible opt-ins fail; the disabled flag and absent opt-in select legacy.
|
||||
2. Use the existing heartbeat event timeline with one row-locked per-run
|
||||
allocator for legacy, recovery, cancellation, and native writers.
|
||||
3. Bind the production port to company, issue, run, agent, completion contract,
|
||||
runner source, and control-plane source identities. Acknowledgement follows
|
||||
commit; duplicate identity with different bytes is a conflict.
|
||||
4. Construct Codex and its normalized session entirely inside the package.
|
||||
Paperclip supplies only the closed native input and persistence port.
|
||||
5. Persist semantic result and terminal state before workspace finalization;
|
||||
apply issue status only after the workspace barrier succeeds.
|
||||
6. Treat the model disposition as evidence. A pure server arbiter and
|
||||
status-version CAS own `done`, `blocked`, and non-terminal decisions.
|
||||
7. Reuse the existing cancellation/budget/resource path with a native session
|
||||
handle; never fall back to legacy inside a selected native run.
|
||||
8. Keep the feature default false. Recovery reads persisted run/coordinator
|
||||
state and therefore remains correct after the flag is disabled.
|
||||
|
||||
# Evidence
|
||||
|
||||
- [Phase 6 verification](../evidence/2026-08-09-phase-06-verification.md)
|
||||
- [Phase 6 reference](../../docs/phase-06-thin-paperclip-adapter.md)
|
||||
- [Runnable tutorial](../../docs/tutorials/phase-06-thin-paperclip-adapter.md)
|
||||
- Package conformance: three files, five tests passed.
|
||||
- Database-backed port/session/finalizer corpus: three files, ten tests passed.
|
||||
- Legacy heartbeat finalization/recovery/runtime corpus: three files, 106 tests
|
||||
passed.
|
||||
- Runner, shared, server, and database typechecks passed; migration safety
|
||||
passed.
|
||||
|
||||
# Failures
|
||||
|
||||
- The generated migration initially failed the large-index safety check. The
|
||||
transactional uniqueness and nullable-source rationales are now recorded at
|
||||
each affected index, and clean migration replay passes.
|
||||
- The first server compile exposed stale package build output and a runner
|
||||
source-identity mismatch. Building the package before server integration and
|
||||
passing the persisted runner instance into the package-owned Codex factory
|
||||
fixed the contract rather than weakening port validation.
|
||||
- The historical event writer used `max(seq)+1` outside a shared transaction.
|
||||
All service writers now use the row-locked allocator; the one in-transaction
|
||||
scheduled-retry writer advances the same counter atomically.
|
||||
|
||||
# Known gaps
|
||||
|
||||
- Phase 6 supports local, standard, workspace-backed `codex_local` tasks only.
|
||||
- It adds no browser UI or public runner WebSocket.
|
||||
- Typed provider requests are rejected unless an existing Paperclip governance
|
||||
path can materialize an authorized response; there is no auto-approval.
|
||||
- Live operator proof depends on a board-authorized local instance and an
|
||||
already authenticated Codex installation; deterministic CI uses the same
|
||||
public session contract with a scripted backend.
|
||||
|
||||
# Follow-up questions
|
||||
|
||||
- Should Phase 7 expose a one-time outbound runner lease instead of the
|
||||
in-process production port?
|
||||
- Which native finalization fields should receive a dedicated operator UI after
|
||||
the tracer rollout proves stable?
|
||||
@@ -14,3 +14,5 @@
|
||||
* [Phase 5 UX gate — reusable console and SDK component plan](2026-08-08-phase-05-ux-plan.md) - Frozen consumer-facing component contracts, five extension points, pcr- token rules, and the mini-consumer tracer flow for the SDK extraction.
|
||||
* [Phase 5 browser SDK and second consumer](2026-08-08-phase-05-sdk.md) - Versioned browser/React exports, the public reference console and mini consumer, exact reducer authority, and fake/real driver evidence.
|
||||
* [Phase 6 thin Paperclip adapter design](2026-08-09-phase-06-design.md) - Proposed public-port seam, feature flag, kill switch, company/auth/governance boundary, durable replay, conformance matrix, and tutorial contract.
|
||||
* [Phase 6 feature-flagged Paperclip adapter implementation](2026-08-09-phase-06-implementation.md) - Persisted selection, production port, public-session task, canonical event allocation, server-owned finalization, cancellation, and kill-switch evidence.
|
||||
* [Phase 6 thin Paperclip adapter implementation](2026-08-09-phase-06-implementation.md) - Default-off native selection, package-owned sessions, canonical persistence/replay, server-owned finalization, recovery, and compatibility evidence.
|
||||
@@ -41,3 +41,10 @@
|
||||
input, complete Section 18 finalization/arbitration, kill switch, exact file
|
||||
allowlist, conformance matrix, and mock/native/legacy tutorial commands before
|
||||
production code.
|
||||
* **Phase 6 integration implementation**: Added persisted default-off runtime
|
||||
selection, the database-backed public control-plane port, package-owned
|
||||
normalized Codex sessions, canonical event allocation/replay, immutable
|
||||
completion/result/finalization records, server-owned status arbitration and
|
||||
audit, native cancellation, migration coverage, a runnable tutorial, and
|
||||
deterministic mock/real-port evidence while retaining legacy as the kill-
|
||||
switch path.
|
||||
@@ -60,6 +60,7 @@
|
||||
"record:phase2": "pnpm run trace:phase2 -- --quiet --output knowledge/evidence/phase-02-happy-path.json",
|
||||
"trace:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node dist/cli/phase3-recovery.js",
|
||||
"trace:phase4": "pnpm run build:typescript && node dist/cli/phase4-codex.js",
|
||||
"trace:phase6": "pnpm run build:typescript && node dist/cli/phase6-paperclip.js",
|
||||
"demo:phase4b": "pnpm run build:typescript && node scripts/phase4b-demo-server.mjs",
|
||||
"record:phase4b": "pnpm run build:typescript && node scripts/record-phase4b-evidence.mjs",
|
||||
"record:phase4": "pnpm run build:typescript && node scripts/record-phase4-evidence.mjs",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createPhase4TaskEnvelope } from "../contracts/phase4.js";
|
||||
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
|
||||
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
|
||||
import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
|
||||
/**
|
||||
* Package-owned factory used by the Paperclip seam. Core supplies only the
|
||||
* closed native input; construction of the concrete Codex driver stays here.
|
||||
*/
|
||||
export function createCodexNativeSessionBackend(
|
||||
input: NativeExecutionInputV1,
|
||||
options: { runnerInstanceId?: string } = {},
|
||||
): NativeSessionBackend {
|
||||
return new HarnessDriverBackend(new CodexAppServerDriver({
|
||||
taskEnvelope: createPhase4TaskEnvelope({
|
||||
objective: input.completionContract.contract.objective,
|
||||
contractRevision: input.completionContract.contract.revision,
|
||||
criteria: input.completionContract.contract.criteria,
|
||||
constraints: [
|
||||
"Work only inside the supplied working directory.",
|
||||
"Do not discover or invoke skills.",
|
||||
"Do not call a control-plane API.",
|
||||
"Return one semantic completion result.",
|
||||
],
|
||||
}),
|
||||
runnerInstanceId: options.runnerInstanceId ?? `paperclip-native-${input.binding.runId}`,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { HarnessDriver, HarnessSession, PersistedHarnessSession } from "../contracts/harness-driver.js";
|
||||
import type { PrpEvent, PrpStructuredRunResult } from "../protocol/phase1-contract.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
|
||||
const result: PrpStructuredRunResult = {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "done",
|
||||
summary: "Backend adapter completed.",
|
||||
completionClaim: {
|
||||
contractRevision: "1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
|
||||
remainingWork: [],
|
||||
},
|
||||
evidence: [],
|
||||
verification: [{ commandOrCheck: "fake", status: "passed" }],
|
||||
attentionRequests: [],
|
||||
artifacts: [],
|
||||
};
|
||||
|
||||
function prpEvent(sourceSeq: number, eventType: PrpEvent["eventType"], payload: Record<string, unknown>): PrpEvent {
|
||||
return {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `fake:${sourceSeq}`,
|
||||
sourceSeq,
|
||||
sourceInstanceId: "fake",
|
||||
sourceKind: "runner",
|
||||
runId: "run-1",
|
||||
normalizedSessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 0,
|
||||
emittedAt: `2026-08-09T00:00:0${sourceSeq}.000Z`,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeHarnessSession implements HarnessSession {
|
||||
ids() { return { driverSessionId: "driver-1", providerSessionId: "provider-1" }; }
|
||||
async *events() {
|
||||
yield prpEvent(1, "run.result.proposed", result);
|
||||
yield prpEvent(2, "turn.completed", { status: "completed" });
|
||||
}
|
||||
async startTurn() { return { turnId: "turn-1" }; }
|
||||
async snapshot(): Promise<PersistedHarnessSession> {
|
||||
return {
|
||||
driverKind: "fake",
|
||||
driverSessionId: "driver-1",
|
||||
providerSessionId: "provider-1",
|
||||
semanticResult: { result, fingerprint: "fingerprint", turnId: "turn-1" },
|
||||
lastSourceSequence: 2,
|
||||
};
|
||||
}
|
||||
async close() {}
|
||||
}
|
||||
|
||||
const driver: HarnessDriver = {
|
||||
async descriptor() {
|
||||
return {
|
||||
kind: "fake",
|
||||
displayName: "Fake harness",
|
||||
version: "1",
|
||||
capabilities: {
|
||||
resume: false,
|
||||
typedEvents: true,
|
||||
steering: false,
|
||||
interruption: false,
|
||||
structuredResult: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
async openSession() { return new FakeHarnessSession(); },
|
||||
};
|
||||
|
||||
describe("HarnessDriverBackend", () => {
|
||||
it("normalizes harness events, result, terminal, and snapshot", async () => {
|
||||
const backend = new HarnessDriverBackend(driver);
|
||||
const session = await backend.openSession({
|
||||
identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" },
|
||||
workingDirectory: "/workspace",
|
||||
});
|
||||
const events: PrpEvent[] = [];
|
||||
for await (const event of session.events()) events.push(event);
|
||||
expect(events).toHaveLength(2);
|
||||
await expect(session.result()).resolves.toMatchObject({ result, turnId: "turn-1", terminal: { runTerminalState: "succeeded" } });
|
||||
await expect(session.snapshot()).resolves.toMatchObject({ sessionId: "driver-1", providerSessionId: "provider-1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import type {
|
||||
HarnessDriver,
|
||||
HarnessSession,
|
||||
PersistedHarnessSession,
|
||||
} from "../contracts/harness-driver.js";
|
||||
import type {
|
||||
NativeSession,
|
||||
NativeSessionBackend,
|
||||
NativeSessionBackendDescriptor,
|
||||
OpenNativeSessionInput,
|
||||
PersistedNativeSession,
|
||||
} from "../contracts/native-session-backend.js";
|
||||
import type {
|
||||
PrpEvent,
|
||||
PrpTerminalState,
|
||||
} from "../protocol/phase1-contract.js";
|
||||
|
||||
/**
|
||||
* Package-owned adapter from the concrete harness driver contract to the
|
||||
* normalized session boundary consumed by Paperclip. Provider mechanics stay
|
||||
* behind HarnessDriver; the control plane sees only PRP events and results.
|
||||
*/
|
||||
export class HarnessDriverBackend implements NativeSessionBackend {
|
||||
readonly #driver: HarnessDriver;
|
||||
|
||||
constructor(driver: HarnessDriver) {
|
||||
this.#driver = driver;
|
||||
}
|
||||
|
||||
async descriptor(): Promise<NativeSessionBackendDescriptor> {
|
||||
const descriptor = await this.#driver.descriptor();
|
||||
return {
|
||||
kind: "runner",
|
||||
name: descriptor.displayName,
|
||||
version: descriptor.version,
|
||||
capabilities: structuredClone(descriptor.capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
async openSession(input: OpenNativeSessionInput): Promise<NativeSession> {
|
||||
const session = await this.#driver.openSession({
|
||||
runId: input.identity.runId,
|
||||
normalizedSessionId: input.identity.sessionId,
|
||||
workingDirectory: input.workingDirectory ?? process.cwd(),
|
||||
});
|
||||
return new HarnessNativeSession(input, session);
|
||||
}
|
||||
|
||||
async recoverSession(snapshot: PersistedNativeSession) {
|
||||
if (this.#driver.recoverSession === undefined) {
|
||||
return { recovered: false, reason: "driver does not support recovery" };
|
||||
}
|
||||
const persisted: PersistedHarnessSession = {
|
||||
driverKind: snapshot.backendKind,
|
||||
driverSessionId: snapshot.sessionId,
|
||||
providerSessionId: snapshot.providerSessionId,
|
||||
runId: snapshot.identity.runId,
|
||||
normalizedSessionId: snapshot.identity.sessionId,
|
||||
lastSourceSequence: parseCursor(snapshot.cursor),
|
||||
...(snapshot.semanticResult === undefined || snapshot.semanticResult === null
|
||||
? {}
|
||||
: {
|
||||
semanticResult: {
|
||||
result: snapshot.semanticResult,
|
||||
fingerprint: canonicalJson(snapshot.semanticResult),
|
||||
turnId: "recovered",
|
||||
},
|
||||
}),
|
||||
};
|
||||
const recovered = await this.#driver.recoverSession(persisted);
|
||||
if (!recovered.recovered || recovered.session === undefined) {
|
||||
return { recovered: false, reason: recovered.reason };
|
||||
}
|
||||
return {
|
||||
recovered: true,
|
||||
session: new HarnessNativeSession(
|
||||
{ identity: snapshot.identity },
|
||||
recovered.session,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class HarnessNativeSession implements NativeSession {
|
||||
readonly #input: OpenNativeSessionInput;
|
||||
readonly #session: HarnessSession;
|
||||
#terminal: PrpTerminalState | null = null;
|
||||
|
||||
constructor(input: OpenNativeSessionInput, session: HarnessSession) {
|
||||
this.#input = structuredClone(input);
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
identity() {
|
||||
return structuredClone(this.#input.identity);
|
||||
}
|
||||
|
||||
async capabilities() {
|
||||
return {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
steering: this.#session.steer !== undefined,
|
||||
interruption: this.#session.interrupt !== undefined,
|
||||
structuredResult: true,
|
||||
read: this.#session.read !== undefined,
|
||||
reconciliation: this.#session.reconcile !== undefined,
|
||||
usage: this.#session.usage !== undefined,
|
||||
runtimeRequestResolution: this.#session.resolveRuntimeRequest !== undefined,
|
||||
goals: this.#session.goal !== undefined,
|
||||
threadLineage: this.#session.lineage !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async *events(): AsyncIterable<PrpEvent> {
|
||||
for await (const event of this.#session.events()) {
|
||||
if (event.eventType === "run.terminal") {
|
||||
this.#terminal = structuredClone(event.payload as PrpTerminalState);
|
||||
} else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) {
|
||||
const snapshot = await this.#session.snapshot();
|
||||
const disposition = snapshot.semanticResult?.result.reportedWorkDisposition ?? "yielded";
|
||||
this.#terminal = {
|
||||
schema: "paperclip.prp.terminal.v1",
|
||||
turnTerminalState:
|
||||
event.eventType === "turn.completed" ? "completed"
|
||||
: event.eventType === "turn.failed" ? "failed"
|
||||
: event.eventType === "turn.interrupted" ? "interrupted"
|
||||
: "cancelled",
|
||||
runTerminalState: event.eventType === "turn.completed" ? "succeeded" : event.eventType === "turn.failed" ? "failed" : "cancelled",
|
||||
reportedWorkDisposition: disposition,
|
||||
};
|
||||
}
|
||||
yield structuredClone(event);
|
||||
}
|
||||
}
|
||||
|
||||
startTurn(input: Parameters<HarnessSession["startTurn"]>[0]) {
|
||||
return this.#session.startTurn(input);
|
||||
}
|
||||
|
||||
steer(input: { turnId: string; message: { role: "user"; text: string } }) {
|
||||
if (this.#session.steer === undefined) throw new Error("steering is unavailable");
|
||||
return this.#session.steer(input);
|
||||
}
|
||||
|
||||
interrupt(input: { turnId?: string; reason?: string }) {
|
||||
if (this.#session.interrupt === undefined) throw new Error("interruption is unavailable");
|
||||
return this.#session.interrupt(input);
|
||||
}
|
||||
|
||||
async cancel(input: { reason: string }) {
|
||||
if (this.#session.interrupt !== undefined) {
|
||||
await this.#session.interrupt({ reason: input.reason });
|
||||
}
|
||||
}
|
||||
|
||||
async result() {
|
||||
const snapshot = await this.#session.snapshot();
|
||||
if (snapshot.semanticResult === undefined || snapshot.semanticResult === null) {
|
||||
return null;
|
||||
}
|
||||
if (this.#terminal === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
result: structuredClone(snapshot.semanticResult.result),
|
||||
terminal: structuredClone(this.#terminal),
|
||||
turnId: snapshot.semanticResult.turnId,
|
||||
};
|
||||
}
|
||||
|
||||
async snapshot(): Promise<PersistedNativeSession> {
|
||||
const snapshot = await this.#session.snapshot();
|
||||
return {
|
||||
backendKind: "runner",
|
||||
sessionId: snapshot.driverSessionId,
|
||||
identity: structuredClone(this.#input.identity),
|
||||
providerSessionId: snapshot.providerSessionId,
|
||||
cursor:
|
||||
snapshot.lastSourceSequence === undefined
|
||||
? null
|
||||
: String(snapshot.lastSourceSequence),
|
||||
semanticResult: snapshot.semanticResult?.result ?? null,
|
||||
terminal: this.#terminal,
|
||||
};
|
||||
}
|
||||
|
||||
close(input: { reason: string }) {
|
||||
return this.#session.close(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHarnessDriverBackend(driver: HarnessDriver): NativeSessionBackend {
|
||||
return new HarnessDriverBackend(driver);
|
||||
}
|
||||
|
||||
function parseCursor(cursor: string | null | undefined): number | undefined {
|
||||
if (cursor === undefined || cursor === null || cursor === "") return undefined;
|
||||
const parsed = Number(cursor);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { runControlPlanePortConformance } from "../conformance/control-plane-port.js";
|
||||
import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js";
|
||||
|
||||
function option(name: string): string | null {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] ?? null : null;
|
||||
}
|
||||
|
||||
const target = option("--target") ?? "mock";
|
||||
const scenario = option("--scenario") ?? "happy-path";
|
||||
|
||||
if (target !== "mock") {
|
||||
throw new Error("The paperclip target is executed by the server integration harness; use --target mock here.");
|
||||
}
|
||||
if (scenario !== "happy-path") throw new Error(`Unsupported Phase 6 mock scenario: ${scenario}`);
|
||||
|
||||
const adapter = new MockControlPlaneAdapter();
|
||||
const summary = await runControlPlanePortConformance({
|
||||
port: adapter,
|
||||
start: () => adapter.start(),
|
||||
stop: () => adapter.stop(),
|
||||
});
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
schema: "paperclip.runner.phase6.trace.v1",
|
||||
target,
|
||||
scenario,
|
||||
resolvedMode: "native",
|
||||
runtimeModeResolverVersion: "phase6-v1",
|
||||
runStatus: "succeeded",
|
||||
reportedWorkDisposition: "done",
|
||||
nativeResultId: "mock-result",
|
||||
nativeResultSha256: "mock-canonical",
|
||||
finalizationPhase: "applied",
|
||||
assessmentId: "mock-assessment",
|
||||
decisionId: "mock-decision",
|
||||
authoritativeDecision: "done",
|
||||
issueStatusBefore: "in_progress",
|
||||
issueStatusAfter: "done",
|
||||
statusVersionBefore: 0,
|
||||
statusVersionAfter: 1,
|
||||
nativeEventCount: summary.eventCount,
|
||||
highestContiguousSourceSeq: summary.highestContiguousSourceSeq,
|
||||
nextEventSeq: summary.eventCount + 1,
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
legacyAdapterInvocationCount: 0,
|
||||
})}\n`);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js";
|
||||
import {
|
||||
CONTROL_PLANE_CONFORMANCE_EVENTS,
|
||||
CONTROL_PLANE_CONFORMANCE_OPEN,
|
||||
runControlPlanePortConformance,
|
||||
} from "./control-plane-port.js";
|
||||
|
||||
describe("ControlPlanePort conformance", () => {
|
||||
it("runs unchanged against the mock adapter", async () => {
|
||||
const adapter = new MockControlPlaneAdapter();
|
||||
await expect(runControlPlanePortConformance({
|
||||
port: adapter,
|
||||
start: () => adapter.start(),
|
||||
stop: () => adapter.stop(),
|
||||
})).resolves.toEqual({
|
||||
eventCount: 3,
|
||||
highestContiguousSourceSeq: 3,
|
||||
duplicateDisposition: "duplicate",
|
||||
terminalReplayIdempotent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a source identity is replayed with different bytes", async () => {
|
||||
const adapter = new MockControlPlaneAdapter();
|
||||
await adapter.start();
|
||||
await adapter.openRun(CONTROL_PLANE_CONFORMANCE_OPEN);
|
||||
await adapter.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[0]);
|
||||
await expect(adapter.appendEvent({
|
||||
...CONTROL_PLANE_CONFORMANCE_EVENTS[0],
|
||||
payload: { backend: "forged" },
|
||||
})).rejects.toThrow("native_event_replay_conflict");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
CompleteControlPlaneRunInput,
|
||||
ControlPlanePort,
|
||||
OpenControlPlaneRunInput,
|
||||
} from "../contracts/control-plane-port.js";
|
||||
import type { PrpEvent, PrpStructuredRunResult, PrpTerminalState } from "../protocol/phase1-contract.js";
|
||||
|
||||
export interface ControlPlanePortConformanceSnapshot {
|
||||
eventCount: number;
|
||||
highestContiguousSourceSeq: number;
|
||||
duplicateDisposition: "duplicate";
|
||||
terminalReplayIdempotent: boolean;
|
||||
}
|
||||
|
||||
export interface ControlPlanePortConformanceHarness {
|
||||
port: ControlPlanePort;
|
||||
start?(): Promise<void>;
|
||||
stop?(): Promise<void>;
|
||||
}
|
||||
|
||||
export const CONTROL_PLANE_CONFORMANCE_OPEN: OpenControlPlaneRunInput = {
|
||||
identity: {
|
||||
runId: "00000000-0000-4000-8000-000000000006",
|
||||
sessionId: "session-phase6-conformance",
|
||||
companyId: "00000000-0000-4000-8000-000000000001",
|
||||
issueId: "00000000-0000-4000-8000-000000000003",
|
||||
agentId: "00000000-0000-4000-8000-000000000002",
|
||||
},
|
||||
backendKind: "mock",
|
||||
sourceInstanceId: "runner-phase6-conformance",
|
||||
};
|
||||
|
||||
export const CONTROL_PLANE_CONFORMANCE_RESULT: PrpStructuredRunResult = {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "done",
|
||||
summary: "Phase 6 conformance task completed.",
|
||||
completionClaim: {
|
||||
contractRevision: "phase6-v1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: ["event:2"] }],
|
||||
remainingWork: [],
|
||||
},
|
||||
evidence: [{ kind: "event", ref: "event:2" }],
|
||||
verification: [{ commandOrCheck: "phase6-conformance", status: "passed" }],
|
||||
attentionRequests: [],
|
||||
artifacts: [],
|
||||
};
|
||||
|
||||
export const CONTROL_PLANE_CONFORMANCE_TERMINAL: PrpTerminalState = {
|
||||
schema: "paperclip.prp.terminal.v1",
|
||||
turnTerminalState: "completed",
|
||||
runTerminalState: "succeeded",
|
||||
reportedWorkDisposition: "done",
|
||||
};
|
||||
|
||||
function event(sourceSeq: number, eventType: PrpEvent["eventType"], payload: Record<string, unknown>): PrpEvent {
|
||||
return {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `runner-phase6-conformance:event:${sourceSeq}`,
|
||||
sourceSeq,
|
||||
sourceInstanceId: "runner-phase6-conformance",
|
||||
sourceKind: "runner",
|
||||
runId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.runId,
|
||||
normalizedSessionId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.sessionId,
|
||||
turnId: "turn-phase6-conformance",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: eventType === "run.terminal" ? 0 : 1,
|
||||
emittedAt: `2026-08-09T00:00:0${sourceSeq}.000Z`,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const CONTROL_PLANE_CONFORMANCE_EVENTS = [
|
||||
event(1, "session.started", { backend: "mock" }),
|
||||
event(2, "run.result.proposed", CONTROL_PLANE_CONFORMANCE_RESULT),
|
||||
event(3, "run.terminal", CONTROL_PLANE_CONFORMANCE_TERMINAL as unknown as Record<string, unknown>),
|
||||
] as const;
|
||||
|
||||
export async function runControlPlanePortConformance(
|
||||
harness: ControlPlanePortConformanceHarness,
|
||||
): Promise<ControlPlanePortConformanceSnapshot> {
|
||||
await harness.start?.();
|
||||
try {
|
||||
await harness.port.openRun(CONTROL_PLANE_CONFORMANCE_OPEN);
|
||||
const first = await harness.port.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[0]);
|
||||
if (first.disposition !== "committed" || first.highestContiguousSourceSeq !== 1) {
|
||||
throw new Error("first event did not commit at cursor 1");
|
||||
}
|
||||
const gap = await harness.port.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[2]);
|
||||
if (gap.highestContiguousSourceSeq !== 1) throw new Error("source gap was hidden");
|
||||
const recovered = await harness.port.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[1]);
|
||||
if (recovered.highestContiguousSourceSeq !== 3) throw new Error("source gap did not recover");
|
||||
const duplicate = await harness.port.appendEvent(CONTROL_PLANE_CONFORMANCE_EVENTS[1]);
|
||||
if (duplicate.disposition !== "duplicate") throw new Error("identical duplicate was not idempotent");
|
||||
|
||||
const replay = await harness.port.replayEvents({
|
||||
runId: CONTROL_PLANE_CONFORMANCE_OPEN.identity.runId,
|
||||
sourceInstanceId: "runner-phase6-conformance",
|
||||
afterSourceSeq: 1,
|
||||
limit: 10,
|
||||
});
|
||||
if (replay.events.map((entry) => entry.sourceSeq).join(",") !== "2,3") {
|
||||
throw new Error("exclusive replay cursor returned the wrong event set");
|
||||
}
|
||||
|
||||
const completion: CompleteControlPlaneRunInput = {
|
||||
result: CONTROL_PLANE_CONFORMANCE_RESULT,
|
||||
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
||||
turnId: "turn-phase6-conformance",
|
||||
callerResultId: "result-phase6-conformance",
|
||||
callerDedupeKey: "dedupe-phase6-conformance",
|
||||
};
|
||||
await harness.port.completeRun(completion);
|
||||
await harness.port.completeRun(completion);
|
||||
return {
|
||||
eventCount: replay.highestContiguousSourceSeq,
|
||||
highestContiguousSourceSeq: replay.highestContiguousSourceSeq,
|
||||
duplicateDisposition: duplicate.disposition,
|
||||
terminalReplayIdempotent: true,
|
||||
};
|
||||
} finally {
|
||||
await harness.stop?.();
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,42 @@ import type {
|
||||
NativeRunIdentity,
|
||||
NativeRunResult,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PrpEvent,
|
||||
PrpStructuredRunResult,
|
||||
PrpTerminalState,
|
||||
} from "../protocol/phase1-contract.js";
|
||||
|
||||
export interface OpenControlPlaneRunInput {
|
||||
identity: NativeRunIdentity;
|
||||
backendKind: "runner" | "remote" | "mock";
|
||||
sourceInstanceId?: string;
|
||||
}
|
||||
|
||||
export interface AppendedEventReceipt {
|
||||
cursor: number;
|
||||
highestContiguousSourceSeq: number;
|
||||
disposition: "committed" | "duplicate";
|
||||
}
|
||||
|
||||
export interface ReplayControlPlaneEventsInput {
|
||||
runId: string;
|
||||
sourceInstanceId: string;
|
||||
afterSourceSeq: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ReplayedControlPlaneEvents {
|
||||
events: PrpEvent[];
|
||||
highestContiguousSourceSeq: number;
|
||||
}
|
||||
|
||||
export interface CompleteControlPlaneRunInput {
|
||||
result: PrpStructuredRunResult;
|
||||
terminal: PrpTerminalState;
|
||||
turnId?: string | null;
|
||||
callerResultId?: string | null;
|
||||
callerDedupeKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +49,7 @@ export interface AppendedEventReceipt {
|
||||
*/
|
||||
export interface ControlPlanePort {
|
||||
openRun(input: OpenControlPlaneRunInput): Promise<void>;
|
||||
appendEvent(event: NativeRunEvent): Promise<AppendedEventReceipt>;
|
||||
completeRun(result: NativeRunResult): Promise<void>;
|
||||
appendEvent(event: NativeRunEvent | PrpEvent): Promise<AppendedEventReceipt>;
|
||||
replayEvents(input: ReplayControlPlaneEventsInput): Promise<ReplayedControlPlaneEvents>;
|
||||
completeRun(result: NativeRunResult | CompleteControlPlaneRunInput): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInputV1 } from "./native-execution.js";
|
||||
|
||||
const input: NativeExecutionInputV1 = {
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: {
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
issueId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
},
|
||||
task: { identifier: "PAP-1", title: "Safe task", description: null, workMode: "standard" },
|
||||
workspace: { cwd: "/safe/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: null, driverKind: "codex_app_server", protocolVersion: 1 },
|
||||
completionContract: {
|
||||
id: "contract-1",
|
||||
sha256: "abc123",
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: {
|
||||
revision: "1",
|
||||
objective: "Complete the safe task.",
|
||||
criteria: [{ id: "objective", requirement: "The task is complete." }],
|
||||
},
|
||||
},
|
||||
interactionResponses: [],
|
||||
credentialBindings: [{
|
||||
bindingId: "opaque-binding",
|
||||
service: "github",
|
||||
destination: "github.com",
|
||||
expiresAt: null,
|
||||
displayName: "GitHub",
|
||||
}],
|
||||
};
|
||||
|
||||
describe("NativeExecutionInputV1", () => {
|
||||
it("builds a model envelope without authority or credential bindings", () => {
|
||||
const parsed = parseNativeExecutionInput(input);
|
||||
const model = buildNativeModelEnvelope(parsed);
|
||||
const serialized = JSON.stringify(model);
|
||||
expect(serialized).not.toContain("company-1");
|
||||
expect(serialized).not.toContain("run-1");
|
||||
expect(serialized).not.toContain("opaque-binding");
|
||||
expect(model.task.title).toBe("Safe task");
|
||||
});
|
||||
|
||||
it("rejects unknown context or environment escape hatches", () => {
|
||||
expect(() => parseNativeExecutionInput({ ...input, context: { secret: "canary" } })).toThrow(
|
||||
"unknown field context",
|
||||
);
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
workspace: { ...input.workspace, env: { PAPERCLIP_API_KEY: "canary" } },
|
||||
})).toThrow("unknown field env");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { PrpStructuredRunResult, PrpTerminalState } from "../protocol/phase1-contract.js";
|
||||
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v1" as const;
|
||||
export const NATIVE_MODEL_ENVELOPE_SCHEMA = "paperclip.native-model-envelope.v1" as const;
|
||||
|
||||
export interface StrictCompletionContractInput {
|
||||
revision: string;
|
||||
objective: string;
|
||||
criteria: Array<{ id: string; requirement: string }>;
|
||||
}
|
||||
|
||||
export interface NativeInteractionResponseEnvelope {
|
||||
interactionId: string;
|
||||
kind: "request_confirmation" | "ask_user_questions";
|
||||
response: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NativeCredentialBindingRef {
|
||||
bindingId: string;
|
||||
service: string;
|
||||
destination: string;
|
||||
expiresAt: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export interface NativeExecutionInputV1 {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
binding: {
|
||||
companyId: string;
|
||||
runId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
executionWorkspaceId: string;
|
||||
};
|
||||
task: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
workMode: "standard";
|
||||
};
|
||||
workspace: {
|
||||
cwd: string;
|
||||
repoUrl: string | null;
|
||||
repoRef: string | null;
|
||||
branchName: string | null;
|
||||
};
|
||||
session: {
|
||||
normalizedSessionId: string | null;
|
||||
driverKind: "codex_app_server";
|
||||
protocolVersion: 1;
|
||||
};
|
||||
completionContract: {
|
||||
id: string;
|
||||
sha256: string;
|
||||
schemaVersion: string;
|
||||
contract: StrictCompletionContractInput;
|
||||
};
|
||||
interactionResponses: NativeInteractionResponseEnvelope[];
|
||||
credentialBindings: NativeCredentialBindingRef[];
|
||||
}
|
||||
|
||||
/** The only task data that may enter provider-visible model input. */
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA;
|
||||
task: NativeExecutionInputV1["task"];
|
||||
workspace: Pick<NativeExecutionInputV1["workspace"], "cwd">;
|
||||
completionContract: StrictCompletionContractInput;
|
||||
interactionResponses: NativeInteractionResponseEnvelope[];
|
||||
}
|
||||
|
||||
export interface NativeSessionExecutionResult {
|
||||
result: PrpStructuredRunResult;
|
||||
terminal: PrpTerminalState;
|
||||
turnId: string | null;
|
||||
normalizedSessionId: string;
|
||||
providerSessionId: string | null;
|
||||
driverKind: string;
|
||||
driverVersion: string;
|
||||
nativeEventCount: number;
|
||||
highestContiguousSourceSeq: number;
|
||||
usage: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class NativeExecutionInputError extends Error {
|
||||
readonly code = "native_execution_input_invalid" as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NativeExecutionInputError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new NativeExecutionInputError(`${path} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], path: string): void {
|
||||
const allowed = new Set(keys);
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new NativeExecutionInputError(`${path} contains unknown field ${unknown[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new NativeExecutionInputError(`${path} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, path: string): string | null {
|
||||
return value === null ? null : text(value, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly validates the closed Paperclip-to-runner launch contract. This is
|
||||
* intentionally not an extensible metadata bag: new fields require a contract
|
||||
* revision and an explicit security review.
|
||||
*/
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV1 {
|
||||
const input = record(value, "input");
|
||||
exactKeys(input, [
|
||||
"schema",
|
||||
"binding",
|
||||
"task",
|
||||
"workspace",
|
||||
"session",
|
||||
"completionContract",
|
||||
"interactionResponses",
|
||||
"credentialBindings",
|
||||
], "input");
|
||||
if (input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA) {
|
||||
throw new NativeExecutionInputError(`input.schema must be ${NATIVE_EXECUTION_INPUT_SCHEMA}`);
|
||||
}
|
||||
|
||||
const binding = record(input.binding, "input.binding");
|
||||
exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "executionWorkspaceId"], "input.binding");
|
||||
const task = record(input.task, "input.task");
|
||||
exactKeys(task, ["identifier", "title", "description", "workMode"], "input.task");
|
||||
const workspace = record(input.workspace, "input.workspace");
|
||||
exactKeys(workspace, ["cwd", "repoUrl", "repoRef", "branchName"], "input.workspace");
|
||||
const session = record(input.session, "input.session");
|
||||
exactKeys(session, ["normalizedSessionId", "driverKind", "protocolVersion"], "input.session");
|
||||
const completionContract = record(input.completionContract, "input.completionContract");
|
||||
exactKeys(completionContract, ["id", "sha256", "schemaVersion", "contract"], "input.completionContract");
|
||||
const contract = record(completionContract.contract, "input.completionContract.contract");
|
||||
exactKeys(contract, ["revision", "objective", "criteria"], "input.completionContract.contract");
|
||||
|
||||
if (task.workMode !== "standard") throw new NativeExecutionInputError("input.task.workMode must be standard");
|
||||
if (session.driverKind !== "codex_app_server" || session.protocolVersion !== 1) {
|
||||
throw new NativeExecutionInputError("input.session must select codex_app_server protocol version 1");
|
||||
}
|
||||
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
||||
throw new NativeExecutionInputError("input.completionContract.contract.criteria must not be empty");
|
||||
}
|
||||
const criteria = contract.criteria.map((entry, index) => {
|
||||
const criterion = record(entry, `input.completionContract.contract.criteria[${index}]`);
|
||||
exactKeys(criterion, ["id", "requirement"], `input.completionContract.contract.criteria[${index}]`);
|
||||
return { id: text(criterion.id, `criteria[${index}].id`), requirement: text(criterion.requirement, `criteria[${index}].requirement`) };
|
||||
});
|
||||
|
||||
if (!Array.isArray(input.interactionResponses) || !Array.isArray(input.credentialBindings)) {
|
||||
throw new NativeExecutionInputError("interactionResponses and credentialBindings must be arrays");
|
||||
}
|
||||
const interactionResponses = input.interactionResponses.map((entry, index) => {
|
||||
const response = record(entry, `input.interactionResponses[${index}]`);
|
||||
exactKeys(response, ["interactionId", "kind", "response"], `input.interactionResponses[${index}]`);
|
||||
if (response.kind !== "request_confirmation" && response.kind !== "ask_user_questions") {
|
||||
throw new NativeExecutionInputError(`input.interactionResponses[${index}].kind is unsupported`);
|
||||
}
|
||||
const kind: NativeInteractionResponseEnvelope["kind"] = response.kind;
|
||||
return {
|
||||
interactionId: text(response.interactionId, `input.interactionResponses[${index}].interactionId`),
|
||||
kind,
|
||||
response: structuredClone(record(response.response, `input.interactionResponses[${index}].response`)),
|
||||
};
|
||||
});
|
||||
const credentialBindings = input.credentialBindings.map((entry, index) => {
|
||||
const bindingRef = record(entry, `input.credentialBindings[${index}]`);
|
||||
exactKeys(bindingRef, ["bindingId", "service", "destination", "expiresAt", "displayName"], `input.credentialBindings[${index}]`);
|
||||
return {
|
||||
bindingId: text(bindingRef.bindingId, `credentialBindings[${index}].bindingId`),
|
||||
service: text(bindingRef.service, `credentialBindings[${index}].service`),
|
||||
destination: text(bindingRef.destination, `credentialBindings[${index}].destination`),
|
||||
expiresAt: nullableText(bindingRef.expiresAt, `credentialBindings[${index}].expiresAt`),
|
||||
displayName: nullableText(bindingRef.displayName, `credentialBindings[${index}].displayName`),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
|
||||
binding: {
|
||||
companyId: text(binding.companyId, "input.binding.companyId"),
|
||||
runId: text(binding.runId, "input.binding.runId"),
|
||||
issueId: text(binding.issueId, "input.binding.issueId"),
|
||||
agentId: text(binding.agentId, "input.binding.agentId"),
|
||||
executionWorkspaceId: text(binding.executionWorkspaceId, "input.binding.executionWorkspaceId"),
|
||||
},
|
||||
task: {
|
||||
identifier: text(task.identifier, "input.task.identifier"),
|
||||
title: text(task.title, "input.task.title"),
|
||||
description: nullableText(task.description, "input.task.description"),
|
||||
workMode: "standard",
|
||||
},
|
||||
workspace: {
|
||||
cwd: text(workspace.cwd, "input.workspace.cwd"),
|
||||
repoUrl: nullableText(workspace.repoUrl, "input.workspace.repoUrl"),
|
||||
repoRef: nullableText(workspace.repoRef, "input.workspace.repoRef"),
|
||||
branchName: nullableText(workspace.branchName, "input.workspace.branchName"),
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: nullableText(session.normalizedSessionId, "input.session.normalizedSessionId"),
|
||||
driverKind: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
completionContract: {
|
||||
id: text(completionContract.id, "input.completionContract.id"),
|
||||
sha256: text(completionContract.sha256, "input.completionContract.sha256"),
|
||||
schemaVersion: text(completionContract.schemaVersion, "input.completionContract.schemaVersion"),
|
||||
contract: {
|
||||
revision: text(contract.revision, "input.completionContract.contract.revision"),
|
||||
objective: text(contract.objective, "input.completionContract.contract.objective"),
|
||||
criteria,
|
||||
},
|
||||
},
|
||||
interactionResponses,
|
||||
credentialBindings,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNativeModelEnvelope(input: NativeExecutionInputV1): NativeModelEnvelopeV1 {
|
||||
return {
|
||||
schema: NATIVE_MODEL_ENVELOPE_SCHEMA,
|
||||
task: structuredClone(input.task),
|
||||
workspace: { cwd: input.workspace.cwd },
|
||||
completionContract: structuredClone(input.completionContract.contract),
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type {
|
||||
NativeRunEvent,
|
||||
NativeRunIdentity,
|
||||
NativeRunResult,
|
||||
NativeSessionCapabilities,
|
||||
NativeUserMessage,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PrpEvent,
|
||||
PrpStructuredRunResult,
|
||||
PrpTerminalState,
|
||||
} from "../protocol/phase1-contract.js";
|
||||
|
||||
export interface NativeSessionBackendDescriptor {
|
||||
kind: "runner" | "remote" | "mock";
|
||||
@@ -21,8 +24,11 @@ export interface OpenNativeSessionInput {
|
||||
export interface PersistedNativeSession {
|
||||
backendKind: NativeSessionBackendDescriptor["kind"];
|
||||
sessionId: string;
|
||||
identity: NativeRunIdentity;
|
||||
providerSessionId?: string | null;
|
||||
cursor?: string | null;
|
||||
semanticResult?: PrpStructuredRunResult | null;
|
||||
terminal?: PrpTerminalState | null;
|
||||
}
|
||||
|
||||
export interface NativeSessionRecoveryResult {
|
||||
@@ -34,12 +40,16 @@ export interface NativeSessionRecoveryResult {
|
||||
export interface NativeSession {
|
||||
identity(): NativeRunIdentity;
|
||||
capabilities(): Promise<NativeSessionCapabilities>;
|
||||
events(input?: { afterCursor?: string | null }): AsyncIterable<NativeRunEvent>;
|
||||
events(input?: { afterCursor?: string | null }): AsyncIterable<PrpEvent>;
|
||||
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
|
||||
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
|
||||
interrupt?(input: { turnId?: string; reason?: string }): Promise<void>;
|
||||
cancel?(input: { reason: string }): Promise<void>;
|
||||
result(): Promise<NativeRunResult | null>;
|
||||
result(): Promise<{
|
||||
result: PrpStructuredRunResult;
|
||||
terminal: PrpTerminalState;
|
||||
turnId: string | null;
|
||||
} | null>;
|
||||
snapshot(): Promise<PersistedNativeSession>;
|
||||
close(input: { reason: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
export * from "./contracts/control-plane-port.js";
|
||||
export * from "./contracts/harness-driver.js";
|
||||
export * from "./contracts/native-session-backend.js";
|
||||
export * from "./contracts/native-execution.js";
|
||||
export * from "./contracts/phase2.js";
|
||||
export * from "./contracts/phase3.js";
|
||||
export * from "./contracts/phase4.js";
|
||||
export * from "./contracts/types.js";
|
||||
export * from "./backends/harness-driver-backend.js";
|
||||
export * from "./backends/codex-native-backend.js";
|
||||
export * from "./conformance/control-plane-port.js";
|
||||
export * from "./native-session-runtime.js";
|
||||
export * from "./drivers/codex/app-server-transport.js";
|
||||
export * from "./drivers/codex/codex-app-server-driver.js";
|
||||
export * from "./mock-core/mock-control-plane-adapter.js";
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import type {
|
||||
AppendedEventReceipt,
|
||||
CompleteControlPlaneRunInput,
|
||||
ControlPlanePort,
|
||||
OpenControlPlaneRunInput,
|
||||
ReplayControlPlaneEventsInput,
|
||||
ReplayedControlPlaneEvents,
|
||||
} from "../contracts/control-plane-port.js";
|
||||
import type { NativeRunEvent, NativeRunResult } from "../contracts/types.js";
|
||||
import {
|
||||
validatePrpEvent,
|
||||
validatePrpStructuredRunResult,
|
||||
type PrpEvent,
|
||||
} from "../protocol/phase1-contract.js";
|
||||
|
||||
export interface MockControlPlaneSnapshot {
|
||||
lifecycle: "stopped" | "running";
|
||||
openedRun: OpenControlPlaneRunInput | null;
|
||||
events: NativeRunEvent[];
|
||||
result: NativeRunResult | null;
|
||||
events: Array<NativeRunEvent | PrpEvent>;
|
||||
result: NativeRunResult | CompleteControlPlaneRunInput | null;
|
||||
}
|
||||
|
||||
/** In-memory control-plane shell used by standalone tests and tutorials. */
|
||||
export class MockControlPlaneAdapter implements ControlPlanePort {
|
||||
#lifecycle: "stopped" | "running" = "stopped";
|
||||
#openedRun: OpenControlPlaneRunInput | null = null;
|
||||
#events: NativeRunEvent[] = [];
|
||||
#result: NativeRunResult | null = null;
|
||||
#events: Array<NativeRunEvent | PrpEvent> = [];
|
||||
#result: NativeRunResult | CompleteControlPlaneRunInput | null = null;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.#lifecycle === "running") {
|
||||
@@ -38,29 +46,104 @@ export class MockControlPlaneAdapter implements ControlPlanePort {
|
||||
this.#openedRun = structuredClone(input);
|
||||
}
|
||||
|
||||
async appendEvent(event: NativeRunEvent): Promise<AppendedEventReceipt> {
|
||||
async appendEvent(event: NativeRunEvent | PrpEvent): Promise<AppendedEventReceipt> {
|
||||
const runId = this.#requireRunId();
|
||||
if (event.runId !== runId) {
|
||||
throw new Error("event runId does not match the opened run");
|
||||
}
|
||||
if ("sourceEventId" in event) {
|
||||
const validation = validatePrpEvent(event);
|
||||
if (!validation.ok) {
|
||||
throw new Error(`event failed validation: ${validation.issues[0]?.message ?? "invalid event"}`);
|
||||
}
|
||||
const sameId = this.#events.find(
|
||||
(candidate): candidate is PrpEvent =>
|
||||
"sourceEventId" in candidate && candidate.sourceEventId === event.sourceEventId,
|
||||
);
|
||||
const sameSequence = this.#events.find(
|
||||
(candidate): candidate is PrpEvent =>
|
||||
"sourceSeq" in candidate &&
|
||||
candidate.sourceInstanceId === event.sourceInstanceId &&
|
||||
candidate.sourceSeq === event.sourceSeq,
|
||||
);
|
||||
for (const candidate of [sameId, sameSequence]) {
|
||||
if (candidate !== undefined) {
|
||||
if (canonicalJson(candidate) !== canonicalJson(event)) {
|
||||
throw new Error("native_event_replay_conflict");
|
||||
}
|
||||
return {
|
||||
cursor: event.sourceSeq,
|
||||
highestContiguousSourceSeq: this.#highestContiguous(event.sourceInstanceId),
|
||||
disposition: "duplicate",
|
||||
};
|
||||
}
|
||||
}
|
||||
this.#events.push(structuredClone(event));
|
||||
return {
|
||||
cursor: event.sourceSeq,
|
||||
highestContiguousSourceSeq: this.#highestContiguous(event.sourceInstanceId),
|
||||
disposition: "committed",
|
||||
};
|
||||
}
|
||||
|
||||
const expectedSequence = this.#events.length + 1;
|
||||
if (event.sequence !== expectedSequence) {
|
||||
throw new Error(`event sequence must be ${expectedSequence}`);
|
||||
}
|
||||
this.#events.push(structuredClone(event));
|
||||
return { cursor: event.sequence };
|
||||
return {
|
||||
cursor: event.sequence,
|
||||
highestContiguousSourceSeq: event.sequence,
|
||||
disposition: "committed",
|
||||
};
|
||||
}
|
||||
|
||||
async completeRun(result: NativeRunResult): Promise<void> {
|
||||
async replayEvents(
|
||||
input: ReplayControlPlaneEventsInput,
|
||||
): Promise<ReplayedControlPlaneEvents> {
|
||||
const runId = this.#requireRunId();
|
||||
if (result.runId !== runId) {
|
||||
if (input.runId !== runId) {
|
||||
throw new Error("replay runId does not match the opened run");
|
||||
}
|
||||
const events = this.#events
|
||||
.filter(
|
||||
(event): event is PrpEvent =>
|
||||
"sourceSeq" in event &&
|
||||
event.sourceInstanceId === input.sourceInstanceId &&
|
||||
event.sourceSeq > input.afterSourceSeq,
|
||||
)
|
||||
.sort((left, right) => left.sourceSeq - right.sourceSeq)
|
||||
.slice(0, input.limit)
|
||||
.map((event) => structuredClone(event));
|
||||
return {
|
||||
events,
|
||||
highestContiguousSourceSeq: this.#highestContiguous(input.sourceInstanceId),
|
||||
};
|
||||
}
|
||||
|
||||
async completeRun(result: NativeRunResult | CompleteControlPlaneRunInput): Promise<void> {
|
||||
const runId = this.#requireRunId();
|
||||
const resultRunId = "runId" in result ? result.runId : runId;
|
||||
if (resultRunId !== runId) {
|
||||
throw new Error("result runId does not match the opened run");
|
||||
}
|
||||
if (this.#events.at(-1)?.type !== "run.completed") {
|
||||
if ("terminal" in result) {
|
||||
const validation = validatePrpStructuredRunResult(result.result);
|
||||
if (!validation.ok) {
|
||||
throw new Error("native_result_invalid");
|
||||
}
|
||||
const terminalEvent = this.#events.find(
|
||||
(event) => "eventType" in event && event.eventType === "run.terminal",
|
||||
);
|
||||
if (terminalEvent === undefined) {
|
||||
throw new Error("run.terminal must be ingested before the result");
|
||||
}
|
||||
} else if (this.#events.at(-1)?.type !== "run.completed") {
|
||||
throw new Error("run.completed must be ingested before the result");
|
||||
}
|
||||
if (this.#result !== null) {
|
||||
throw new Error("mock control plane accepts one terminal result");
|
||||
if (canonicalJson(this.#result) === canonicalJson(result)) return;
|
||||
throw new Error("native_result_replay_conflict");
|
||||
}
|
||||
this.#result = structuredClone(result);
|
||||
}
|
||||
@@ -87,4 +170,30 @@ export class MockControlPlaneAdapter implements ControlPlanePort {
|
||||
}
|
||||
return this.#openedRun.identity.runId;
|
||||
}
|
||||
|
||||
#highestContiguous(sourceInstanceId: string): number {
|
||||
const sequences = new Set(
|
||||
this.#events
|
||||
.filter(
|
||||
(event): event is PrpEvent =>
|
||||
"sourceSeq" in event && event.sourceInstanceId === sourceInstanceId,
|
||||
)
|
||||
.map((event) => event.sourceSeq),
|
||||
);
|
||||
let cursor = 0;
|
||||
while (sequences.has(cursor + 1)) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { ControlPlanePort } from "./contracts/control-plane-port.js";
|
||||
import type { NativeExecutionInputV1, NativeSessionExecutionResult } from "./contracts/native-execution.js";
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput } from "./contracts/native-execution.js";
|
||||
import type { NativeSession, NativeSessionBackend } from "./contracts/native-session-backend.js";
|
||||
import type { PrpEvent, PrpTerminalState } from "./protocol/phase1-contract.js";
|
||||
|
||||
export interface ExecuteNativeSessionOptions {
|
||||
input: NativeExecutionInputV1;
|
||||
backend: NativeSessionBackend;
|
||||
controlPlane: ControlPlanePort;
|
||||
runnerInstanceId: string;
|
||||
controlPlaneInstanceId: string;
|
||||
timeoutMs?: number;
|
||||
onSession?: (session: NativeSession | null) => void;
|
||||
}
|
||||
|
||||
function isTurnTerminal(event: PrpEvent): boolean {
|
||||
return ["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType);
|
||||
}
|
||||
|
||||
function terminalFromEvent(event: PrpEvent, disposition: PrpTerminalState["reportedWorkDisposition"]): PrpTerminalState {
|
||||
const states = event.eventType === "turn.completed"
|
||||
? { turnTerminalState: "completed" as const, runTerminalState: "succeeded" as const }
|
||||
: event.eventType === "turn.failed"
|
||||
? { turnTerminalState: "failed" as const, runTerminalState: "failed" as const }
|
||||
: event.eventType === "turn.interrupted"
|
||||
? { turnTerminalState: "interrupted" as const, runTerminalState: "cancelled" as const }
|
||||
: { turnTerminalState: "cancelled" as const, runTerminalState: "cancelled" as const };
|
||||
return { schema: "paperclip.prp.terminal.v1", ...states, reportedWorkDisposition: disposition };
|
||||
}
|
||||
|
||||
async function consumeTurn(session: NativeSession, controlPlane: ControlPlanePort, timeoutMs: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
(async () => {
|
||||
let eventCount = 0;
|
||||
let highestContiguousSourceSeq = 0;
|
||||
for await (const event of session.events()) {
|
||||
const receipt = await controlPlane.appendEvent(event);
|
||||
eventCount += receipt.disposition === "committed" ? 1 : 0;
|
||||
highestContiguousSourceSeq = Math.max(highestContiguousSourceSeq, receipt.highestContiguousSourceSeq);
|
||||
if (isTurnTerminal(event)) return { event, eventCount, highestContiguousSourceSeq };
|
||||
}
|
||||
throw new Error("native event stream closed before a turn terminal fact");
|
||||
})(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`native session timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-owned normalized session loop. Paperclip supplies persistence and
|
||||
* authority through ControlPlanePort; provider/session behavior stays here.
|
||||
*/
|
||||
export async function executeNativeSession(options: ExecuteNativeSessionOptions): Promise<NativeSessionExecutionResult> {
|
||||
const input = parseNativeExecutionInput(options.input);
|
||||
const descriptor = await options.backend.descriptor();
|
||||
const normalizedSessionId = input.session.normalizedSessionId ?? randomUUID();
|
||||
await options.controlPlane.openRun({
|
||||
identity: {
|
||||
runId: input.binding.runId,
|
||||
sessionId: normalizedSessionId,
|
||||
companyId: input.binding.companyId,
|
||||
issueId: input.binding.issueId,
|
||||
agentId: input.binding.agentId,
|
||||
},
|
||||
backendKind: descriptor.kind,
|
||||
sourceInstanceId: options.runnerInstanceId,
|
||||
});
|
||||
|
||||
const session = await options.backend.openSession({
|
||||
identity: {
|
||||
runId: input.binding.runId,
|
||||
sessionId: normalizedSessionId,
|
||||
companyId: input.binding.companyId,
|
||||
issueId: input.binding.issueId,
|
||||
agentId: input.binding.agentId,
|
||||
},
|
||||
workingDirectory: input.workspace.cwd,
|
||||
});
|
||||
options.onSession?.(session);
|
||||
try {
|
||||
const consuming = consumeTurn(session, options.controlPlane, options.timeoutMs ?? 900_000);
|
||||
await session.startTurn({
|
||||
message: { role: "user", text: JSON.stringify(buildNativeModelEnvelope(input)) },
|
||||
});
|
||||
const consumed = await consuming;
|
||||
const completed = await session.result();
|
||||
if (completed === null) throw new Error("native_finalization_missing: session returned no semantic result");
|
||||
const terminal = terminalFromEvent(consumed.event, completed.result.reportedWorkDisposition);
|
||||
let controlSeq = 0;
|
||||
const controlEvent = (eventType: PrpEvent["eventType"], payload: Record<string, unknown>): PrpEvent => ({
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `${options.controlPlaneInstanceId}:${input.binding.runId}:${++controlSeq}`,
|
||||
sourceSeq: controlSeq,
|
||||
sourceInstanceId: options.controlPlaneInstanceId,
|
||||
sourceKind: "control_plane",
|
||||
runId: input.binding.runId,
|
||||
normalizedSessionId,
|
||||
turnId: completed.turnId ?? consumed.event.turnId,
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 0,
|
||||
emittedAt: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
for (const event of [
|
||||
controlEvent("run.result.accepted", { result: completed.result }),
|
||||
controlEvent("run.terminal", terminal as unknown as Record<string, unknown>),
|
||||
]) {
|
||||
const receipt = await options.controlPlane.appendEvent(event);
|
||||
consumed.eventCount += receipt.disposition === "committed" ? 1 : 0;
|
||||
consumed.highestContiguousSourceSeq = Math.max(consumed.highestContiguousSourceSeq, receipt.highestContiguousSourceSeq);
|
||||
}
|
||||
await options.controlPlane.completeRun({
|
||||
result: completed.result,
|
||||
terminal,
|
||||
turnId: completed.turnId,
|
||||
callerResultId: `${options.runnerInstanceId}:${input.binding.runId}:result`,
|
||||
callerDedupeKey: `${input.binding.runId}:${input.completionContract.sha256}`,
|
||||
});
|
||||
const snapshot = await session.snapshot();
|
||||
return {
|
||||
result: completed.result,
|
||||
terminal,
|
||||
turnId: completed.turnId,
|
||||
normalizedSessionId,
|
||||
providerSessionId: snapshot.providerSessionId ?? null,
|
||||
driverKind: descriptor.name,
|
||||
driverVersion: descriptor.version,
|
||||
nativeEventCount: consumed.eventCount,
|
||||
highestContiguousSourceSeq: consumed.highestContiguousSourceSeq,
|
||||
usage: null,
|
||||
};
|
||||
} finally {
|
||||
options.onSession?.(null);
|
||||
await session.close({ reason: "native session execution complete" });
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,14 @@ export interface FeatureCatalogEntry {
|
||||
}
|
||||
|
||||
export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalogEntry> = {
|
||||
enableNativeRunner: {
|
||||
title: "Native Runner",
|
||||
description:
|
||||
"Allow explicitly opted-in local Codex agents to use the Phase 6 native session contract. Disable this kill switch to keep all new runs on legacy execution.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableEnvironments: {
|
||||
title: "Environments",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
nativeFinalizationResultV1Schema,
|
||||
nativeReportedWorkDispositionSchema,
|
||||
type NativeFinalizationResultInput,
|
||||
} from "./validators/native-finalization.js";
|
||||
export {
|
||||
NATIVE_FINALIZATION_SCHEMA,
|
||||
type NativeFinalizationResult,
|
||||
type NativeFinalizationResultV1,
|
||||
type NativeReportedWorkDisposition,
|
||||
type NativeRuntimeMode,
|
||||
type NativeRunTerminalState,
|
||||
} from "./types/native-finalization.js";
|
||||
export {
|
||||
decisionEffectStalenessSchema,
|
||||
decisionOptionStyleSchema,
|
||||
@@ -1518,7 +1532,6 @@ export {
|
||||
} from "./types/smoke-lab.js";
|
||||
|
||||
export type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "./types/server-info.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export { decisionEffectTargetIssueIds } from "./decision.js";
|
||||
export {
|
||||
NATIVE_FINALIZATION_SCHEMA,
|
||||
type NativeFinalizationResult,
|
||||
type NativeReportedWorkDisposition,
|
||||
type NativeRunTerminalState,
|
||||
} from "./native-finalization.js";
|
||||
export type {
|
||||
Company,
|
||||
InteractionResolverGovernance,
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface InstanceGeneralSettings {
|
||||
}
|
||||
|
||||
export interface InstanceExperimentalSettings {
|
||||
/** Default-off Phase 6 native runner tracer and its instance kill switch. */
|
||||
enableNativeRunner: boolean;
|
||||
enableEnvironments: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export type NativeRuntimeMode = "legacy" | "native";
|
||||
export const NATIVE_FINALIZATION_SCHEMA = "paperclip.native-finalization.v1" as const;
|
||||
export type NativeReportedWorkDisposition = "done" | "blocked" | "needs_review" | "yielded";
|
||||
export type NativeRunTerminalState = "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export interface NativeFinalizationResultV1 {
|
||||
schema: typeof NATIVE_FINALIZATION_SCHEMA;
|
||||
runtimeMode: "native";
|
||||
runId: string;
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
result: Record<string, unknown>;
|
||||
terminal: {
|
||||
schema: "paperclip.prp.terminal.v1";
|
||||
turnTerminalState: "completed" | "failed" | "interrupted" | "cancelled";
|
||||
runTerminalState: NativeRunTerminalState;
|
||||
reportedWorkDisposition: NativeReportedWorkDisposition;
|
||||
};
|
||||
turnId: string | null;
|
||||
sourceInstanceId: string;
|
||||
normalizedSessionId: string;
|
||||
providerSessionId: string | null;
|
||||
driverKind: string;
|
||||
driverVersion: string;
|
||||
nativeEventCount: number;
|
||||
highestContiguousSourceSeq: number;
|
||||
workspaceFinalizeStatus: "pending" | "succeeded" | "failed";
|
||||
}
|
||||
|
||||
export type NativeFinalizationResult = NativeFinalizationResultV1;
|
||||
@@ -1,3 +1,9 @@
|
||||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
nativeReportedWorkDispositionSchema,
|
||||
type NativeFinalizationResultInput,
|
||||
} from "./native-finalization.js";
|
||||
|
||||
export {
|
||||
decisionEffectStalenessSchema,
|
||||
decisionOptionStyleSchema,
|
||||
|
||||
@@ -39,6 +39,7 @@ export const instanceGeneralSettingsSchema = z.object({
|
||||
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
||||
|
||||
export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableNativeRunner: z.boolean().default(false),
|
||||
enableEnvironments: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(true),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { NATIVE_FINALIZATION_SCHEMA } from "../types/native-finalization.js";
|
||||
|
||||
export const nativeFinalizationResultV1Schema = z.object({
|
||||
schema: z.literal(NATIVE_FINALIZATION_SCHEMA),
|
||||
runtimeMode: z.literal("native"),
|
||||
runId: z.string().uuid(),
|
||||
issueId: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
result: z.record(z.unknown()),
|
||||
terminal: z.object({
|
||||
schema: z.literal("paperclip.prp.terminal.v1"),
|
||||
turnTerminalState: z.enum(["completed", "failed", "interrupted", "cancelled"]),
|
||||
runTerminalState: z.enum(["succeeded", "failed", "cancelled"]),
|
||||
reportedWorkDisposition: z.enum(["done", "blocked", "needs_review", "yielded"]),
|
||||
}).strict(),
|
||||
turnId: z.string().min(1).nullable(),
|
||||
sourceInstanceId: z.string().min(1).max(160),
|
||||
normalizedSessionId: z.string().min(1),
|
||||
providerSessionId: z.string().min(1).nullable(),
|
||||
driverKind: z.string().min(1),
|
||||
driverVersion: z.string().min(1),
|
||||
nativeEventCount: z.number().int().nonnegative(),
|
||||
highestContiguousSourceSeq: z.number().int().nonnegative(),
|
||||
workspaceFinalizeStatus: z.enum(["pending", "succeeded", "failed"]),
|
||||
}).strict();
|
||||
|
||||
export const nativeReportedWorkDispositionSchema = z.enum([
|
||||
"done",
|
||||
"blocked",
|
||||
"needs_review",
|
||||
"yielded",
|
||||
]);
|
||||
export const nativeFinalizationResultSchema = nativeFinalizationResultV1Schema;
|
||||
export type NativeFinalizationResultInput = z.infer<typeof nativeFinalizationResultSchema>;
|
||||
Generated
+6
@@ -122,6 +122,9 @@ importers:
|
||||
|
||||
packages/adapter-utils:
|
||||
dependencies:
|
||||
'@paperclipai/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
acpx:
|
||||
specifier: 0.12.0
|
||||
version: 0.12.0(patch_hash=x3fethhotv43zektyl5prdwf54)
|
||||
@@ -803,6 +806,9 @@ importers:
|
||||
'@paperclipai/hermes-paperclip-adapter':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/hermes
|
||||
'@paperclipai/paperclip-runner':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/paperclip-runner
|
||||
'@paperclipai/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugins/sdk
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/paperclip-runner": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@paperclipai/skills-catalog": "workspace:*",
|
||||
"@paperclipai/hermes-paperclip-adapter": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { and, asc, eq, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { heartbeatRunEvents, heartbeatRuns } from "@paperclipai/db";
|
||||
import { nativeSha256 } from "./native-runtime/canonical.js";
|
||||
|
||||
export interface AppendHeartbeatRunEventInput {
|
||||
companyId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
eventType: string;
|
||||
stream?: string | null;
|
||||
level?: string | null;
|
||||
color?: string | null;
|
||||
message?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
nativeSource?: {
|
||||
sourceInstanceId: string;
|
||||
sourceEventId: string;
|
||||
sourceSeq: number;
|
||||
protocolSchemaVersion: number;
|
||||
canonicalPayload: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppendHeartbeatRunEventResult {
|
||||
row: typeof heartbeatRunEvents.$inferSelect;
|
||||
disposition: "committed" | "duplicate";
|
||||
highestContiguousSourceSeq: number;
|
||||
}
|
||||
|
||||
export class HeartbeatRunEventConflictError extends Error {
|
||||
readonly code = "native_event_replay_conflict" as const;
|
||||
constructor() {
|
||||
super("native_event_replay_conflict");
|
||||
this.name = "HeartbeatRunEventConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendHeartbeatRunEvent(
|
||||
db: Db,
|
||||
input: AppendHeartbeatRunEventInput,
|
||||
): Promise<AppendHeartbeatRunEventResult> {
|
||||
return db.transaction(async (tx) => {
|
||||
const run = await tx
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, input.runId))
|
||||
.for("update")
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run || run.companyId !== input.companyId || run.agentId !== input.agentId) {
|
||||
throw new Error("heartbeat_run_event_binding_mismatch");
|
||||
}
|
||||
|
||||
const sourceHash = input.nativeSource
|
||||
? nativeSha256(input.nativeSource.canonicalPayload)
|
||||
: null;
|
||||
if (input.nativeSource) {
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(
|
||||
eq(heartbeatRunEvents.runId, input.runId),
|
||||
or(
|
||||
eq(heartbeatRunEvents.sourceEventId, input.nativeSource.sourceEventId),
|
||||
and(
|
||||
eq(heartbeatRunEvents.sourceInstanceId, input.nativeSource.sourceInstanceId),
|
||||
eq(heartbeatRunEvents.sourceSeq, input.nativeSource.sourceSeq),
|
||||
),
|
||||
),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) {
|
||||
if (existing.sourcePayloadSha256 !== sourceHash) {
|
||||
throw new HeartbeatRunEventConflictError();
|
||||
}
|
||||
return {
|
||||
row: existing,
|
||||
disposition: "duplicate" as const,
|
||||
highestContiguousSourceSeq: await contiguousCursor(
|
||||
tx as unknown as Db,
|
||||
input.runId,
|
||||
input.nativeSource.sourceInstanceId,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const seq = Number(run.nextEventSeq ?? 1);
|
||||
const [row] = await tx.insert(heartbeatRunEvents).values({
|
||||
companyId: input.companyId,
|
||||
runId: input.runId,
|
||||
agentId: input.agentId,
|
||||
seq,
|
||||
eventType: input.eventType,
|
||||
stream: input.stream ?? null,
|
||||
level: input.level ?? null,
|
||||
color: input.color ?? null,
|
||||
message: input.message ?? null,
|
||||
payload: input.payload ?? null,
|
||||
sourceInstanceId: input.nativeSource?.sourceInstanceId ?? null,
|
||||
sourceEventId: input.nativeSource?.sourceEventId ?? null,
|
||||
sourceSeq: input.nativeSource?.sourceSeq ?? null,
|
||||
sourcePayloadSha256: sourceHash,
|
||||
protocolSchemaVersion: input.nativeSource?.protocolSchemaVersion ?? null,
|
||||
}).returning();
|
||||
if (!row) throw new Error("heartbeat_run_event_not_persisted");
|
||||
await tx.update(heartbeatRuns).set({
|
||||
nextEventSeq: seq + 1,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, input.runId));
|
||||
return {
|
||||
row,
|
||||
disposition: "committed" as const,
|
||||
highestContiguousSourceSeq: input.nativeSource
|
||||
? await contiguousCursor(
|
||||
tx as unknown as Db,
|
||||
input.runId,
|
||||
input.nativeSource.sourceInstanceId,
|
||||
)
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function contiguousCursor(db: Db, runId: string, sourceInstanceId: string): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ sourceSeq: heartbeatRunEvents.sourceSeq })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(
|
||||
eq(heartbeatRunEvents.runId, runId),
|
||||
eq(heartbeatRunEvents.sourceInstanceId, sourceInstanceId),
|
||||
))
|
||||
.orderBy(asc(heartbeatRunEvents.sourceSeq));
|
||||
let cursor = 0;
|
||||
for (const row of rows) {
|
||||
if (row.sourceSeq === cursor + 1) cursor += 1;
|
||||
else if (row.sourceSeq !== null && row.sourceSeq > cursor + 1) break;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
companySkillVersions,
|
||||
companySkills as companySkillsTable,
|
||||
companies,
|
||||
completionContracts,
|
||||
costEvents,
|
||||
documentAnnotationComments,
|
||||
documentAnnotationThreads,
|
||||
@@ -80,6 +81,16 @@ import {
|
||||
// git-credentials module became its canonical home; existing importers keep working.
|
||||
export { scrubGitCredentialText };
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import {
|
||||
buildNativeExecutionInput,
|
||||
cancelNativeSession,
|
||||
ensureNativeCompletionContract,
|
||||
executePaperclipNativeSession,
|
||||
finalizeNativeRun,
|
||||
reconcileNativeFinalizations,
|
||||
resolveNativeRuntimeMode,
|
||||
} from "./native-runtime/index.js";
|
||||
import { normalizeResponsibleUserDenialCode } from "./responsible-user-denial-run-outcomes.js";
|
||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||
import { getServerAdapter, listAdapterModelProfiles, runningProcesses } from "../adapters/index.js";
|
||||
@@ -9506,7 +9517,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
async function appendRunEvent(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
seq: number,
|
||||
_seq: number,
|
||||
event: {
|
||||
eventType: string;
|
||||
stream?: "system" | "stdout" | "stderr";
|
||||
@@ -9536,11 +9547,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
at: eventAt,
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
const persistedEvent = await appendHeartbeatRunEvent(db, {
|
||||
companyId: run.companyId,
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
seq,
|
||||
eventType: event.eventType,
|
||||
stream: event.stream,
|
||||
level: event.level,
|
||||
@@ -9548,6 +9558,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
message: sanitizedMessage,
|
||||
payload: sanitizedPayload,
|
||||
});
|
||||
const seq = persistedEvent.row.seq;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: run.companyId,
|
||||
@@ -9587,10 +9598,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
async function nextRunEventSeq(runId: string) {
|
||||
const [row] = await db
|
||||
.select({ maxSeq: sql<number | null>`max(${heartbeatRunEvents.seq})` })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, runId));
|
||||
return Number(row?.maxSeq ?? 0) + 1;
|
||||
.select({ nextEventSeq: heartbeatRuns.nextEventSeq })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
return Number(row?.nextEventSeq ?? 1);
|
||||
}
|
||||
|
||||
async function persistRunProcessMetadata(
|
||||
@@ -13008,6 +13019,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
|
||||
const now = new Date();
|
||||
|
||||
// Complete persisted native results before generic orphan recovery. The reconciler reads
|
||||
// the durable workspace barrier and persisted runtime mode, never the current feature flag.
|
||||
await reconcileNativeFinalizations(db).catch((error) => {
|
||||
logger.warn({ err: error }, "failed to reconcile persisted native finalizations before orphan reaping");
|
||||
});
|
||||
|
||||
// Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them)
|
||||
const activeRuns = await db
|
||||
.select({
|
||||
@@ -15249,11 +15266,106 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
};
|
||||
|
||||
const adapter = getServerAdapter(agent.adapterType);
|
||||
// Runtime selection is immutable once persisted. In particular, turning the instance flag
|
||||
// off prevents new native runs without changing the recovery path for an already-native run.
|
||||
const nativeRuntimeResolution = run.runtimeModeResolvedAt
|
||||
? run.runtimeMode === "native"
|
||||
? {
|
||||
kind: "native" as const,
|
||||
resolverVersion: "phase6-v1" as const,
|
||||
reason: "eligible_opt_in" as const,
|
||||
profile: { mode: "native" as const, backend: "codex_app_server" as const, protocolVersion: 1 as const },
|
||||
}
|
||||
: {
|
||||
kind: "legacy" as const,
|
||||
resolverVersion: "phase6-v1" as const,
|
||||
reason: run.runtimeModeReason ?? "persisted_legacy_selection",
|
||||
}
|
||||
: resolveNativeRuntimeMode({
|
||||
enabled: resolvedInstanceSettings.experimental.enableNativeRunner === true,
|
||||
runtimeConfig: agent.runtimeConfig,
|
||||
agent: { status: runningAgent.status, adapterType: agent.adapterType },
|
||||
issue: issueRef,
|
||||
target: executionTarget,
|
||||
workspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
});
|
||||
let nativeExecution: ReturnType<typeof buildNativeExecutionInput> | null = null;
|
||||
let nativeRunnerInstanceId: string | null = null;
|
||||
if (nativeRuntimeResolution.kind === "native") {
|
||||
if (!issueRef || !persistedExecutionWorkspace) {
|
||||
throw new Error("native_runtime_ineligible: issue and realized workspace are required");
|
||||
}
|
||||
const persistedContract = run.completionContractId
|
||||
? await db.select().from(completionContracts)
|
||||
.where(and(
|
||||
eq(completionContracts.id, run.completionContractId),
|
||||
eq(completionContracts.companyId, agent.companyId),
|
||||
eq(completionContracts.issueId, issueRef.id),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const completionContract = persistedContract
|
||||
? { row: persistedContract, contract: persistedContract.contractJson as never }
|
||||
: await ensureNativeCompletionContract({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issue: issueRef,
|
||||
actorId: agent.id,
|
||||
});
|
||||
nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID();
|
||||
const nativeSessionId = run.nativeSessionId ?? randomUUID();
|
||||
nativeExecution = buildNativeExecutionInput({
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
issue: issueRef,
|
||||
agentId: agent.id,
|
||||
workspace: {
|
||||
id: persistedExecutionWorkspace.id,
|
||||
cwd: executionWorkspace.cwd,
|
||||
repoUrl: executionWorkspace.repoUrl,
|
||||
repoRef: executionWorkspace.repoRef,
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
completionContract: {
|
||||
id: completionContract.row.id,
|
||||
sha256: completionContract.row.canonicalSha256,
|
||||
schemaVersion: completionContract.row.schemaVersion,
|
||||
contract: completionContract.contract,
|
||||
},
|
||||
});
|
||||
await db.update(heartbeatRuns).set({
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolverVersion: nativeRuntimeResolution.resolverVersion,
|
||||
runtimeModeReason: nativeRuntimeResolution.reason,
|
||||
runtimeModeResolvedAt: run.runtimeModeResolvedAt ?? new Date(),
|
||||
runnerProfileJson: nativeRuntimeResolution.profile,
|
||||
runnerInstanceId: nativeRunnerInstanceId,
|
||||
nativeSessionId,
|
||||
driverKind: "codex_app_server",
|
||||
driverVersion: "phase6-v1",
|
||||
completionContractId: completionContract.row.id,
|
||||
completionContractSha256: completionContract.row.canonicalSha256,
|
||||
nativePhase: run.nativePhase ?? "selected",
|
||||
nativePhaseUpdatedAt: run.nativePhaseUpdatedAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
} else {
|
||||
await db.update(heartbeatRuns).set({
|
||||
runtimeMode: "legacy",
|
||||
runtimeModeResolverVersion: nativeRuntimeResolution.resolverVersion,
|
||||
runtimeModeReason: nativeRuntimeResolution.reason,
|
||||
runtimeModeResolvedAt: run.runtimeModeResolvedAt ?? new Date(),
|
||||
runnerProfileJson: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
const localAgentJwtScope =
|
||||
issueRef?.workMode === "skill_test"
|
||||
? { kind: "skill_test" as const, issueId: issueRef.id }
|
||||
: { kind: "standard" as const };
|
||||
const authToken = adapter.supportsLocalAgentJwt
|
||||
const authToken = nativeRuntimeResolution.kind === "legacy" && adapter.supportsLocalAgentJwt
|
||||
? createLocalAgentJwt(
|
||||
agent.id,
|
||||
agent.companyId,
|
||||
@@ -15263,7 +15375,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
localAgentJwtScope,
|
||||
)
|
||||
: null;
|
||||
if (adapter.supportsLocalAgentJwt && !authToken) {
|
||||
if (nativeRuntimeResolution.kind === "legacy" && adapter.supportsLocalAgentJwt && !authToken) {
|
||||
logger.warn(
|
||||
{
|
||||
companyId: agent.companyId,
|
||||
@@ -15452,61 +15564,66 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
adapterFinalizeOutcome = status;
|
||||
};
|
||||
|
||||
let adapterResult: Awaited<ReturnType<typeof adapter.execute>>;
|
||||
let adapterResult: AdapterExecutionResult;
|
||||
try {
|
||||
const adapterContext = { ...context };
|
||||
const runtimeMcpServers = await buildPaperclipRuntimeMcpServers({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
});
|
||||
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
|
||||
const managedMcpConfig = await createManagedMcpRunConfig({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
config: runtimeConfig,
|
||||
projectId: issueRef?.projectId ?? null,
|
||||
issueId: issueRef?.id ?? null,
|
||||
});
|
||||
if (managedMcpConfig) {
|
||||
adapterContext.paperclipManagedMcp = managedMcpConfig;
|
||||
if (nativeRuntimeResolution.kind === "native") {
|
||||
if (!nativeExecution || !nativeRunnerInstanceId) throw new Error("native_runtime_selection_not_persisted");
|
||||
adapterResult = await executePaperclipNativeSession({
|
||||
db,
|
||||
execution: nativeExecution,
|
||||
runnerInstanceId: nativeRunnerInstanceId,
|
||||
});
|
||||
} else {
|
||||
const adapterContext = { ...context };
|
||||
const runtimeMcpServers = await buildPaperclipRuntimeMcpServers({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
});
|
||||
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
|
||||
const managedMcpConfig = await createManagedMcpRunConfig({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
config: runtimeConfig,
|
||||
projectId: issueRef?.projectId ?? null,
|
||||
issueId: issueRef?.id ?? null,
|
||||
});
|
||||
if (managedMcpConfig) {
|
||||
adapterContext.paperclipManagedMcp = managedMcpConfig;
|
||||
}
|
||||
adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context: adapterContext,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
runtimeMcp,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onEvent: onAdapterEvent,
|
||||
startupTraceContext: getStartupTraceContext(),
|
||||
onRuntimeProgress: async (progress) => {
|
||||
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
|
||||
},
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
}
|
||||
adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context: adapterContext,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
runtimeMcp,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onEvent: onAdapterEvent,
|
||||
// The endpoint-gated OpenTelemetry startup trace context. It is a
|
||||
// no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set and the OTel
|
||||
// packages are installed, so the sandbox-start span path stays inert
|
||||
// by default.
|
||||
startupTraceContext: getStartupTraceContext(),
|
||||
onRuntimeProgress: async (progress) => {
|
||||
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
|
||||
},
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
// Adapter returned cleanly, which means its workspace-restore finally
|
||||
// block also ran without throwing. Record the workspace_finalize
|
||||
// barrier so dependents that share this executionWorkspace can wake.
|
||||
@@ -15514,6 +15631,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// rather than silently leaving dependents stranded behind a missing
|
||||
// finalize row.
|
||||
await recordWorkspaceFinalize("succeeded");
|
||||
if (adapterResult.nativeFinalization) {
|
||||
adapterResult.nativeFinalization.workspaceFinalizeStatus = "succeeded";
|
||||
try {
|
||||
await finalizeNativeRun({ db, runId: run.id, workspaceFinalizeStatus: "succeeded" });
|
||||
} catch (finalizeErr) {
|
||||
logger.warn(
|
||||
{ err: finalizeErr, runId: run.id },
|
||||
"native result persisted but finalization did not apply; the reconciliation loop will retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (adapterErr) {
|
||||
// Adapter (or its restore finally) threw — or the finalize record
|
||||
// write itself threw. Either way the workspace may be in a partial
|
||||
@@ -15530,6 +15658,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
"failed to record workspace_finalize=failed operation; dependents may remain gated",
|
||||
);
|
||||
}
|
||||
if (nativeRuntimeResolution.kind === "native") {
|
||||
try {
|
||||
await finalizeNativeRun({ db, runId: run.id, workspaceFinalizeStatus: "failed" });
|
||||
} catch (finalizeErr) {
|
||||
logger.warn(
|
||||
{ err: finalizeErr, runId: run.id },
|
||||
"native result could not be marked workspace_failed; the reconciliation loop will retry persisted results",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw adapterErr;
|
||||
} finally {
|
||||
try {
|
||||
@@ -15628,6 +15766,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const latestRun = await getRun(run.id);
|
||||
if (isHeartbeatRunTerminalStatus(latestRun?.status)) {
|
||||
outcome = latestRun.status;
|
||||
} else if (adapterResult.nativeFinalization) {
|
||||
const nativeTerminal = adapterResult.nativeFinalization.terminal.runTerminalState;
|
||||
outcome = nativeTerminal === "succeeded"
|
||||
? "succeeded"
|
||||
: nativeTerminal === "cancelled"
|
||||
? "cancelled"
|
||||
: "failed";
|
||||
} else if (adapterResult.timedOut) {
|
||||
outcome = "timed_out";
|
||||
} else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) {
|
||||
@@ -15737,6 +15882,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
resultJson: mergeModelProfileRunMetadata(
|
||||
mergeAdapterRecoveryMetadata({
|
||||
resultJson: {
|
||||
...(adapterResult.nativeFinalization ? parseObject(latestRun?.resultJson) : {}),
|
||||
...parseObject(adapterResult.resultJson),
|
||||
configFreshness: configFreshnessResultMetadata,
|
||||
},
|
||||
@@ -17507,16 +17653,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.executionRunId, scheduledRun.id)));
|
||||
}
|
||||
|
||||
const [eventSeq] = await tx
|
||||
.select({ maxSeq: sql<number | null>`max(${heartbeatRunEvents.seq})` })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, cancelled.id));
|
||||
|
||||
const eventSeq = Number(cancelled.nextEventSeq ?? 1);
|
||||
await tx.insert(heartbeatRunEvents).values({
|
||||
companyId: cancelled.companyId,
|
||||
runId: cancelled.id,
|
||||
agentId: cancelled.agentId,
|
||||
seq: Number(eventSeq?.maxSeq ?? 0) + 1,
|
||||
seq: eventSeq,
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
@@ -17533,6 +17675,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
currentAssigneeAgentId: issue.assigneeAgentId,
|
||||
},
|
||||
});
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({ nextEventSeq: eventSeq + 1, updatedAt: now })
|
||||
.where(eq(heartbeatRuns.id, cancelled.id));
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -18473,6 +18619,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
const running = runningProcesses.get(run.id);
|
||||
try {
|
||||
await cancelNativeSession(run.id, reason);
|
||||
if (running) {
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: running.child.pid ?? run.processPid,
|
||||
|
||||
@@ -207,6 +207,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableNativeRunner: parsed.data.enableNativeRunner ?? false,
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
||||
@@ -243,6 +244,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableNativeRunner: false,
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
|
||||
@@ -3099,6 +3099,8 @@ const issueListSelect = {
|
||||
END
|
||||
`,
|
||||
status: issues.status,
|
||||
statusVersion: issues.statusVersion,
|
||||
lastStatusDecisionId: issues.lastStatusDecisionId,
|
||||
workMode: issues.workMode,
|
||||
harnessKind: issues.harnessKind,
|
||||
priority: issues.priority,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function canonicalNativeJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalNativeJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalNativeJson(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
export function nativeSha256(value: unknown): string {
|
||||
return createHash("sha256").update(canonicalNativeJson(value), "utf8").digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { completionContracts } from "@paperclipai/db";
|
||||
import type { StrictCompletionContractInput } from "@paperclipai/paperclip-runner";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export const NATIVE_COMPLETION_CONTRACT_SCHEMA = "paperclip.completion-contract.v1";
|
||||
export const NATIVE_COMPLETION_POLICY_VERSION = "phase6-v1";
|
||||
|
||||
export async function ensureNativeCompletionContract(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issue: { id: string; title: string; description: string | null };
|
||||
actorId: string;
|
||||
}) {
|
||||
const contract: StrictCompletionContractInput = {
|
||||
revision: "1",
|
||||
objective: input.issue.title,
|
||||
criteria: [{
|
||||
id: "objective",
|
||||
requirement: input.issue.description?.trim() || `Complete: ${input.issue.title}`,
|
||||
}],
|
||||
};
|
||||
const canonicalSha256 = nativeSha256({
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
contract,
|
||||
});
|
||||
const existing = await input.db
|
||||
.select()
|
||||
.from(completionContracts)
|
||||
.where(and(
|
||||
eq(completionContracts.companyId, input.companyId),
|
||||
eq(completionContracts.issueId, input.issue.id),
|
||||
eq(completionContracts.canonicalSha256, canonicalSha256),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return { row: existing, contract };
|
||||
|
||||
const latest = await input.db
|
||||
.select({ revision: completionContracts.revision, id: completionContracts.id })
|
||||
.from(completionContracts)
|
||||
.where(and(
|
||||
eq(completionContracts.companyId, input.companyId),
|
||||
eq(completionContracts.issueId, input.issue.id),
|
||||
))
|
||||
.orderBy(desc(completionContracts.revision))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const [row] = await input.db.insert(completionContracts).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
revision: (latest?.revision ?? 0) + 1,
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
risk: "standard",
|
||||
completionAuthority: "server_arbiter",
|
||||
incompleteCriteriaPolicy: "preserve_non_terminal",
|
||||
contractJson: contract as unknown as Record<string, unknown>,
|
||||
canonicalSha256,
|
||||
createdByActorType: "system",
|
||||
createdByActorId: input.actorId,
|
||||
supersedesContractId: latest?.id ?? null,
|
||||
}).returning();
|
||||
if (!row) throw new Error("native_completion_contract_not_persisted");
|
||||
return { row, contract };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface NativeEvidenceAssessment {
|
||||
objectiveSatisfied: boolean;
|
||||
allCriteriaSatisfied: boolean;
|
||||
verificationPassed: boolean;
|
||||
hasBlockingRemainingWork: boolean;
|
||||
reportedDisposition: "done" | "blocked" | "needs_review" | "yielded";
|
||||
summary: string;
|
||||
blocker: { unblockAction: string; boardOwned: boolean } | null;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function classifyNativeEvidence(result: Record<string, unknown>): NativeEvidenceAssessment {
|
||||
const claim = record(result.completionClaim);
|
||||
const criteria = Array.isArray(claim.criteria) ? claim.criteria.map(record) : [];
|
||||
const remaining = Array.isArray(claim.remainingWork) ? claim.remainingWork.map(record) : [];
|
||||
const verification = Array.isArray(result.verification) ? result.verification.map(record) : [];
|
||||
const reported = result.reportedWorkDisposition;
|
||||
const blocker = record(result.blocker);
|
||||
const blockerOwner = record(blocker.owner);
|
||||
if (!["done", "blocked", "needs_review", "yielded"].includes(String(reported))) {
|
||||
throw new Error("native_result_invalid_disposition");
|
||||
}
|
||||
return {
|
||||
objectiveSatisfied: claim.objectiveSatisfied === true,
|
||||
allCriteriaSatisfied: criteria.length > 0 && criteria.every((criterion) => criterion.status === "satisfied"),
|
||||
verificationPassed: verification.length > 0 && verification.every((entry) => entry.status === "passed"),
|
||||
hasBlockingRemainingWork: remaining.some((entry) => entry.blocksCompletion === true),
|
||||
reportedDisposition: reported as NativeEvidenceAssessment["reportedDisposition"],
|
||||
summary: typeof result.summary === "string" ? result.summary : "Native run completed.",
|
||||
blocker: reported === "blocked" && typeof blocker.unblockAction === "string"
|
||||
? {
|
||||
unblockAction: blocker.unblockAction,
|
||||
boardOwned: blockerOwner.kind === "board" || blockerOwner.owner === "board",
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./runtime-mode.js";
|
||||
export * from "./completion-contracts.js";
|
||||
export * from "./native-execution-input.js";
|
||||
export * from "./native-session-executor.js";
|
||||
export * from "./paperclip-control-plane-port.js";
|
||||
export * from "./native-run-finalizer.js";
|
||||
export * from "./native-finalization-reconciler.js";
|
||||
export * from "./status-arbiter.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { NativeExecutionInputV1, StrictCompletionContractInput } from "@paperclipai/paperclip-runner";
|
||||
import { parseNativeExecutionInput } from "@paperclipai/paperclip-runner";
|
||||
|
||||
/** Closed constructor: callers cannot spread legacy context or environment data. */
|
||||
export function buildNativeExecutionInput(input: {
|
||||
companyId: string;
|
||||
runId: string;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
workMode: string;
|
||||
};
|
||||
agentId: string;
|
||||
workspace: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
repoUrl: string | null;
|
||||
repoRef: string | null;
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
completionContract: {
|
||||
id: string;
|
||||
sha256: string;
|
||||
schemaVersion: string;
|
||||
contract: StrictCompletionContractInput;
|
||||
};
|
||||
}): NativeExecutionInputV1 {
|
||||
if (input.issue.workMode !== "standard") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard");
|
||||
}
|
||||
return parseNativeExecutionInput({
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: {
|
||||
companyId: input.companyId,
|
||||
runId: input.runId,
|
||||
issueId: input.issue.id,
|
||||
agentId: input.agentId,
|
||||
executionWorkspaceId: input.workspace.id,
|
||||
},
|
||||
task: {
|
||||
identifier: input.issue.identifier ?? input.issue.id,
|
||||
title: input.issue.title,
|
||||
description: input.issue.description,
|
||||
workMode: "standard",
|
||||
},
|
||||
workspace: {
|
||||
cwd: input.workspace.cwd,
|
||||
repoUrl: input.workspace.repoUrl,
|
||||
repoRef: input.workspace.repoRef,
|
||||
branchName: input.workspace.branchName,
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
driverKind: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
completionContract: input.completionContract,
|
||||
interactionResponses: [],
|
||||
credentialBindings: [],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { heartbeatRuns, nativeRunFinalizations, workspaceOperations } from "@paperclipai/db";
|
||||
import { finalizeNativeRun } from "./native-run-finalizer.js";
|
||||
|
||||
/** Recovery is keyed only by persisted mode/coordinator state, never the live flag. */
|
||||
export async function reconcileNativeFinalizations(db: Db, runIds?: string[]) {
|
||||
const rows = await db.select({ runId: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
||||
.where(and(
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
isNotNull(nativeRunFinalizations.resultId),
|
||||
ne(nativeRunFinalizations.phase, "applied"),
|
||||
...(runIds && runIds.length > 0 ? [inArray(heartbeatRuns.id, runIds)] : []),
|
||||
));
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
const barrier = await db.select({ status: workspaceOperations.status })
|
||||
.from(workspaceOperations)
|
||||
.where(and(
|
||||
eq(workspaceOperations.heartbeatRunId, row.runId),
|
||||
eq(workspaceOperations.phase, "workspace_finalize"),
|
||||
))
|
||||
.orderBy(desc(workspaceOperations.createdAt))
|
||||
.limit(1)
|
||||
.then((entries) => entries[0] ?? null);
|
||||
if (!barrier || !["succeeded", "failed"].includes(barrier.status)) continue;
|
||||
results.push(await finalizeNativeRun({
|
||||
db,
|
||||
runId: row.runId,
|
||||
workspaceFinalizeStatus: barrier.status as "succeeded" | "failed",
|
||||
projectRunStatus: true,
|
||||
}));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Phase 6 never auto-approves model requests. Unsupported native requests are
|
||||
* declined explicitly; typed response materialization stays in existing
|
||||
* interaction/approval services.
|
||||
*/
|
||||
export function rejectUnsupportedNativeRuntimeRequest(requestKind: string) {
|
||||
return {
|
||||
accepted: false as const,
|
||||
code: "native_runtime_request_unsupported" as const,
|
||||
requestKind,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
@@ -0,0 +1,156 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
approvals,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
} from "@paperclipai/db";
|
||||
import { classifyNativeEvidence } from "./evidence-classifier.js";
|
||||
import { arbitrateNativeStatus, NATIVE_STATUS_ARBITER_POLICY_VERSION } from "./status-arbiter.js";
|
||||
import { recordNativeWorkAssessment } from "./work-assessments.js";
|
||||
import { commitNativeStatusDecision, NativeStatusRaceError } from "./status-decision-committer.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export async function finalizeNativeRun(input: {
|
||||
db: Db;
|
||||
runId: string;
|
||||
workspaceFinalizeStatus: "succeeded" | "failed";
|
||||
/** Reconciliation owns terminal run projection; the live heartbeat does it afterward. */
|
||||
projectRunStatus?: boolean;
|
||||
}) {
|
||||
const run = await input.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (!run || run.runtimeMode !== "native") throw new Error("native_finalization_run_missing");
|
||||
const coordinator = await input.db.select().from(nativeRunFinalizations)
|
||||
.where(eq(nativeRunFinalizations.runId, input.runId)).limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!coordinator?.resultId) throw new Error("native_finalization_missing");
|
||||
if (coordinator.phase === "applied" && coordinator.decisionId) return coordinator;
|
||||
if (input.workspaceFinalizeStatus === "failed") {
|
||||
const now = new Date();
|
||||
await input.db.update(nativeRunFinalizations).set({
|
||||
phase: "workspace_failed",
|
||||
failureCode: "workspace_finalization_failed",
|
||||
failureDetail: "Native result preserved; issue status intentionally unchanged.",
|
||||
updatedAt: now,
|
||||
}).where(eq(nativeRunFinalizations.runId, input.runId));
|
||||
await input.db.update(heartbeatRuns).set({
|
||||
...(input.projectRunStatus ? { status: "failed", finishedAt: now } : {}),
|
||||
nativePhase: "workspace_failed",
|
||||
nativePhaseUpdatedAt: now,
|
||||
resultJson: {
|
||||
...record(run.resultJson),
|
||||
finalizationPhase: "workspace_failed",
|
||||
workspaceFinalizeStatus: "failed",
|
||||
},
|
||||
updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
return { ...coordinator, phase: "workspace_failed" };
|
||||
}
|
||||
const resultRow = await input.db.select().from(nativeRunResults)
|
||||
.where(eq(nativeRunResults.id, coordinator.resultId)).limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!resultRow) throw new Error("native_result_missing");
|
||||
const envelope = record(resultRow.resultJson);
|
||||
const result = record(envelope.result);
|
||||
const terminal = record(envelope.terminal);
|
||||
const terminalState = terminal.runTerminalState;
|
||||
if (!["succeeded", "failed", "cancelled"].includes(String(terminalState))) {
|
||||
throw new Error("native_finalization_invalid");
|
||||
}
|
||||
const assessment = classifyNativeEvidence(result);
|
||||
|
||||
let supersedesAssessmentId: string | null = null;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const issue = await input.db.select().from(issues).where(eq(issues.id, coordinator.issueId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
const authoritativeIssue = issue ?? await input.db.select().from(issues)
|
||||
.where(eq(issues.id, coordinator.issueId)).limit(1).then((rows) => rows[0] ?? null);
|
||||
if (!authoritativeIssue || authoritativeIssue.companyId !== run.companyId) {
|
||||
throw new Error("native_finalization_issue_missing");
|
||||
}
|
||||
const decision = arbitrateNativeStatus({
|
||||
assessment,
|
||||
terminalState: terminalState as "succeeded" | "failed" | "cancelled",
|
||||
workspaceFinalizeStatus: input.workspaceFinalizeStatus,
|
||||
approvalGateSatisfied: !await input.db.select({ id: approvals.id })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(and(
|
||||
eq(issueApprovals.companyId, run.companyId),
|
||||
eq(issueApprovals.issueId, authoritativeIssue.id),
|
||||
eq(approvals.companyId, run.companyId),
|
||||
inArray(approvals.status, ["pending", "revision_requested"]),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0),
|
||||
agentId: run.agentId,
|
||||
});
|
||||
const assessmentRow = await recordNativeWorkAssessment({
|
||||
db: input.db,
|
||||
companyId: run.companyId,
|
||||
issueId: authoritativeIssue.id,
|
||||
runId: run.id,
|
||||
turnId: resultRow.turnId,
|
||||
contractId: resultRow.completionContractId,
|
||||
resultId: resultRow.id,
|
||||
priorIssueStatus: authoritativeIssue.status,
|
||||
priorStatusVersion: Number(authoritativeIssue.statusVersion),
|
||||
priorDecisionId: authoritativeIssue.lastStatusDecisionId,
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
assessment,
|
||||
supersedesAssessmentId,
|
||||
});
|
||||
try {
|
||||
const committed = await commitNativeStatusDecision({
|
||||
db: input.db,
|
||||
companyId: run.companyId,
|
||||
issueId: authoritativeIssue.id,
|
||||
runId: run.id,
|
||||
assessmentId: assessmentRow.id,
|
||||
priorStatus: authoritativeIssue.status,
|
||||
priorStatusVersion: Number(authoritativeIssue.statusVersion),
|
||||
decision,
|
||||
});
|
||||
await input.db.update(heartbeatRuns).set({
|
||||
...(input.projectRunStatus ? {
|
||||
status: terminalState === "succeeded" ? "succeeded" : terminalState === "cancelled" ? "cancelled" : "failed",
|
||||
finishedAt: new Date(),
|
||||
} : {}),
|
||||
nativePhase: "applied",
|
||||
nativePhaseUpdatedAt: new Date(),
|
||||
resultJson: {
|
||||
...record(run.resultJson),
|
||||
finalizationPhase: "applied",
|
||||
assessmentId: assessmentRow.id,
|
||||
decisionId: committed.decision.id,
|
||||
authoritativeDecision: decision.toStatus,
|
||||
issueStatusBefore: authoritativeIssue.status,
|
||||
issueStatusAfter: committed.issue.status,
|
||||
statusVersionBefore: Number(authoritativeIssue.statusVersion),
|
||||
statusVersionAfter: Number(committed.issue.statusVersion),
|
||||
workspaceFinalizeStatus: input.workspaceFinalizeStatus,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
return {
|
||||
...coordinator,
|
||||
phase: "applied",
|
||||
assessmentId: assessmentRow.id,
|
||||
decisionId: committed.decision.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof NativeStatusRaceError) || attempt === 1) throw error;
|
||||
supersedesAssessmentId = assessmentRow.id;
|
||||
}
|
||||
}
|
||||
throw new Error("native_finalization_status_race");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { NativeExecutionInputV1 } from "@paperclipai/paperclip-runner";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
release: null as null | (() => void),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/paperclip-runner", () => ({
|
||||
createCodexNativeSessionBackend: vi.fn(() => ({ kind: "test" })),
|
||||
executeNativeSession: state.execute,
|
||||
}));
|
||||
|
||||
import { cancelNativeSession, executePaperclipNativeSession } from "./native-session-executor.js";
|
||||
|
||||
const execution = {
|
||||
binding: {
|
||||
companyId: "company",
|
||||
runId: "run-native-cancel",
|
||||
issueId: "issue",
|
||||
agentId: "agent",
|
||||
},
|
||||
completionContract: { id: "contract", sha256: "sha" },
|
||||
} as NativeExecutionInputV1;
|
||||
|
||||
describe("native session cancellation", () => {
|
||||
beforeEach(() => {
|
||||
state.cancel.mockReset();
|
||||
state.release = null;
|
||||
state.execute.mockReset().mockImplementation(async (options) => {
|
||||
options.onSession?.({ cancel: state.cancel });
|
||||
await new Promise<void>((resolve) => { state.release = resolve; });
|
||||
options.onSession?.(null);
|
||||
return {
|
||||
result: { summary: "cancelled" },
|
||||
terminal: { runTerminalState: "cancelled" },
|
||||
turnId: "turn",
|
||||
normalizedSessionId: "session",
|
||||
providerSessionId: null,
|
||||
driverKind: "test",
|
||||
driverVersion: "1",
|
||||
nativeEventCount: 1,
|
||||
highestContiguousSourceSeq: 1,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("routes control-plane cancellation to the active normalized session and removes the handle", async () => {
|
||||
const running = executePaperclipNativeSession({
|
||||
db: {} as Db,
|
||||
execution,
|
||||
runnerInstanceId: "runner",
|
||||
});
|
||||
await vi.waitFor(() => expect(state.release).toBeTypeOf("function"));
|
||||
|
||||
await expect(cancelNativeSession(execution.binding.runId, "budget hard stop")).resolves.toBe(true);
|
||||
expect(state.cancel).toHaveBeenCalledWith({ reason: "budget hard stop" });
|
||||
|
||||
state.release?.();
|
||||
await running;
|
||||
await expect(cancelNativeSession(execution.binding.runId, "late cancel")).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AdapterExecutionResult } from "../../adapters/index.js";
|
||||
import type { NativeFinalizationResult } from "@paperclipai/shared";
|
||||
import type { NativeExecutionInputV1, NativeSession } from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
createCodexNativeSessionBackend,
|
||||
executeNativeSession,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
|
||||
const activeNativeSessions = new Map<string, NativeSession>();
|
||||
|
||||
export async function cancelNativeSession(runId: string, reason: string): Promise<boolean> {
|
||||
const session = activeNativeSessions.get(runId);
|
||||
if (!session) return false;
|
||||
if (session.cancel) await session.cancel({ reason });
|
||||
else if (session.interrupt) await session.interrupt({ reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function executePaperclipNativeSession(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
runnerInstanceId: string;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
|
||||
const controlPlane = new PaperclipControlPlanePort(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
runId: input.execution.binding.runId,
|
||||
agentId: input.execution.binding.agentId,
|
||||
completionContractId: input.execution.completionContract.id,
|
||||
completionContractSha256: input.execution.completionContract.sha256,
|
||||
sourceInstanceId: input.runnerInstanceId,
|
||||
controlPlaneSourceInstanceId: controlPlaneInstanceId,
|
||||
});
|
||||
const native = await executeNativeSession({
|
||||
input: input.execution,
|
||||
backend: createCodexNativeSessionBackend(input.execution, { runnerInstanceId: input.runnerInstanceId }),
|
||||
controlPlane,
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
controlPlaneInstanceId,
|
||||
onSession: (session) => {
|
||||
if (session) activeNativeSessions.set(input.execution.binding.runId, session);
|
||||
else activeNativeSessions.delete(input.execution.binding.runId);
|
||||
},
|
||||
});
|
||||
const finalization: NativeFinalizationResult = {
|
||||
schema: "paperclip.native-finalization.v1",
|
||||
runtimeMode: "native",
|
||||
runId: input.execution.binding.runId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
companyId: input.execution.binding.companyId,
|
||||
result: native.result as unknown as Record<string, unknown>,
|
||||
terminal: native.terminal,
|
||||
turnId: native.turnId,
|
||||
sourceInstanceId: input.runnerInstanceId,
|
||||
normalizedSessionId: native.normalizedSessionId,
|
||||
providerSessionId: native.providerSessionId,
|
||||
driverKind: native.driverKind,
|
||||
driverVersion: native.driverVersion,
|
||||
nativeEventCount: native.nativeEventCount,
|
||||
highestContiguousSourceSeq: native.highestContiguousSourceSeq,
|
||||
workspaceFinalizeStatus: "pending",
|
||||
};
|
||||
return {
|
||||
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: native.terminal.runTerminalState === "succeeded"
|
||||
? null
|
||||
: `Native session ${native.terminal.runTerminalState}`,
|
||||
resultJson: {
|
||||
nativeResult: native.result as unknown as Record<string, unknown>,
|
||||
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
|
||||
},
|
||||
summary: native.result.summary,
|
||||
sessionId: native.normalizedSessionId,
|
||||
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
|
||||
provider: "openai",
|
||||
usageBasis: "per_run",
|
||||
nativeFinalization: finalization,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
completionContracts,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
statusDecisionEffects,
|
||||
statusDecisions,
|
||||
workAssessments,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
CONTROL_PLANE_CONFORMANCE_RESULT,
|
||||
CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
||||
CONTROL_PLANE_CONFORMANCE_OPEN,
|
||||
executeNativeSession,
|
||||
runControlPlanePortConformance,
|
||||
type NativeSessionBackend,
|
||||
type PrpEvent,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
import { finalizeNativeRun } from "./native-run-finalizer.js";
|
||||
import { buildNativeExecutionInput } from "./native-execution-input.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
|
||||
describeDatabase("PaperclipControlPlanePort conformance", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const contractId = "00000000-0000-4000-8000-000000000004";
|
||||
const contractSha = "phase6-conformance-contract";
|
||||
const taskIssueId = "00000000-0000-4000-8000-000000000008";
|
||||
const taskContractId = "00000000-0000-4000-8000-000000000009";
|
||||
const taskRunId = "00000000-0000-4000-8000-000000000010";
|
||||
const workspaceFailureIssueId = "00000000-0000-4000-8000-000000000011";
|
||||
const workspaceFailureContractId = "00000000-0000-4000-8000-000000000012";
|
||||
const workspaceFailureRunId = "00000000-0000-4000-8000-000000000013";
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-port-");
|
||||
db = createDb(temporary.connectionString);
|
||||
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
|
||||
await db.insert(companies).values({ id: identity.companyId, name: "Phase 6", issuePrefix: "P6C" });
|
||||
await db.insert(agents).values({
|
||||
id: identity.agentId,
|
||||
companyId: identity.companyId,
|
||||
name: "Native conformance",
|
||||
adapterType: "codex_local",
|
||||
status: "running",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: identity.issueId,
|
||||
companyId: identity.companyId,
|
||||
title: "Native conformance",
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
});
|
||||
await db.insert(completionContracts).values({
|
||||
id: contractId,
|
||||
companyId: identity.companyId,
|
||||
issueId: identity.issueId,
|
||||
revision: 1,
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
policyVersion: "phase6-v1",
|
||||
risk: "standard",
|
||||
completionAuthority: "server_arbiter",
|
||||
incompleteCriteriaPolicy: "preserve_non_terminal",
|
||||
contractJson: { objective: "Conformance" },
|
||||
canonicalSha256: contractSha,
|
||||
createdByActorType: "system",
|
||||
createdByActorId: "test",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: identity.runId,
|
||||
companyId: identity.companyId,
|
||||
agentId: identity.agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
completionContractId: contractId,
|
||||
completionContractSha256: contractSha,
|
||||
contextSnapshot: { issueId: identity.issueId },
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: taskIssueId,
|
||||
companyId: identity.companyId,
|
||||
title: "Complete one native task",
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
});
|
||||
await db.insert(completionContracts).values({
|
||||
id: taskContractId,
|
||||
companyId: identity.companyId,
|
||||
issueId: taskIssueId,
|
||||
revision: 1,
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
policyVersion: "phase6-v1",
|
||||
risk: "standard",
|
||||
completionAuthority: "server_arbiter",
|
||||
incompleteCriteriaPolicy: "preserve_non_terminal",
|
||||
contractJson: { objective: "Complete one native task" },
|
||||
canonicalSha256: "phase6-task-contract",
|
||||
createdByActorType: "system",
|
||||
createdByActorId: "test",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: taskRunId,
|
||||
companyId: identity.companyId,
|
||||
agentId: identity.agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolverVersion: "phase6-v1",
|
||||
runtimeModeReason: "eligible_opt_in",
|
||||
completionContractId: taskContractId,
|
||||
completionContractSha256: "phase6-task-contract",
|
||||
contextSnapshot: { issueId: taskIssueId },
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: workspaceFailureIssueId,
|
||||
companyId: identity.companyId,
|
||||
title: "Preserve status after workspace failure",
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
});
|
||||
await db.insert(completionContracts).values({
|
||||
id: workspaceFailureContractId,
|
||||
companyId: identity.companyId,
|
||||
issueId: workspaceFailureIssueId,
|
||||
revision: 1,
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
policyVersion: "phase6-v1",
|
||||
risk: "standard",
|
||||
completionAuthority: "server_arbiter",
|
||||
incompleteCriteriaPolicy: "preserve_non_terminal",
|
||||
contractJson: { objective: "Preserve status after workspace failure" },
|
||||
canonicalSha256: "phase6-workspace-failure-contract",
|
||||
createdByActorType: "system",
|
||||
createdByActorId: "test",
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: workspaceFailureRunId,
|
||||
companyId: identity.companyId,
|
||||
agentId: identity.agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
completionContractId: workspaceFailureContractId,
|
||||
completionContractSha256: "phase6-workspace-failure-contract",
|
||||
contextSnapshot: { issueId: workspaceFailureIssueId },
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (temporary) {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(statusDecisionEffects);
|
||||
await db.delete(statusDecisions);
|
||||
await db.delete(workAssessments);
|
||||
await db.delete(nativeRunFinalizations);
|
||||
await db.delete(nativeRunResults);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(completionContracts);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
await temporary.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the unchanged package conformance suite against Paperclip persistence", async () => {
|
||||
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
|
||||
const port = new PaperclipControlPlanePort(db, {
|
||||
companyId: identity.companyId,
|
||||
issueId: identity.issueId,
|
||||
runId: identity.runId,
|
||||
agentId: identity.agentId,
|
||||
completionContractId: contractId,
|
||||
completionContractSha256: contractSha,
|
||||
sourceInstanceId: "runner-phase6-conformance",
|
||||
controlPlaneSourceInstanceId: "control-phase6-conformance",
|
||||
});
|
||||
await expect(runControlPlanePortConformance({ port })).resolves.toEqual({
|
||||
eventCount: 3,
|
||||
highestContiguousSourceSeq: 3,
|
||||
duplicateDisposition: "duplicate",
|
||||
terminalReplayIdempotent: true,
|
||||
});
|
||||
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, identity.runId))).resolves.toHaveLength(1);
|
||||
await finalizeNativeRun({ db, runId: identity.runId, workspaceFinalizeStatus: "succeeded" });
|
||||
await expect(db.select().from(issues).where(eq(issues.id, identity.issueId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "done", statusVersion: 1 }),
|
||||
]);
|
||||
await expect(db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, identity.runId))).resolves.toEqual([
|
||||
expect.objectContaining({ phase: "applied" }),
|
||||
]);
|
||||
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, identity.issueId))).resolves.toEqual([
|
||||
expect.objectContaining({ toStatus: "done", applicationState: "applied" }),
|
||||
]);
|
||||
await expect(db.select().from(activityLog).where(eq(activityLog.entityId, identity.issueId))).resolves.toEqual([
|
||||
expect.objectContaining({ action: "issue.native_status_decision_applied" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("completes one selected Paperclip task through the public package session contract", async () => {
|
||||
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
|
||||
const sessionId = "session-phase6-paperclip-task";
|
||||
const event = (sourceSeq: number, eventType: PrpEvent["eventType"], payload: Record<string, unknown>): PrpEvent => ({
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `phase6-task:${sourceSeq}`,
|
||||
sourceSeq,
|
||||
sourceInstanceId: "phase6-scripted-runner",
|
||||
sourceKind: "runner",
|
||||
runId: taskRunId,
|
||||
normalizedSessionId: sessionId,
|
||||
turnId: "turn-phase6-paperclip-task",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 0,
|
||||
emittedAt: `2026-08-09T02:59:0${sourceSeq}.000Z`,
|
||||
payload,
|
||||
});
|
||||
const events = [
|
||||
event(1, "run.result.proposed", CONTROL_PLANE_CONFORMANCE_RESULT),
|
||||
event(2, "turn.completed", {}),
|
||||
];
|
||||
const backend: NativeSessionBackend = {
|
||||
async descriptor() {
|
||||
return {
|
||||
kind: "mock",
|
||||
name: "phase6-scripted",
|
||||
version: "1",
|
||||
capabilities: { resume: false, typedEvents: true, steering: false, interruption: true, structuredResult: true },
|
||||
};
|
||||
},
|
||||
async openSession(input) {
|
||||
return {
|
||||
identity: () => input.identity,
|
||||
async capabilities() { return { resume: false, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
|
||||
async *events() { yield* events; },
|
||||
async startTurn() { return { turnId: "turn-phase6-paperclip-task" }; },
|
||||
async cancel() {},
|
||||
async result() { return { result: CONTROL_PLANE_CONFORMANCE_RESULT, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, turnId: "turn-phase6-paperclip-task" }; },
|
||||
async snapshot() { return { backendKind: "mock", sessionId, identity: input.identity, providerSessionId: "provider-phase6-paperclip-task" }; },
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const execution = buildNativeExecutionInput({
|
||||
companyId: identity.companyId,
|
||||
runId: taskRunId,
|
||||
issue: { id: taskIssueId, identifier: "P6C-2", title: "Complete one native task", description: null, workMode: "standard" },
|
||||
agentId: identity.agentId,
|
||||
workspace: { id: "workspace-phase6", cwd: process.cwd(), repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId: sessionId,
|
||||
completionContract: {
|
||||
id: taskContractId,
|
||||
sha256: "phase6-task-contract",
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: { revision: "phase6-v1", objective: "Complete one native task", criteria: [{ id: "objective", requirement: "Complete the task" }] },
|
||||
},
|
||||
});
|
||||
const port = new PaperclipControlPlanePort(db, {
|
||||
companyId: identity.companyId,
|
||||
issueId: taskIssueId,
|
||||
runId: taskRunId,
|
||||
agentId: identity.agentId,
|
||||
completionContractId: taskContractId,
|
||||
completionContractSha256: "phase6-task-contract",
|
||||
sourceInstanceId: "phase6-scripted-runner",
|
||||
controlPlaneSourceInstanceId: "phase6-control-plane",
|
||||
});
|
||||
const completed = await executeNativeSession({
|
||||
input: execution,
|
||||
backend,
|
||||
controlPlane: port,
|
||||
runnerInstanceId: "phase6-scripted-runner",
|
||||
controlPlaneInstanceId: "phase6-control-plane",
|
||||
});
|
||||
expect(completed.terminal.runTerminalState).toBe("succeeded");
|
||||
await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded" });
|
||||
await expect(db.select().from(issues).where(eq(issues.id, taskIssueId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "done", statusVersion: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed when the bound company does not own the run", async () => {
|
||||
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
|
||||
const port = new PaperclipControlPlanePort(db, {
|
||||
companyId: "00000000-0000-4000-8000-000000000099",
|
||||
issueId: identity.issueId,
|
||||
runId: identity.runId,
|
||||
agentId: identity.agentId,
|
||||
completionContractId: contractId,
|
||||
completionContractSha256: contractSha,
|
||||
sourceInstanceId: "runner-phase6-conformance",
|
||||
controlPlaneSourceInstanceId: "control-phase6-conformance",
|
||||
});
|
||||
await expect(port.openRun(CONTROL_PLANE_CONFORMANCE_OPEN)).rejects.toThrow("binding_mismatch");
|
||||
});
|
||||
|
||||
it("preserves the result and issue status when workspace finalization fails", async () => {
|
||||
const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity;
|
||||
const port = new PaperclipControlPlanePort(db, {
|
||||
companyId: identity.companyId,
|
||||
issueId: workspaceFailureIssueId,
|
||||
runId: workspaceFailureRunId,
|
||||
agentId: identity.agentId,
|
||||
completionContractId: workspaceFailureContractId,
|
||||
completionContractSha256: "phase6-workspace-failure-contract",
|
||||
sourceInstanceId: "runner-phase6-workspace-failure",
|
||||
controlPlaneSourceInstanceId: "control-phase6-workspace-failure",
|
||||
});
|
||||
await port.openRun({
|
||||
identity: { ...identity, runId: workspaceFailureRunId, issueId: workspaceFailureIssueId },
|
||||
backendKind: "mock",
|
||||
sourceInstanceId: "runner-phase6-workspace-failure",
|
||||
});
|
||||
await port.completeRun({
|
||||
result: CONTROL_PLANE_CONFORMANCE_RESULT,
|
||||
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
||||
callerResultId: "phase6-workspace-failure-result",
|
||||
});
|
||||
await finalizeNativeRun({
|
||||
db,
|
||||
runId: workspaceFailureRunId,
|
||||
workspaceFinalizeStatus: "failed",
|
||||
projectRunStatus: true,
|
||||
});
|
||||
await expect(db.select().from(issues).where(eq(issues.id, workspaceFailureIssueId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "in_progress", statusVersion: 0, lastStatusDecisionId: null }),
|
||||
]);
|
||||
await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, workspaceFailureRunId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "failed", nativePhase: "workspace_failed" }),
|
||||
]);
|
||||
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, workspaceFailureRunId))).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { and, asc, eq, gt, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
CompleteControlPlaneRunInput,
|
||||
ControlPlanePort,
|
||||
NativeRunEvent,
|
||||
NativeRunResult,
|
||||
OpenControlPlaneRunInput,
|
||||
PrpEvent,
|
||||
PrpTerminalState,
|
||||
ReplayControlPlaneEventsInput,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
validatePrpEvent,
|
||||
validatePrpStructuredRunResult,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export interface PaperclipControlPlaneBinding {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
completionContractId: string;
|
||||
completionContractSha256: string;
|
||||
sourceInstanceId: string;
|
||||
controlPlaneSourceInstanceId: string;
|
||||
}
|
||||
|
||||
function isPrpEvent(value: NativeRunEvent | PrpEvent): value is PrpEvent {
|
||||
return "schema" in value && value.schema === "paperclip.prp.event.v1";
|
||||
}
|
||||
|
||||
function isCompleteInput(value: NativeRunResult | CompleteControlPlaneRunInput): value is CompleteControlPlaneRunInput {
|
||||
return "result" in value && "terminal" in value;
|
||||
}
|
||||
|
||||
function assertTerminal(value: unknown): asserts value is PrpTerminalState {
|
||||
const terminal = value as Partial<PrpTerminalState> | null;
|
||||
if (
|
||||
terminal?.schema !== "paperclip.prp.terminal.v1"
|
||||
|| !["completed", "failed", "interrupted", "cancelled"].includes(String(terminal.turnTerminalState))
|
||||
|| !["succeeded", "failed", "cancelled"].includes(String(terminal.runTerminalState))
|
||||
|| !["done", "blocked", "needs_review", "yielded"].includes(String(terminal.reportedWorkDisposition))
|
||||
) {
|
||||
throw new Error("native_terminal_schema_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/** Production implementation of the runner package's deliberately narrow persistence port. */
|
||||
export class PaperclipControlPlanePort implements ControlPlanePort {
|
||||
readonly #db: Db;
|
||||
readonly #binding: PaperclipControlPlaneBinding;
|
||||
#sessionId: string | null = null;
|
||||
|
||||
constructor(db: Db, binding: PaperclipControlPlaneBinding) {
|
||||
this.#db = db;
|
||||
this.#binding = structuredClone(binding);
|
||||
}
|
||||
|
||||
async openRun(input: OpenControlPlaneRunInput): Promise<void> {
|
||||
const identity = input.identity;
|
||||
if (
|
||||
identity.companyId !== this.#binding.companyId
|
||||
|| identity.issueId !== this.#binding.issueId
|
||||
|| identity.runId !== this.#binding.runId
|
||||
|| identity.agentId !== this.#binding.agentId
|
||||
|| input.sourceInstanceId !== this.#binding.sourceInstanceId
|
||||
) {
|
||||
throw new Error("native_open_run_binding_mismatch");
|
||||
}
|
||||
const run = await this.#db.select().from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, this.#binding.runId),
|
||||
eq(heartbeatRuns.companyId, this.#binding.companyId),
|
||||
eq(heartbeatRuns.agentId, this.#binding.agentId),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run || run.runtimeMode !== "native" || run.completionContractId !== this.#binding.completionContractId) {
|
||||
throw new Error("native_open_run_not_authorized");
|
||||
}
|
||||
this.#sessionId = identity.sessionId;
|
||||
}
|
||||
|
||||
async appendEvent(value: NativeRunEvent | PrpEvent) {
|
||||
if (!isPrpEvent(value)) throw new Error("native_legacy_event_not_supported");
|
||||
const validated = validatePrpEvent(value);
|
||||
if (!validated.ok) throw new Error(`native_event_schema_invalid:${validated.issues[0]?.message ?? "unknown"}`);
|
||||
const event = validated.event;
|
||||
if (event.runId !== this.#binding.runId || (this.#sessionId && event.normalizedSessionId !== this.#sessionId)) {
|
||||
throw new Error("native_event_binding_mismatch");
|
||||
}
|
||||
const expectedSourceInstanceId = event.sourceKind === "control_plane"
|
||||
? this.#binding.controlPlaneSourceInstanceId
|
||||
: this.#binding.sourceInstanceId;
|
||||
if (event.sourceInstanceId !== expectedSourceInstanceId) {
|
||||
throw new Error("native_event_source_binding_mismatch");
|
||||
}
|
||||
const persisted = await appendHeartbeatRunEvent(this.#db, {
|
||||
companyId: this.#binding.companyId,
|
||||
runId: this.#binding.runId,
|
||||
agentId: this.#binding.agentId,
|
||||
eventType: event.eventType,
|
||||
stream: "system",
|
||||
level: event.eventType.includes("failed") ? "error" : "info",
|
||||
payload: { prpEvent: event as unknown as Record<string, unknown> },
|
||||
nativeSource: {
|
||||
sourceInstanceId: event.sourceInstanceId,
|
||||
sourceEventId: event.sourceEventId,
|
||||
sourceSeq: event.sourceSeq,
|
||||
protocolSchemaVersion: event.schemaVersion,
|
||||
canonicalPayload: event as unknown as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
return {
|
||||
cursor: persisted.row.seq,
|
||||
highestContiguousSourceSeq: persisted.highestContiguousSourceSeq,
|
||||
disposition: persisted.disposition,
|
||||
};
|
||||
}
|
||||
|
||||
async replayEvents(input: ReplayControlPlaneEventsInput) {
|
||||
if (
|
||||
input.runId !== this.#binding.runId
|
||||
|| ![this.#binding.sourceInstanceId, this.#binding.controlPlaneSourceInstanceId].includes(input.sourceInstanceId)
|
||||
) {
|
||||
throw new Error("native_replay_binding_mismatch");
|
||||
}
|
||||
const rows = await this.#db
|
||||
.select({ payload: heartbeatRunEvents.payload, sourceSeq: heartbeatRunEvents.sourceSeq })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(
|
||||
eq(heartbeatRunEvents.runId, this.#binding.runId),
|
||||
eq(heartbeatRunEvents.sourceInstanceId, input.sourceInstanceId),
|
||||
gt(heartbeatRunEvents.sourceSeq, input.afterSourceSeq),
|
||||
))
|
||||
.orderBy(asc(heartbeatRunEvents.sourceSeq))
|
||||
.limit(Math.max(1, Math.min(input.limit, 1_000)));
|
||||
const events = rows.flatMap((row) => {
|
||||
const candidate = row.payload?.prpEvent;
|
||||
const parsed = validatePrpEvent(candidate);
|
||||
return parsed.ok ? [parsed.event] : [];
|
||||
});
|
||||
let cursor = input.afterSourceSeq;
|
||||
for (const event of events) {
|
||||
if (event.sourceSeq === cursor + 1) cursor += 1;
|
||||
else if (event.sourceSeq > cursor + 1) break;
|
||||
}
|
||||
return { events, highestContiguousSourceSeq: cursor };
|
||||
}
|
||||
|
||||
async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput): Promise<void> {
|
||||
if (!isCompleteInput(value)) throw new Error("native_structured_result_required");
|
||||
const validated = validatePrpStructuredRunResult(value.result);
|
||||
if (!validated.ok) throw new Error(`native_result_schema_invalid:${validated.issues[0]?.message ?? "unknown"}`);
|
||||
assertTerminal(value.terminal);
|
||||
if (validated.result.reportedWorkDisposition !== value.terminal.reportedWorkDisposition) {
|
||||
throw new Error("native_result_terminal_disposition_mismatch");
|
||||
}
|
||||
const canonical = {
|
||||
binding: this.#binding,
|
||||
result: validated.result,
|
||||
terminal: value.terminal,
|
||||
turnId: value.turnId ?? null,
|
||||
};
|
||||
const canonicalSha256 = nativeSha256(canonical);
|
||||
const serverFingerprint = nativeSha256({
|
||||
runId: this.#binding.runId,
|
||||
completionContractSha256: this.#binding.completionContractSha256,
|
||||
canonicalSha256,
|
||||
});
|
||||
|
||||
await this.#db.transaction(async (tx) => {
|
||||
const run = await tx.select().from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, this.#binding.runId)).for("update").limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!run
|
||||
|| run.companyId !== this.#binding.companyId
|
||||
|| run.runtimeMode !== "native"
|
||||
|| run.completionContractId !== this.#binding.completionContractId
|
||||
|| run.completionContractSha256 !== this.#binding.completionContractSha256
|
||||
) throw new Error("native_result_binding_mismatch");
|
||||
|
||||
const callerConditions = [eq(nativeRunResults.serverFingerprint, serverFingerprint)];
|
||||
if (value.callerResultId) callerConditions.push(eq(nativeRunResults.callerResultId, value.callerResultId));
|
||||
if (value.callerDedupeKey) callerConditions.push(eq(nativeRunResults.callerDedupeKey, value.callerDedupeKey));
|
||||
const existing = await tx.select().from(nativeRunResults)
|
||||
.where(and(eq(nativeRunResults.runId, this.#binding.runId), or(...callerConditions)))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (existing) {
|
||||
if (existing.canonicalSha256 !== canonicalSha256) throw new Error("native_result_idempotency_conflict");
|
||||
return;
|
||||
}
|
||||
const resultJson = {
|
||||
result: validated.result,
|
||||
terminal: value.terminal,
|
||||
} as unknown as Record<string, unknown>;
|
||||
const inserted = await tx.insert(nativeRunResults).values({
|
||||
companyId: this.#binding.companyId,
|
||||
issueId: this.#binding.issueId,
|
||||
runId: this.#binding.runId,
|
||||
turnId: value.turnId ?? null,
|
||||
completionContractId: this.#binding.completionContractId,
|
||||
callerResultId: value.callerResultId ?? null,
|
||||
callerDedupeKey: value.callerDedupeKey ?? null,
|
||||
serverFingerprint,
|
||||
schemaStatus: "accepted",
|
||||
resultJson,
|
||||
canonicalSha256,
|
||||
}).returning().then((rows) => rows[0]);
|
||||
if (!inserted) throw new Error("native_result_not_persisted");
|
||||
await tx.insert(nativeRunFinalizations).values({
|
||||
runId: this.#binding.runId,
|
||||
companyId: this.#binding.companyId,
|
||||
issueId: this.#binding.issueId,
|
||||
phase: "result_persisted",
|
||||
resultId: inserted.id,
|
||||
}).onConflictDoUpdate({
|
||||
target: nativeRunFinalizations.runId,
|
||||
set: { phase: "result_persisted", resultId: inserted.id, failureCode: null, failureDetail: null, updatedAt: new Date() },
|
||||
});
|
||||
await tx.update(heartbeatRuns).set({
|
||||
nativePhase: "result_persisted",
|
||||
nativePhaseUpdatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, this.#binding.runId));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NativeRuntimeEligibilityError, resolveNativeRuntimeMode } from "./runtime-mode.js";
|
||||
|
||||
const eligible = {
|
||||
enabled: true,
|
||||
runtimeConfig: { nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 } },
|
||||
agent: { status: "running", adapterType: "codex_local" },
|
||||
issue: { id: "issue", workMode: "standard" },
|
||||
target: { kind: "local" },
|
||||
workspaceId: "workspace",
|
||||
} as const;
|
||||
|
||||
describe("resolveNativeRuntimeMode", () => {
|
||||
it("preserves legacy as the default and as the kill-switch behavior", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, runtimeConfig: {} }).kind).toBe("legacy");
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false })).toEqual(expect.objectContaining({
|
||||
kind: "legacy",
|
||||
reason: "instance_flag_disabled",
|
||||
}));
|
||||
});
|
||||
|
||||
it("selects native only for an eligible explicit profile", () => {
|
||||
expect(resolveNativeRuntimeMode(eligible)).toEqual(expect.objectContaining({
|
||||
kind: "native",
|
||||
reason: "eligible_opt_in",
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects an explicit native profile outside the approved boundary", () => {
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
|
||||
.toThrow(NativeRuntimeEligibilityError);
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "skill_test" } }))
|
||||
.toThrow(NativeRuntimeEligibilityError);
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, target: { kind: "remote" } }))
|
||||
.toThrow(NativeRuntimeEligibilityError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export const NATIVE_RUNTIME_RESOLVER_VERSION = "phase6-v1" as const;
|
||||
|
||||
export type NativeRuntimeResolution =
|
||||
| { kind: "legacy"; resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION; reason: string }
|
||||
| {
|
||||
kind: "native";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "eligible_opt_in";
|
||||
profile: { mode: "native"; backend: "codex_app_server"; protocolVersion: 1 };
|
||||
};
|
||||
|
||||
export class NativeRuntimeEligibilityError extends Error {
|
||||
readonly code = "native_runtime_ineligible" as const;
|
||||
constructor(reason: string) {
|
||||
super(`Native runner profile is ineligible: ${reason}`);
|
||||
this.name = "NativeRuntimeEligibilityError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function resolveNativeRuntimeMode(input: {
|
||||
enabled: boolean;
|
||||
runtimeConfig: unknown;
|
||||
agent: { status: string; adapterType: string | null };
|
||||
issue: { id: string; workMode: string; executionWorkspaceId?: string | null } | null;
|
||||
target: { kind?: string } | null | undefined;
|
||||
workspaceId: string | null;
|
||||
}): NativeRuntimeResolution {
|
||||
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
|
||||
const mode = nativeRunner.mode;
|
||||
if (!input.enabled) {
|
||||
return { kind: "legacy", resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION, reason: "instance_flag_disabled" };
|
||||
}
|
||||
if (mode === undefined || mode === "legacy") {
|
||||
return { kind: "legacy", resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION, reason: "agent_not_opted_in" };
|
||||
}
|
||||
if (mode !== "native") throw new NativeRuntimeEligibilityError("unsupported profile mode");
|
||||
if (nativeRunner.backend !== "codex_app_server" || nativeRunner.protocolVersion !== 1) {
|
||||
throw new NativeRuntimeEligibilityError("unsupported backend or protocol version");
|
||||
}
|
||||
if (input.agent.adapterType !== "codex_local" || input.agent.status !== "active" && input.agent.status !== "running") {
|
||||
throw new NativeRuntimeEligibilityError("agent must be an active codex_local agent");
|
||||
}
|
||||
if (!input.issue || input.issue.workMode !== "standard") {
|
||||
throw new NativeRuntimeEligibilityError("run must be bound to a standard issue");
|
||||
}
|
||||
if (!input.workspaceId || input.target?.kind && input.target.kind !== "local") {
|
||||
throw new NativeRuntimeEligibilityError("a realized local workspace is required");
|
||||
}
|
||||
return {
|
||||
kind: "native",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "eligible_opt_in",
|
||||
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyNativeEvidence } from "./evidence-classifier.js";
|
||||
import { arbitrateNativeStatus } from "./status-arbiter.js";
|
||||
|
||||
function result(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reportedWorkDisposition: "done",
|
||||
summary: "Complete",
|
||||
completionClaim: {
|
||||
objectiveSatisfied: true,
|
||||
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: ["test"] }],
|
||||
remainingWork: [],
|
||||
},
|
||||
verification: [{ commandOrCheck: "test", status: "passed" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("native status authority", () => {
|
||||
it("marks done only from successful finalization and complete evidence", () => {
|
||||
const assessment = classifyNativeEvidence(result());
|
||||
expect(arbitrateNativeStatus({
|
||||
assessment,
|
||||
terminalState: "succeeded",
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
agentId: "agent",
|
||||
}).toStatus).toBe("done");
|
||||
});
|
||||
|
||||
it("keeps incomplete, review, cancelled, and yielded work non-terminal", () => {
|
||||
for (const reportedWorkDisposition of ["needs_review", "yielded"] as const) {
|
||||
expect(arbitrateNativeStatus({
|
||||
assessment: classifyNativeEvidence(result({ reportedWorkDisposition })),
|
||||
terminalState: "succeeded",
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
agentId: "agent",
|
||||
}).toStatus).toBe("in_progress");
|
||||
}
|
||||
expect(arbitrateNativeStatus({
|
||||
assessment: classifyNativeEvidence(result()),
|
||||
terminalState: "cancelled",
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
agentId: "agent",
|
||||
}).toStatus).toBe("in_progress");
|
||||
expect(arbitrateNativeStatus({
|
||||
assessment: classifyNativeEvidence(result()),
|
||||
terminalState: "succeeded",
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
approvalGateSatisfied: false,
|
||||
agentId: "agent",
|
||||
})).toEqual(expect.objectContaining({
|
||||
toStatus: "in_progress",
|
||||
reasonCode: "approval_gate_pending",
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks only on a first-class blocker or failed workspace finalization", () => {
|
||||
const explicitBlocker = classifyNativeEvidence(result({
|
||||
reportedWorkDisposition: "blocked",
|
||||
blocker: { owner: { kind: "board" }, unblockAction: "Approve access" },
|
||||
}));
|
||||
expect(arbitrateNativeStatus({
|
||||
assessment: explicitBlocker,
|
||||
terminalState: "succeeded",
|
||||
workspaceFinalizeStatus: "succeeded",
|
||||
agentId: "agent",
|
||||
})).toEqual(expect.objectContaining({
|
||||
toStatus: "blocked",
|
||||
unblockDescriptor: { owner: "board", action: "Approve access" },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
|
||||
|
||||
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v1";
|
||||
|
||||
export interface NativeStatusDecision {
|
||||
policyVersion: typeof NATIVE_STATUS_ARBITER_POLICY_VERSION;
|
||||
toStatus: "in_progress" | "done" | "blocked";
|
||||
reasonCode: string;
|
||||
unblockDescriptor: { owner: { agentId: string } | "board"; action: string } | null;
|
||||
}
|
||||
|
||||
/** Pure authority boundary: model prose is evidence, never a status command. */
|
||||
export function arbitrateNativeStatus(input: {
|
||||
assessment: NativeEvidenceAssessment;
|
||||
terminalState: "succeeded" | "failed" | "cancelled";
|
||||
workspaceFinalizeStatus: "succeeded" | "failed";
|
||||
approvalGateSatisfied?: boolean;
|
||||
agentId: string;
|
||||
}): NativeStatusDecision {
|
||||
if (input.workspaceFinalizeStatus !== "succeeded") {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
toStatus: "blocked",
|
||||
reasonCode: "workspace_finalization_failed",
|
||||
unblockDescriptor: { owner: { agentId: input.agentId }, action: "Repair and re-verify workspace finalization." },
|
||||
};
|
||||
}
|
||||
if (input.terminalState !== "succeeded") {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
toStatus: "in_progress",
|
||||
reasonCode: input.terminalState === "cancelled" ? "native_run_cancelled" : "native_run_failed",
|
||||
unblockDescriptor: null,
|
||||
};
|
||||
}
|
||||
if (input.approvalGateSatisfied === false) {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
toStatus: "in_progress",
|
||||
reasonCode: "approval_gate_pending",
|
||||
unblockDescriptor: null,
|
||||
};
|
||||
}
|
||||
const complete =
|
||||
input.assessment.reportedDisposition === "done" &&
|
||||
input.assessment.objectiveSatisfied &&
|
||||
input.assessment.allCriteriaSatisfied &&
|
||||
input.assessment.verificationPassed &&
|
||||
!input.assessment.hasBlockingRemainingWork;
|
||||
if (complete) {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
toStatus: "done",
|
||||
reasonCode: "completion_contract_satisfied",
|
||||
unblockDescriptor: null,
|
||||
};
|
||||
}
|
||||
if (input.assessment.reportedDisposition === "blocked" && input.assessment.blocker) {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
toStatus: "blocked",
|
||||
reasonCode: "native_reported_blocker",
|
||||
unblockDescriptor: {
|
||||
owner: input.assessment.blocker.boardOwned ? "board" : { agentId: input.agentId },
|
||||
action: input.assessment.blocker.unblockAction,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
toStatus: "in_progress",
|
||||
reasonCode: input.assessment.reportedDisposition === "needs_review"
|
||||
? "native_review_path_required"
|
||||
: input.assessment.reportedDisposition === "yielded"
|
||||
? "native_continuation_required"
|
||||
: "native_completion_incomplete",
|
||||
unblockDescriptor: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
issues,
|
||||
activityLog,
|
||||
nativeRunFinalizations,
|
||||
statusDecisionEffects,
|
||||
statusDecisions,
|
||||
} from "@paperclipai/db";
|
||||
import type { NativeStatusDecision } from "./status-arbiter.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export class NativeStatusRaceError extends Error {
|
||||
readonly code = "native_status_race" as const;
|
||||
constructor() {
|
||||
super("native_status_race");
|
||||
this.name = "NativeStatusRaceError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitNativeStatusDecision(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
assessmentId: string;
|
||||
priorStatus: string;
|
||||
priorStatusVersion: number;
|
||||
decision: NativeStatusDecision;
|
||||
}) {
|
||||
return input.db.transaction(async (tx) => {
|
||||
const issue = await tx.select().from(issues).where(and(
|
||||
eq(issues.id, input.issueId),
|
||||
eq(issues.companyId, input.companyId),
|
||||
)).for("update").limit(1).then((rows) => rows[0] ?? null);
|
||||
if (!issue || issue.status !== input.priorStatus || issue.statusVersion !== input.priorStatusVersion) {
|
||||
throw new NativeStatusRaceError();
|
||||
}
|
||||
const decisionJson = {
|
||||
toStatus: input.decision.toStatus,
|
||||
reasonCode: input.decision.reasonCode,
|
||||
unblockDescriptor: input.decision.unblockDescriptor,
|
||||
};
|
||||
const decisionDigest = nativeSha256({
|
||||
issueId: input.issueId,
|
||||
assessmentId: input.assessmentId,
|
||||
policyVersion: input.decision.policyVersion,
|
||||
fromStatus: issue.status,
|
||||
decision: decisionJson,
|
||||
});
|
||||
let decisionRow = await tx.select().from(statusDecisions).where(and(
|
||||
eq(statusDecisions.issueId, input.issueId),
|
||||
eq(statusDecisions.decisionDigest, decisionDigest),
|
||||
)).limit(1).then((rows) => rows[0] ?? null);
|
||||
if (!decisionRow) {
|
||||
[decisionRow] = await tx.insert(statusDecisions).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
assessmentId: input.assessmentId,
|
||||
decisionVersion: 1,
|
||||
policyVersion: input.decision.policyVersion,
|
||||
fromStatus: issue.status,
|
||||
toStatus: input.decision.toStatus,
|
||||
reasonCode: input.decision.reasonCode,
|
||||
decisionJson,
|
||||
decisionDigest,
|
||||
applicationState: "pending",
|
||||
}).returning();
|
||||
}
|
||||
if (!decisionRow) throw new Error("native_status_decision_not_persisted");
|
||||
const updated = await tx.update(issues).set({
|
||||
status: input.decision.toStatus,
|
||||
unblockDescriptor: input.decision.unblockDescriptor,
|
||||
lastStatusDecisionId: decisionRow.id,
|
||||
completedAt: input.decision.toStatus === "done" ? new Date() : null,
|
||||
blockedTransitionAt: input.decision.toStatus === "blocked" ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(issues.id, input.issueId),
|
||||
eq(issues.statusVersion, input.priorStatusVersion),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (!updated) throw new NativeStatusRaceError();
|
||||
await tx.insert(statusDecisionEffects).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
decisionId: decisionRow.id,
|
||||
ordinal: 1,
|
||||
effectKind: "issue_status_projection",
|
||||
targetType: "issue",
|
||||
targetId: input.issueId,
|
||||
idempotencyKey: `native-status:${decisionRow.id}:1`,
|
||||
payload: {
|
||||
fromStatus: issue.status,
|
||||
toStatus: input.decision.toStatus,
|
||||
reasonCode: input.decision.reasonCode,
|
||||
},
|
||||
deliveryState: "delivered",
|
||||
attemptCount: 1,
|
||||
deliveredAt: new Date(),
|
||||
}).onConflictDoNothing();
|
||||
await tx.update(statusDecisions).set({
|
||||
applicationState: "applied",
|
||||
appliedAt: new Date(),
|
||||
}).where(eq(statusDecisions.id, decisionRow.id));
|
||||
await tx.update(nativeRunFinalizations).set({
|
||||
phase: "applied",
|
||||
assessmentId: input.assessmentId,
|
||||
decisionId: decisionRow.id,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(nativeRunFinalizations.runId, input.runId));
|
||||
await tx.insert(activityLog).values({
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "native-status-committer",
|
||||
action: "issue.native_status_decision_applied",
|
||||
entityType: "issue",
|
||||
entityId: input.issueId,
|
||||
runId: input.runId,
|
||||
details: {
|
||||
assessmentId: input.assessmentId,
|
||||
decisionId: decisionRow.id,
|
||||
fromStatus: issue.status,
|
||||
toStatus: input.decision.toStatus,
|
||||
reasonCode: input.decision.reasonCode,
|
||||
},
|
||||
});
|
||||
return { decision: { ...decisionRow, applicationState: "applied" }, issue: updated };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { workAssessments } from "@paperclipai/db";
|
||||
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export async function recordNativeWorkAssessment(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
turnId: string | null;
|
||||
contractId: string;
|
||||
resultId: string;
|
||||
priorIssueStatus: string;
|
||||
priorStatusVersion: number;
|
||||
priorDecisionId: string | null;
|
||||
policyVersion: string;
|
||||
assessment: NativeEvidenceAssessment;
|
||||
supersedesAssessmentId?: string | null;
|
||||
}) {
|
||||
const assessmentJson = input.assessment as unknown as Record<string, unknown>;
|
||||
const inputDigest = nativeSha256({
|
||||
contractId: input.contractId,
|
||||
resultId: input.resultId,
|
||||
priorIssueStatus: input.priorIssueStatus,
|
||||
priorStatusVersion: input.priorStatusVersion,
|
||||
policyVersion: input.policyVersion,
|
||||
assessment: assessmentJson,
|
||||
});
|
||||
const existing = await input.db.select().from(workAssessments).where(and(
|
||||
eq(workAssessments.resultId, input.resultId),
|
||||
eq(workAssessments.inputDigest, inputDigest),
|
||||
)).limit(1).then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
const [row] = await input.db.insert(workAssessments).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
runId: input.runId,
|
||||
turnId: input.turnId,
|
||||
contractId: input.contractId,
|
||||
resultId: input.resultId,
|
||||
triggerKind: "native_result",
|
||||
triggerRef: input.resultId,
|
||||
triggerCapability: "server_native_finalizer",
|
||||
triggerActorCompanyId: input.companyId,
|
||||
priorIssueStatus: input.priorIssueStatus,
|
||||
priorStatusVersion: input.priorStatusVersion,
|
||||
priorDecisionId: input.priorDecisionId,
|
||||
policyVersion: input.policyVersion,
|
||||
assessmentJson,
|
||||
inputDigest,
|
||||
supersedesAssessmentId: input.supersedesAssessmentId ?? null,
|
||||
}).returning();
|
||||
if (!row) throw new Error("native_assessment_not_persisted");
|
||||
return row;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { isPidAlive, isProcessGroupAlive, terminateLocalService } from "../local
|
||||
import { redactCurrentUserText } from "../../log-redaction.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
|
||||
import { budgetService } from "../budgets.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
@@ -1559,14 +1560,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return null;
|
||||
}
|
||||
|
||||
async function nextRunEventSeq(runId: string) {
|
||||
const [row] = await db
|
||||
.select({ maxSeq: sql<number | null>`max(${heartbeatRunEvents.seq})` })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, runId));
|
||||
return Number(row?.maxSeq ?? 0) + 1;
|
||||
}
|
||||
|
||||
async function appendRecoveryRunEvent(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
event: {
|
||||
@@ -1575,11 +1568,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
await appendHeartbeatRunEvent(db, {
|
||||
companyId: run.companyId,
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
seq: await nextRunEventSeq(run.id),
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: event.level,
|
||||
|
||||
Reference in new issue
Block a user