mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
fix(runner): refresh runtime context when restoring ACPX sessions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3d0c45920c
commit
e08bb92aa2
2 files changed
+66
No files matched your search
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { ControlPlanePort } from "../contracts/control-plane-port.js";
|
||||
import { bindAcpxAgentFiles } from "../drivers/acpx/agent-files-binding.js";
|
||||
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
|
||||
import type {
|
||||
NativeSession,
|
||||
@@ -485,6 +486,61 @@ it("carries the provider attachment seed across consecutive authority rotations"
|
||||
});
|
||||
});
|
||||
|
||||
it("restores ACPX with the current registered copy after the old run copy is collected", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-acpx-context-rotation-"));
|
||||
const priorRoot = join(root, "prior-run-copy");
|
||||
const currentRoot = join(root, "current-run-copy");
|
||||
await Promise.all([mkdir(priorRoot), mkdir(currentRoot)]);
|
||||
const context = (rootPath: string) => ({
|
||||
instructions: {
|
||||
workingCopy: { kind: "agent_files", rootPath, entryPath: "AGENTS.md" },
|
||||
},
|
||||
}) as NativeRuntimeContextSnapshot;
|
||||
const prior = context(priorRoot);
|
||||
const current = context(currentRoot);
|
||||
const desired = {
|
||||
runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "new-run",
|
||||
normalizedSessionId: "same-session", turnId: "new-turn", itemId: "new-item",
|
||||
};
|
||||
const provider = {
|
||||
kind: "acpx", agent: "copilot", runId: "old-run",
|
||||
normalizedSessionId: "same-session", commandDigest: "sha256:immutable",
|
||||
model: "explicit-model", runtimeContext: prior,
|
||||
};
|
||||
const state = {
|
||||
runAttachTemplate: { provider, workspace: { cwd: root } },
|
||||
commands: [{ type: "turn.start", payload: { text: "must not replay" } }],
|
||||
};
|
||||
try {
|
||||
await rm(priorRoot, { recursive: true });
|
||||
expect(() => bindAcpxAgentFiles(prior, [])).toThrowError(
|
||||
expect.objectContaining({ code: "ENOENT" }),
|
||||
);
|
||||
const restored = runnerdRecoveryInternals.rotatedRunAttachPayload(
|
||||
state, desired, null, undefined, current,
|
||||
);
|
||||
const restoredProvider = restored.provider as typeof provider;
|
||||
expect(restoredProvider).toEqual({ ...provider, runId: desired.runId, runtimeContext: current });
|
||||
expect(bindAcpxAgentFiles(restoredProvider.runtimeContext, [])?.root).toContain("current-run-copy");
|
||||
expect(restored).not.toHaveProperty("text");
|
||||
expect(state.runAttachTemplate.provider.runtimeContext).toBe(prior);
|
||||
// A reopened provider can retain the current grant on another restoration.
|
||||
const same = runnerdRecoveryInternals.rotatedRunAttachPayload(
|
||||
{ runAttachTemplate: restored }, desired, null, undefined, current,
|
||||
);
|
||||
expect(same).toEqual(restored);
|
||||
expect((same.provider as typeof provider).runtimeContext).not.toBe(current);
|
||||
// Live warm attachment has no new provider process and must preserve its
|
||||
// existing context until settlement, rather than replace its filesystem grant.
|
||||
const live = runnerdRecoveryInternals.rotatedRunAttachPayload(
|
||||
state, desired, null, undefined,
|
||||
);
|
||||
expect((live.provider as typeof provider).runtimeContext).toEqual(prior);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replays the durable run attachment outcome and latest provider identity", () => {
|
||||
expect(
|
||||
runnerdRecoveryInternals.recoveredRunAttachment({
|
||||
|
||||
@@ -418,6 +418,7 @@ function rotatedRunAttachPayload(
|
||||
authorizedTools: Record<string, unknown> | null,
|
||||
completionContract:
|
||||
{ revision: string; criterionIds: readonly string[] } | undefined,
|
||||
runtimeContext?: NativeRuntimeContextSnapshot | null,
|
||||
): Record<string, unknown> {
|
||||
const commands = Array.isArray(state.commands)
|
||||
? state.commands.map(record)
|
||||
@@ -443,6 +444,7 @@ function rotatedRunAttachPayload(
|
||||
desired,
|
||||
authorizedTools,
|
||||
completionContract,
|
||||
runtimeContext,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -452,12 +454,19 @@ function retargetRunAttachPayload(
|
||||
authorizedTools: Record<string, unknown> | null,
|
||||
completionContract:
|
||||
{ revision: string; criterionIds: readonly string[] } | undefined,
|
||||
runtimeContext?: NativeRuntimeContextSnapshot | null,
|
||||
): Record<string, unknown> {
|
||||
const payload = structuredClone(seedPayload);
|
||||
const provider = record(payload.provider);
|
||||
if (provider.kind === "acpx" || provider.provider === "acpx") {
|
||||
provider.runId = desired.runId;
|
||||
provider.normalizedSessionId = desired.normalizedSessionId;
|
||||
// A run-scoped registered instruction copy is collected after shutdown.
|
||||
// Restore the durable provider identity with the current authenticated
|
||||
// context, never the prior run's now-stale filesystem grant.
|
||||
if (runtimeContext !== undefined) {
|
||||
provider.runtimeContext = structuredClone(runtimeContext);
|
||||
}
|
||||
payload.provider = provider;
|
||||
}
|
||||
if (authorizedTools !== null) payload.authorizedTools = authorizedTools;
|
||||
@@ -5140,6 +5149,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
desiredIdentity,
|
||||
this.#authorizedTools,
|
||||
this.options.resumeCompletionContract,
|
||||
runtimeContext,
|
||||
);
|
||||
if (provider === "codex") {
|
||||
// These controller-owned, token-free paths belong to the new run.
|
||||
|
||||
Reference in new issue
Block a user