536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
import {
|
|
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
type NativeAuthoritativeIssueStatus,
|
|
type NativeStatusDecision,
|
|
} from "./status-arbiter.js";
|
|
import {
|
|
PaperclipRunnerProviderProfileError,
|
|
resolvePaperclipRunnerProviderProfile,
|
|
type PaperclipRunnerProviderProfile,
|
|
} from "./provider-profile.js";
|
|
|
|
/**
|
|
* Public compatibility resolver version. This value is persisted by the
|
|
* original heartbeat selection seam and must remain stable for existing runs
|
|
* and downstream importers.
|
|
*/
|
|
export const NATIVE_RUNTIME_RESOLVER_VERSION = "paperclip-runner-v1" as const;
|
|
|
|
/** Resolver version for the richer native runtime profile used by runnerd. */
|
|
export const NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION = "phase6-v1" as const;
|
|
|
|
export type HeartbeatRuntimeResolution =
|
|
| {
|
|
kind: "legacy";
|
|
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
|
reason: "direct_adapter" | "persisted_legacy_selection";
|
|
}
|
|
| {
|
|
kind: "native";
|
|
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
|
reason: "explicit_paperclip_runner" | "persisted_native_selection";
|
|
provider: PaperclipRunnerProviderProfile["provider"];
|
|
};
|
|
|
|
export type NativeRuntimeResolution =
|
|
| {
|
|
kind: "legacy";
|
|
resolverVersion: typeof NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION;
|
|
reason: string;
|
|
authorityDecision?: NativeStatusDecision;
|
|
}
|
|
| {
|
|
kind: "native";
|
|
resolverVersion: typeof NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION;
|
|
reason: "eligible_opt_in";
|
|
profile: {
|
|
mode: "native";
|
|
backend:
|
|
| "codex_app_server"
|
|
| "opencode_server"
|
|
| "claude_managed_agents_api"
|
|
| "aws_agentcore_harness_api"
|
|
| "acpx_runtime";
|
|
protocolVersion: 1;
|
|
};
|
|
authorityDecision: NativeStatusDecision;
|
|
};
|
|
|
|
/**
|
|
* Runner ingress follows the resolved runtime decision, not a second instance
|
|
* flag. Persisted native runs therefore keep their transport during recovery
|
|
* after the rollout flag is disabled, while legacy runs never gain ingress.
|
|
*/
|
|
export function isRunnerIngressAuthorized(
|
|
resolution: NativeRuntimeResolution,
|
|
): boolean {
|
|
return resolution.kind === "native";
|
|
}
|
|
|
|
export class NativeRunnerSelectionError extends Error {
|
|
constructor(readonly code: string, message: string) {
|
|
super(message);
|
|
this.name = "NativeRunnerSelectionError";
|
|
}
|
|
}
|
|
|
|
export class NativeRuntimeEligibilityError extends NativeRunnerSelectionError {
|
|
constructor(
|
|
code: string,
|
|
reason?: string,
|
|
) {
|
|
super(code, reason ?? `Native runner profile is ineligible: ${code}`);
|
|
this.name = "NativeRuntimeEligibilityError";
|
|
}
|
|
}
|
|
|
|
function ineligible(
|
|
code: string,
|
|
reason: string,
|
|
): NativeRuntimeEligibilityError {
|
|
return new NativeRuntimeEligibilityError(
|
|
code,
|
|
reason,
|
|
);
|
|
}
|
|
|
|
export function resolveNativeRuntimeMode(input: {
|
|
enabled: boolean;
|
|
runtimeConfig: unknown;
|
|
adapterConfig?: unknown;
|
|
agent: { id?: string; status: string; adapterType: string | null };
|
|
issue: { id: string; workMode: string; executionWorkspaceId?: string | null } | null;
|
|
target: { kind?: string } | null | undefined;
|
|
workspaceId: string | null;
|
|
}): NativeRuntimeResolution {
|
|
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
|
|
// Fresh direct-adapter runs never enter the native control plane, even if an
|
|
// obsolete runtimeConfig.nativeRunner value is still present. Persisted
|
|
// native runs are handled by resolveHeartbeatNativeRuntimeMode above this
|
|
// fresh-selection seam so they remain recoverable after rollout changes.
|
|
if (!runnerAdapterSelected) {
|
|
return {
|
|
kind: "legacy",
|
|
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
|
|
reason: "direct_adapter",
|
|
};
|
|
}
|
|
if (!input.enabled) {
|
|
throw ineligible(
|
|
"paperclip_runner_rollout_disabled",
|
|
"Paperclip Runner is experimental and disabled on this instance.",
|
|
);
|
|
}
|
|
let runnerProfile: PaperclipRunnerProviderProfile;
|
|
try {
|
|
runnerProfile = resolvePaperclipRunnerProviderProfile(input.adapterConfig);
|
|
} catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) {
|
|
throw ineligible(error.code, error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
if (
|
|
input.agent.adapterType !== "paperclip_runner"
|
|
|| input.agent.status !== "active" && input.agent.status !== "running"
|
|
) {
|
|
throw ineligible(
|
|
"paperclip_runner_agent_ineligible",
|
|
"Paperclip Runner requires an active agent.",
|
|
);
|
|
}
|
|
const allowedWorkModes = ["standard", "planning", "ask"];
|
|
if (!input.issue || !allowedWorkModes.includes(input.issue.workMode)) {
|
|
throw ineligible(
|
|
"paperclip_runner_issue_ineligible",
|
|
"Paperclip Runner requires a standard, planning, or ask task.",
|
|
);
|
|
}
|
|
const rollout = resolveNativeMigrationStatus({
|
|
facts: { applicationEnabled: true },
|
|
priorIssueStatus: "in_progress",
|
|
agentId: input.agent.id ?? "00000000-0000-4000-8000-000000000000",
|
|
});
|
|
if (!rollout.effects.some((effect) => effect.kind === "record_mode_native")) {
|
|
throw ineligible(
|
|
"paperclip_runner_rollout_policy_rejected",
|
|
"Native rollout policy did not select native mode.",
|
|
);
|
|
}
|
|
return {
|
|
kind: "native",
|
|
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
|
|
reason: "eligible_opt_in",
|
|
profile: {
|
|
mode: "native",
|
|
backend: runnerProfile.backend,
|
|
protocolVersion: 1,
|
|
},
|
|
authorityDecision: rollout,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Backward-compatible heartbeat selection API. New runnerd code consumes the
|
|
* richer profile from resolveHeartbeatNativeRuntimeMode; this public seam
|
|
* keeps the original result shape, reason codes, and resolver version.
|
|
*/
|
|
export function resolveHeartbeatRuntimeMode(input: {
|
|
persisted: {
|
|
runtimeMode: string | null;
|
|
runtimeModeResolvedAt: Date | null;
|
|
};
|
|
enabled: boolean;
|
|
adapterType: string | null;
|
|
adapterConfig: unknown;
|
|
agentStatus: string;
|
|
issue: { workMode: string } | null;
|
|
executionTarget: { kind?: string } | null | undefined;
|
|
}): HeartbeatRuntimeResolution {
|
|
if (input.persisted.runtimeModeResolvedAt) {
|
|
if (input.persisted.runtimeMode === "native") {
|
|
return {
|
|
kind: "native",
|
|
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
|
reason: "persisted_native_selection",
|
|
provider: "codex",
|
|
};
|
|
}
|
|
return {
|
|
kind: "legacy",
|
|
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
|
reason: "persisted_legacy_selection",
|
|
};
|
|
}
|
|
|
|
let resolution: NativeRuntimeResolution;
|
|
try {
|
|
resolution = resolveNativeRuntimeMode({
|
|
enabled: input.enabled,
|
|
runtimeConfig: {},
|
|
adapterConfig: input.adapterConfig,
|
|
agent: {
|
|
status: input.agentStatus,
|
|
adapterType: input.adapterType,
|
|
},
|
|
issue: input.issue
|
|
? { id: "heartbeat-runtime-selection", workMode: input.issue.workMode }
|
|
: null,
|
|
target: input.executionTarget,
|
|
workspaceId: null,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof NativeRuntimeEligibilityError) {
|
|
throw new NativeRunnerSelectionError(error.code, error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
if (resolution.kind === "legacy") {
|
|
return {
|
|
kind: "legacy",
|
|
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
|
reason: "direct_adapter",
|
|
};
|
|
}
|
|
return {
|
|
kind: "native",
|
|
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
|
reason: "explicit_paperclip_runner",
|
|
provider: resolution.profile.backend === "opencode_server"
|
|
? "opencode"
|
|
: resolution.profile.backend === "claude_managed_agents_api"
|
|
? "claude_managed"
|
|
: resolution.profile.backend === "aws_agentcore_harness_api"
|
|
? "aws_agentcore"
|
|
: resolution.profile.backend === "acpx_runtime"
|
|
? "acpx"
|
|
: "codex",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Production heartbeat selection seam. A resolved run keeps its persisted
|
|
* mode across configuration changes; only a fresh unresolved run consults the
|
|
* current global flag and agent profile.
|
|
*/
|
|
export function resolveHeartbeatNativeRuntimeMode(input: {
|
|
persisted: {
|
|
runtimeMode: string | null;
|
|
runtimeModeReason: string | null;
|
|
runtimeModeResolvedAt: Date | null;
|
|
driverKind?: string | null;
|
|
};
|
|
enabled: boolean;
|
|
runtimeConfig: unknown;
|
|
adapterConfig?: unknown;
|
|
agent: { id?: string; status: string; adapterType: string | null };
|
|
issue: { id: string; workMode: string; executionWorkspaceId?: string | null } | null;
|
|
target: { kind?: string } | null | undefined;
|
|
workspaceId: string | null;
|
|
}): NativeRuntimeResolution {
|
|
if (input.persisted.runtimeModeResolvedAt) {
|
|
if (input.persisted.runtimeMode === "native") {
|
|
if (input.agent.adapterType !== "paperclip_runner") {
|
|
throw ineligible(
|
|
"paperclip_runner_adapter_binding_mismatch",
|
|
"A persisted native run must remain bound to the Paperclip Runner adapter.",
|
|
);
|
|
}
|
|
if (
|
|
input.agent.status !== "active" &&
|
|
input.agent.status !== "running"
|
|
) {
|
|
throw ineligible(
|
|
"paperclip_runner_agent_ineligible",
|
|
"A persisted Paperclip Runner run cannot recover through a non-invokable agent.",
|
|
);
|
|
}
|
|
const driverKind = input.persisted.driverKind;
|
|
const backend = driverKind === "opencode_server"
|
|
? "opencode_server"
|
|
: driverKind === "claude_managed_agents_api"
|
|
? "claude_managed_agents_api"
|
|
: driverKind === "aws_agentcore_harness_api"
|
|
? "aws_agentcore_harness_api"
|
|
: driverKind === "acpx_runtime"
|
|
? "acpx_runtime"
|
|
: driverKind === null
|
|
|| driverKind === undefined
|
|
|| driverKind === "codex"
|
|
|| driverKind === "codex_app_server"
|
|
? "codex_app_server"
|
|
: null;
|
|
if (backend === null) {
|
|
throw ineligible(
|
|
"paperclip_runner_driver_unsupported",
|
|
`Persisted Paperclip Runner driver is unsupported: ${driverKind}`,
|
|
);
|
|
}
|
|
return {
|
|
kind: "native",
|
|
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
|
|
reason: "eligible_opt_in",
|
|
profile: {
|
|
mode: "native",
|
|
backend,
|
|
protocolVersion: 1,
|
|
},
|
|
authorityDecision: resolveNativeMigrationStatus({
|
|
facts: input.enabled
|
|
? { applicationEnabled: true }
|
|
: { killSwitchActiveForNewRuns: true },
|
|
priorIssueStatus: "in_progress",
|
|
agentId: input.agent.id ?? "00000000-0000-4000-8000-000000000000",
|
|
}),
|
|
};
|
|
}
|
|
return {
|
|
kind: "legacy",
|
|
resolverVersion: NATIVE_RUNTIME_PROFILE_RESOLVER_VERSION,
|
|
reason: input.persisted.runtimeModeReason ?? "persisted_legacy_selection",
|
|
};
|
|
}
|
|
return resolveNativeRuntimeMode(input);
|
|
}
|
|
|
|
/** Production read-model facts used by compatibility and mixed-ledger views. */
|
|
export function inspectNativeCompatibilityState(input: {
|
|
resolution: NativeRuntimeResolution;
|
|
nativeRecordCount: number;
|
|
decisionCount: number;
|
|
issueStatus: string;
|
|
statusVersion: number;
|
|
persistedEffectKinds: string[];
|
|
}) {
|
|
const effects = input.persistedEffectKinds.length > 0
|
|
? [...input.persistedEffectKinds]
|
|
: input.resolution.kind === "legacy"
|
|
? ["legacy_existing_behavior"]
|
|
: input.nativeRecordCount === 0 && input.statusVersion === 0
|
|
? ["initialize_status_version_zero"]
|
|
: [];
|
|
return {
|
|
mode: input.resolution.kind,
|
|
native: input.nativeRecordCount > 0,
|
|
hasNativeDecisionLineage: input.decisionCount > 0,
|
|
issueStatus: input.issueStatus,
|
|
statusVersion: input.statusVersion,
|
|
statusAction: input.resolution.kind === "legacy" ? "legacy_finalizer" : "preserve",
|
|
reasonCode: null,
|
|
effects,
|
|
} as const;
|
|
}
|
|
|
|
/** Expand-only migration evidence; it never mutates or synthesizes history. */
|
|
export function inspectNativeMigrationState(input: {
|
|
resolution: NativeRuntimeResolution;
|
|
nativeRecordCount: number;
|
|
decisionCount: number;
|
|
issueStatusBefore: string;
|
|
issueStatusAfter: string;
|
|
statusVersion: number;
|
|
hasPendingReview: boolean;
|
|
}) {
|
|
const effects = input.resolution.kind === "legacy"
|
|
? input.issueStatusBefore === "done"
|
|
? ["retain_legacy_mode", "retain_audit_lineage"]
|
|
: ["return_native_false"]
|
|
: input.nativeRecordCount === 0 && input.hasPendingReview && input.statusVersion > 0
|
|
? ["increment_status_version_once", "bind_reviewer"]
|
|
: input.nativeRecordCount === 0
|
|
? ["expand_schema", "status_version_default_zero"]
|
|
: [];
|
|
return {
|
|
mode: input.resolution.kind,
|
|
native: input.nativeRecordCount > 0,
|
|
hasSyntheticHistory: input.nativeRecordCount === 0 && input.decisionCount > 0,
|
|
statusPreserved: input.issueStatusBefore === input.issueStatusAfter,
|
|
statusVersion: input.statusVersion,
|
|
statusAction: input.resolution.kind === "legacy" ? "legacy_finalizer"
|
|
: input.hasPendingReview ? input.issueStatusAfter : "preserve",
|
|
reasonCode: null,
|
|
effects,
|
|
} as const;
|
|
}
|
|
|
|
export type NativeCompatibilityFacts = {
|
|
invalidNativeFinalization?: boolean;
|
|
terminalResumeAuthorized?: boolean;
|
|
shadowApplicationDisabled?: boolean;
|
|
mixedLedger?: boolean;
|
|
statusWriterAdvancedVersion?: boolean;
|
|
};
|
|
|
|
export function resolveNativeCompatibilityStatus(input: {
|
|
facts: NativeCompatibilityFacts;
|
|
priorIssueStatus: NativeAuthoritativeIssueStatus;
|
|
agentId: string;
|
|
}): NativeStatusDecision {
|
|
const preserve = (reasonCode: string, effects: NativeStatusDecision["effects"]): NativeStatusDecision => ({
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "preserve",
|
|
toStatus: input.priorIssueStatus,
|
|
reasonCode,
|
|
unblockDescriptor: null,
|
|
effects,
|
|
});
|
|
if (input.facts.invalidNativeFinalization) {
|
|
return preserve("native_finalization_invalid", [{
|
|
kind: "record_finalization_error",
|
|
cause: "native_finalization_invalid",
|
|
nextAction: "Repair the persisted native result.",
|
|
agentId: input.agentId,
|
|
}]);
|
|
}
|
|
if (input.facts.terminalResumeAuthorized) {
|
|
return {
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "in_progress",
|
|
toStatus: "in_progress",
|
|
reasonCode: "authorized_resume",
|
|
unblockDescriptor: null,
|
|
effects: [{
|
|
kind: "enqueue_continuation",
|
|
continuationKind: "same_agent",
|
|
summary: "Resume the terminal issue through the authorized compatibility path.",
|
|
idempotencyKey: "native-compatibility:authorized-resume",
|
|
agentId: input.agentId,
|
|
}],
|
|
};
|
|
}
|
|
if (input.facts.shadowApplicationDisabled) {
|
|
return preserve("completion_contract_satisfied", [{ kind: "record_shadow_decision" }]);
|
|
}
|
|
if (input.facts.mixedLedger) {
|
|
return preserve("completion_contract_satisfied", [{ kind: "render_four_layers" }]);
|
|
}
|
|
if (input.facts.statusWriterAdvancedVersion) {
|
|
return preserve("arbitration_conflict_reloaded", [
|
|
{ kind: "increment_status_version" },
|
|
{ kind: "schedule_reconciliation" },
|
|
]);
|
|
}
|
|
throw new Error("native_compatibility_facts_invalid");
|
|
}
|
|
|
|
export type NativeMigrationFacts = {
|
|
shadowMaterialization?: boolean;
|
|
classifiedDivergence?: boolean;
|
|
applicationEnabled?: boolean;
|
|
policyPinned?: boolean;
|
|
killSwitchActiveForNewRuns?: boolean;
|
|
};
|
|
|
|
export function resolveNativeMigrationStatus(input: {
|
|
facts: NativeMigrationFacts;
|
|
priorIssueStatus: NativeAuthoritativeIssueStatus;
|
|
agentId: string;
|
|
}): NativeStatusDecision {
|
|
const preserve = (reasonCode: string, effects: NativeStatusDecision["effects"]): NativeStatusDecision => ({
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "preserve",
|
|
toStatus: input.priorIssueStatus,
|
|
reasonCode,
|
|
unblockDescriptor: null,
|
|
effects,
|
|
});
|
|
if (input.facts.shadowMaterialization) {
|
|
return preserve("completion_contract_satisfied", [
|
|
{ kind: "materialize_contract" },
|
|
{ kind: "record_shadow_decision" },
|
|
]);
|
|
}
|
|
if (input.facts.classifiedDivergence) {
|
|
return preserve("completion_evidence_incomplete", [{ kind: "record_mode_labeled_divergence" }]);
|
|
}
|
|
if (input.facts.killSwitchActiveForNewRuns) {
|
|
return {
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "in_progress",
|
|
toStatus: "in_progress",
|
|
reasonCode: "live_continuation_registered",
|
|
unblockDescriptor: null,
|
|
effects: [
|
|
{
|
|
kind: "enqueue_continuation",
|
|
continuationKind: "same_agent",
|
|
summary: "Finish the already-active run in native mode.",
|
|
idempotencyKey: "native-migration:kill-switch-active-run",
|
|
agentId: input.agentId,
|
|
},
|
|
{ kind: "finish_as_native" },
|
|
],
|
|
};
|
|
}
|
|
if (input.facts.policyPinned) {
|
|
return {
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "done",
|
|
toStatus: "done",
|
|
reasonCode: "completion_contract_satisfied",
|
|
unblockDescriptor: null,
|
|
effects: [{ kind: "record_mode_native" }, { kind: "record_policy_version" }],
|
|
};
|
|
}
|
|
if (input.facts.applicationEnabled) {
|
|
return {
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "in_progress",
|
|
toStatus: "in_progress",
|
|
reasonCode: "live_continuation_registered",
|
|
unblockDescriptor: null,
|
|
effects: [
|
|
{
|
|
kind: "enqueue_continuation",
|
|
continuationKind: "same_agent",
|
|
summary: "Continue the allowlisted native run.",
|
|
idempotencyKey: "native-migration:application-enabled",
|
|
agentId: input.agentId,
|
|
},
|
|
{ kind: "record_mode_native" },
|
|
],
|
|
};
|
|
}
|
|
throw new Error("native_migration_facts_invalid");
|
|
}
|