mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
Merge current master before controller evidence review
Co-Authored-By: Paperclip <noreply@paperclip.ing> * origin/master: fix(native): use current authority for warm plugin completion (#17444) fix(heartbeat): keep source-free project policy stable across turns (#17447)
This commit is contained in:
commit
31656ded92
4 files changed
+54
-6
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
@@ -81,6 +82,29 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
|
||||
expect(resumed.finished.sessionIdBefore).toBe("saved-session");
|
||||
});
|
||||
|
||||
it("keeps inherited project policy stable after binding a repository-free workspace", async () => {
|
||||
const f = await seed();
|
||||
const storage = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
|
||||
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
|
||||
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
|
||||
} } }).where(eq(plugins.id, storage.pluginId));
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Directory project" }).returning();
|
||||
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId));
|
||||
try {
|
||||
const first = await execute(f);
|
||||
const [bound] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
expect(bound.executionWorkspaceId).toEqual(expect.any(String));
|
||||
const next = await execute(f);
|
||||
expect(next.input.runtime.sessionId).toBe("saved-session");
|
||||
expect(next.input.config.workspaceStrategy).toEqual(first.input.config.workspaceStrategy);
|
||||
expect(next.finished.resultJson).toMatchObject({ configFreshness: {
|
||||
session: { reset: false, taskSessionReused: true, changedCategories: [] },
|
||||
workspace: { action: "reuse", workspaceReused: true, changedCategories: [], activeWorkspaceId: bound.executionWorkspaceId },
|
||||
} });
|
||||
} finally { await db.delete(plugins).where(eq(plugins.id, storage.pluginId)); }
|
||||
});
|
||||
|
||||
it("starts fresh when effective model configuration changes and persists the reason", async () => {
|
||||
const f = await seed();
|
||||
await execute(f);
|
||||
|
||||
@@ -2371,7 +2371,7 @@ export function heartbeatService(
|
||||
!boundSourceWorkspace && !taskWorkspaceIntentRow?.intent && !issueContext?.projectWorkspaceId
|
||||
? taskProjectContext : null
|
||||
);
|
||||
const parsedProjectExecutionWorkspacePolicy = workspacePolicyContext && !boundSourceWorkspace
|
||||
const parsedProjectExecutionWorkspacePolicy = workspacePolicyContext?.hasWorkspace && !boundSourceWorkspace
|
||||
? await effectiveProjectWorkspacePolicy(db, agent.companyId, workspacePolicyContext.executionWorkspacePolicy, workspacePolicyContext.hasGitSource)
|
||||
: parseProjectExecutionWorkspacePolicy(workspacePolicyContext?.executionWorkspacePolicy);
|
||||
const projectExecutionWorkspacePolicy =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as environmentTaskRuntime from "../environment-task-runtime.js";
|
||||
import * as pluginTaskRuntime from "./plugin-runner-task.js";
|
||||
import * as completionFeedback from "./native-completion-feedback.js";
|
||||
import { environmentTaskOperationSchema } from "@paperclipai/plugin-sdk";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
|
||||
@@ -119,6 +120,7 @@ type BackendFactoryOptions = {
|
||||
};
|
||||
}) => unknown;
|
||||
dynamicToolHandler?: (call: unknown) => Promise<unknown>;
|
||||
completionFeedback?: (result: Parameters<typeof completionFeedback.nativeCompletionFeedback>[2]) => Promise<string>;
|
||||
onSpawn?: (meta: {
|
||||
pid: number;
|
||||
processGroupId: number | null;
|
||||
@@ -10521,15 +10523,32 @@ describe("runnerd provider runtime wiring", () => {
|
||||
await writeRunnerAuthority(root, identity, "suspended");
|
||||
}) };
|
||||
const nextSession = { close: vi.fn(async () => undefined) };
|
||||
const result = { result: { summary: "done" }, terminal: { runTerminalState: "succeeded" },
|
||||
const validated = validatePrpStructuredRunResult({ schema: "paperclip.run_result.v1", reportedWorkDisposition: "done",
|
||||
summary: "done", completionClaim: { contractRevision: first.completionContract.contract.revision, objectiveSatisfied: true,
|
||||
criteria: [], remainingWork: [] }, evidence: [], verification: [], artifacts: [], attentionRequests: [] });
|
||||
if (!validated.ok) throw new Error("Invalid completion fixture");
|
||||
const result = { result: validated.result, terminal: { runTerminalState: "succeeded" },
|
||||
turnId: "turn", normalizedSessionId: first.session.normalizedSessionId,
|
||||
providerSessionId: "provider", driverKind: "test", driverVersion: "1",
|
||||
nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
|
||||
const feedback = vi.spyOn(completionFeedback, "nativeCompletionFeedback").mockResolvedValue("accepted");
|
||||
const firstDb = leaseDb(first);
|
||||
let retainedFeedback: BackendFactoryOptions["completionFeedback"];
|
||||
state.execute.mockReset().mockImplementationOnce(async options => {
|
||||
retainedFeedback = state.createBackend.mock.calls.at(-1)![1].completionFeedback;
|
||||
await options.onSession?.(firstSession);
|
||||
return result;
|
||||
}).mockImplementationOnce(async options => {
|
||||
order.push("execute-next");
|
||||
if (reusable) {
|
||||
await expect(retainedFeedback!(result.result)).resolves.toBe("accepted");
|
||||
expect(feedback).toHaveBeenLastCalledWith(firstDb, next.binding.runId, result.result);
|
||||
expect(state.toolAuthorityDefinitions).toHaveBeenLastCalledWith(expect.objectContaining({ runId: next.binding.runId }));
|
||||
feedback.mockClear();
|
||||
state.toolAuthorityDefinitions.mockRejectedValueOnce(new Error("current authority denied"));
|
||||
await expect(retainedFeedback!(result.result)).rejects.toThrow("current authority denied");
|
||||
expect(feedback).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(options.existingSession).toBe(reusable ? firstSession : undefined);
|
||||
await options.onSession?.(reusable ? firstSession : nextSession);
|
||||
return result;
|
||||
@@ -10542,7 +10561,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||
? priorNativeRunDb(scenario === "stale-owner" ? older : first, scenario === "active-owner" ? "running" : "succeeded").select(fields as never)
|
||||
: ordinarySelect(fields as never)) as Db["select"];
|
||||
try {
|
||||
await executePaperclipNativeSession({ db: leaseDb(first), execution: first,
|
||||
await executePaperclipNativeSession({ db: firstDb, execution: first,
|
||||
runnerInstanceId: identity.runnerInstanceId, useRunnerd: true, runnerExecutionTarget: pluginTarget as never,...pluginOptions });
|
||||
if (scenario === "stale-owner") await writeRunnerAuthority(root, { ...identity, runId: older.binding.runId }, "ready");
|
||||
if (scenario === "missing-owner") {
|
||||
@@ -10598,7 +10617,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||
}
|
||||
} finally {
|
||||
failRetirement = false;
|
||||
resume?.mockRestore();owners?.mockRestore();
|
||||
resume?.mockRestore();owners?.mockRestore();feedback.mockRestore();
|
||||
await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11916,7 +11916,12 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// its host environment out of the plugin and records the prepared directory.
|
||||
runnerInstanceId: prior?.runnerInstanceId ?? effectiveRunnerInstanceId, environment: {},
|
||||
workingDirectoryAuthority: "runner_prepared_launch", dynamicTools, dynamicToolHandler: executeTool,
|
||||
completionFeedback: async result => { await authorityEpoch.definitions(); return nativeCompletionFeedback(input.db, input.execution.binding.runId, result); },
|
||||
completionFeedback: async result => {
|
||||
const current = sessionToolAuthorityEpochs.get(sessionScopeId);
|
||||
if (!current) throw new Error("native_session_tool_authority_unavailable");
|
||||
await current.definitions();
|
||||
return nativeCompletionFeedback(input.db, current.runId, result);
|
||||
},
|
||||
codexTransportFactory: recovery => createRunnerdCodexTransport({ provider: provider.kind, stateDirectory: root,
|
||||
...owner.driver, runnerArtifact: description.runner,
|
||||
...(provider.kind === "acpx" ? { acpxAgent: provider.agent,
|
||||
|
||||
Reference in new issue
Block a user