mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
Align warm Pi reopen with native cold admission
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
96a0e329b6
commit
0d65753fe1
5 files changed
+184
-10
No files matched your search
@@ -332,8 +332,13 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI.
|
||||
- Normal ARM, Intel and Linux public installations pass in 8.043, 37.681 and
|
||||
5.698 s respectively. Each uses the normal installed daemon, verifies profile
|
||||
14, submits no prompt and observes clean Runner exit. Workspace build and
|
||||
typecheck pass. The full local Vitest run remains live; embedded Postgres
|
||||
integration suites report host skips, so it is not complete integration proof.
|
||||
typecheck pass. The broad local Vitest attempt completes with 47 failed files,
|
||||
533 passed files and 163 skipped files. Private Postgres library symlinks are
|
||||
missing, and the restricted test PATH omits macOS lsof. Both setup causes are
|
||||
corrected only in the task-owned dependency environment. The focused DB and
|
||||
process-owner checks pass 95 tests with three existing skips. The full suite
|
||||
then reruns with the corrected environment. The failed log remains intact; it is not
|
||||
a passing full local test or complete integration claim.
|
||||
- The local Product controller-restart cell passes with canonical evidence.
|
||||
The next cell, warm-three-turn, completes turn 1 but fails before turn 2
|
||||
provider work: native `run.attach` reaches its 30-second command timeout.
|
||||
@@ -345,13 +350,18 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI.
|
||||
of the completed-turn failure and does not qualify warm continuity. Its strict
|
||||
cleanup receipt fails even though native state reports suspended and Runner
|
||||
exits cleanly; both observations are retained. No model prompt is submitted.
|
||||
- Investigate authority rotation and native provider reopen before choosing the
|
||||
correction. Native ACPX attachment checkpoints the old sidecar and opens a
|
||||
new one. Pi's native session-open admission bound is 60 s, but warm attachment
|
||||
currently uses the ordinary 30 s controller command wait. This discrepancy
|
||||
is a candidate cause, not a measured resolution. Do not widen the 60 s bound,
|
||||
weaken the three-turn identity checks or retry the paid failure unchanged.
|
||||
- All 55 CI checks and Greptile 5/5 are terminal at `3d75626bf`, with zero
|
||||
- A free completed-turn regression proves the warm-admission mismatch. It
|
||||
finishes turn 1 through the native semantic bridge, checkpoints the sidecar,
|
||||
and delays the replacement's exact-identity admission by 32 s. The unchanged
|
||||
controller times out at `run.attach` and strict suspension fails. The corrected
|
||||
controller uses Pi's existing absolute 60 s cold-process admission budget for
|
||||
warm attachment and aborts admission on close. The same fixture then completes
|
||||
turn 2, retains one Runner, starts exactly two sidecars without a retry, and
|
||||
passes strict cleanup. TypeScript passes; the full transport/eval-session
|
||||
suite is running. The native 60 s bound and profile-14 bytes are unchanged.
|
||||
The paid three-turn failure remains failed. Fresh exact-source packaging and
|
||||
one explicit paid retry are still required; no live resolution is claimed.
|
||||
- All 55 CI checks and Greptile 5/5 are terminal at `96a0e329b`, with zero
|
||||
unresolved draft-PR threads. The immutable image job 37099122706 remains
|
||||
queued. Track it without a duplicate dispatch. Qualification, prerequisites
|
||||
and image/companion gates still hold production.
|
||||
|
||||
@@ -60,6 +60,12 @@ and Daytona qualification passes. Their verified distributions are build-owned;
|
||||
no candidate accepts an arbitrary executable. See
|
||||
[the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md).
|
||||
|
||||
Pi cold provider admission has an absolute 60-second budget. Warm run attachment
|
||||
checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget
|
||||
while retaining the existing Runner authority. Live adoption and ordinary
|
||||
commands retain their 30-second bounds. Closing the transport cancels admission;
|
||||
an acknowledgement received after the admission deadline cannot revive it.
|
||||
|
||||
Cursor candidate configuration accepts `acpxSessionMode: "agent" | "plan" | "ask"`
|
||||
(default `agent`). This selects the native Cursor mode independently of
|
||||
`acpxPermissionMode` and Paperclip task planning or company approvals. The mode
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Protocol-only fixture: no credentials, network, or model calls.
|
||||
const { appendFileSync } = require("node:fs");
|
||||
const { createInterface } = require("node:readline");
|
||||
// The test binds configuration into the verified script snapshot before launch.
|
||||
const config = /* fixture-config */ null;
|
||||
let identity;
|
||||
let runId;
|
||||
let turnId;
|
||||
let sequence = 1;
|
||||
const send = (value) => process.stdout.write(`${JSON.stringify({ protocolVersion: 2, ...value })}\n`);
|
||||
const event = (eventType, payload) => send({ sequence: sequence++, eventType, runId, turnId, payload });
|
||||
const journal = (value) => appendFileSync(config.journal, `${JSON.stringify({ pid: process.pid, ...value })}\n`);
|
||||
journal({ event: "spawn" });
|
||||
|
||||
async function handle({ id, command, params = {} }) {
|
||||
journal({ command, resumed: command === "session.open" && !!params.expectedIdentity });
|
||||
let result;
|
||||
switch (command) {
|
||||
case "initialize":
|
||||
result = { protocolVersion: 2, sidecarPid: process.pid, profile: { agent: "pi" }, capabilities: {
|
||||
persistentSessions: true, exactModelVerification: true, permissions: "runner_policy",
|
||||
semanticTools: "runner_bridge", structuredInput: "paperclip.question_set.v1",
|
||||
} };
|
||||
break;
|
||||
case "session.open":
|
||||
if (params.expectedIdentity) await new Promise((resolve) => setTimeout(resolve, config.resumeDelayMs));
|
||||
identity = params.expectedIdentity ?? {
|
||||
kind: "acpx", normalizedSessionId: params.normalizedSessionId,
|
||||
acpxRecordId: "record-warm", backendSessionId: "backend-warm", agentSessionId: "agent-warm",
|
||||
profileDigest: "sha256:f35145437eeb355ed37bc5a4fa93d7ede561d9c45daf311979b46890b808ddd4",
|
||||
workspaceDigest: `sha256:${"2".repeat(64)}`, requestedModel: params.model, effectiveModel: params.model,
|
||||
permissionMode: params.permissionMode, piThinkingLevel: params.piThinkingLevel,
|
||||
providerLifetimeFenceCandidates: [61001, 61002, 61003],
|
||||
};
|
||||
result = { sidecarPid: process.pid, identity, status: {}, turnControls: { steering: true, queuedFollowUp: true } };
|
||||
break;
|
||||
case "run.attach":
|
||||
runId = params.runId;
|
||||
result = { runId, catalogRevision: params.catalogRevision };
|
||||
break;
|
||||
case "turn.start":
|
||||
turnId = params.turnId;
|
||||
result = { turnId, turnControls: { steering: true, queuedFollowUp: true } };
|
||||
break;
|
||||
case "tool.resolve":
|
||||
if (params.error) throw new Error(`Completion rejected: ${JSON.stringify(params.error)}`);
|
||||
result = { resolved: true };
|
||||
break;
|
||||
case "session.snapshot":
|
||||
result = { identity, runId, turnId: null, pendingRuntimeRequests: [] };
|
||||
break;
|
||||
case "session.goal.get":
|
||||
result = { schema: "paperclip.session_goal.snapshot.v1", goal: null, workingNow: false, sessionGoals: {
|
||||
availability: "available", actions: ["set", "pause", "resume", "clear"], autonomousUpdates: true,
|
||||
persistentAcrossResume: true, maxObjectiveChars: 4000, tokenBudgetControl: false, usageReporting: false,
|
||||
} };
|
||||
break;
|
||||
case "session.suspend": result = { suspended: true, identity }; break;
|
||||
case "session.close": result = { closed: true }; break;
|
||||
case "turn.cancel": result = { cancelled: true }; break;
|
||||
default: throw new Error(`Unexpected fixture command ${command}`);
|
||||
}
|
||||
send({ id, ok: true, result });
|
||||
if (command === "turn.start") event("runtime.tool_called", {
|
||||
callId: `finish-${turnId}`, operationId: "paperclip_finish", input: {
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Fixture turn completed.",
|
||||
completionClaim: { contractRevision: "warm-pi-contract", objectiveSatisfied: true, criteria: [], remainingWork: [] },
|
||||
evidence: [], verification: [], attentionRequests: [], artifacts: [],
|
||||
},
|
||||
});
|
||||
if (command === "tool.resolve") event("runtime.turn_terminal", { status: "completed" });
|
||||
}
|
||||
|
||||
let handling = Promise.resolve();
|
||||
createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
handling = handling.then(() => handle(JSON.parse(line))).catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
@@ -6294,6 +6294,75 @@ it("rotates PRP authority in place for a warm cross-run attachment", async () =>
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("reopens Pi after a completed turn within its cold admission budget during warm attachment", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-pi-warm-admission-"));
|
||||
const cliRoot = join(root, "dist/cli");
|
||||
await mkdir(cliRoot, { recursive: true });
|
||||
const sidecarPath = join(cliRoot, "acpx-runtime-sidecar.cjs");
|
||||
const journal = join(root, "commands.ndjson");
|
||||
const fixture = await readFile(fileURLToPath(new URL("./fixtures/fake-pi-warm-sidecar.cjs", import.meta.url)), "utf8");
|
||||
await writeFile(sidecarPath, fixture.replace("/* fixture-config */ null", JSON.stringify({ journal, resumeDelayMs: 32_000 })));
|
||||
const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", acpxPermissionMode: "deny-all",
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(), stateDirectory: root, runnerFilesystemRoot: root,
|
||||
providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath),
|
||||
acpxSidecarPath: sidecarPath, acpxSidecarSha256: digest(sidecarPath),
|
||||
providerPackAuthorityDigest: `sha256:${"d".repeat(64)}`,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 },
|
||||
environment: { PATH: "/usr/bin:/bin" },
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: "Completion report accepted." }],
|
||||
}));
|
||||
let failure: unknown;
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
dynamicTools: codexSemanticToolSpecs(),
|
||||
completionContract: { revision: "warm-pi-contract", criterionIds: [] },
|
||||
});
|
||||
const runnerPid = bundle.evidence().runnerPid;
|
||||
const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
|
||||
const completeTurn = async () => {
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Complete the fixture turn." }] });
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const next = await Promise.race([
|
||||
notifications.next(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Fixture completion timed out")), 5_000)),
|
||||
]);
|
||||
if (next.value?.method === "turn/completed") return;
|
||||
}
|
||||
throw new Error("Fixture turn did not complete");
|
||||
};
|
||||
await completeTurn();
|
||||
const started = Date.now();
|
||||
await bundle.transport.attachRun!({ runId: "run-pi-warm-second", turnId: "turn-pi-warm-second", itemId: "item-pi-warm-second" });
|
||||
expect(Date.now() - started).toBeGreaterThan(30_000);
|
||||
await completeTurn();
|
||||
expect(bundle.evidence()).toMatchObject({ runnerPid, runnerExited: false });
|
||||
const commands = (await readFile(journal, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
|
||||
expect(commands.filter((entry) => entry.event === "spawn")).toHaveLength(2);
|
||||
expect(commands.filter((entry) => entry.command === "session.open")).toHaveLength(2);
|
||||
expect(commands.filter((entry) => entry.resumed)).toHaveLength(1);
|
||||
expect(commands.filter((entry) => entry.command === "turn.start")).toHaveLength(2);
|
||||
expect(commands.filter((entry) => entry.command === "session.close")).toHaveLength(1);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await bundle.transport.close();
|
||||
} catch (cleanupError) {
|
||||
if (failure) throw new AggregateError([failure, cleanupError], "Warm attachment and strict cleanup failed");
|
||||
throw cleanupError;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 75_000);
|
||||
|
||||
it("waits for a warm runner to re-authenticate before probing attachment readiness", async () => {
|
||||
const stateDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-warm-reattach-before-probe-"),
|
||||
|
||||
@@ -4014,7 +4014,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
paperclipNextAuthority: { identity: desired, connection },
|
||||
};
|
||||
core.queueCommand("run.attach", payload, commandId, true);
|
||||
await this.#waitCommand("run.attach", commandId);
|
||||
// ACPX run attachment checkpoints the old sidecar and starts a fresh
|
||||
// provider process. Pi must use the same absolute cold-admission budget
|
||||
// as startup/recovery even though its Runner authority remains warm.
|
||||
// Other providers retain the ordinary command bound.
|
||||
await this.#waitCommand(
|
||||
"run.attach",
|
||||
commandId,
|
||||
this.#coldAdmissionDeadline(),
|
||||
true,
|
||||
);
|
||||
const attached = core.getCommand(commandId);
|
||||
if (attached?.status !== "completed") {
|
||||
throw new Error("native_runner_prp_run_rotation_failed");
|
||||
|
||||
Reference in new issue
Block a user