fix(runner): keep warm run.attach events on ordinary accounting (#19707)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-11-06 23:09:57 -08:00
1 parent b03ea621c6
commit 167e39c4ef
6 files changed
+203 -5

No files matched your search

@@ -8586,6 +8586,46 @@ describe("native terminal-turn accounting", () => {
}
});
it("acknowledges source lifecycle events without a proven recovery usage source", async () => {
const directory = await mkdtemp(join(tmpdir(), "native-plugin-recovery-lifecycle-"));
const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = directory;
const current = { ...execution, provider: { kind: "codex", model: "gpt-6-astra" },
binding: { ...execution.binding, runId: "plugin-lifecycle-run", executionWorkspaceId: "plugin-lifecycle-workspace" },
session: { ...execution.session, normalizedSessionId: "plugin-lifecycle-session" } } as NativeExecutionInput;
const launcher = vi.fn(), registration = vi.fn();
const driver = { authorityRunId: current.binding.runId, prepareLaunch: vi.fn(), runnerProcessLauncher: launcher, controlPlaneRegistration: registration };
const owner = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockReturnValue(
{ launcher, registration, driver, readWorkspaceFile: vi.fn(), isRetired: () => false, rebind: vi.fn() } as never);
const task = vi.fn(async (operation: unknown) => {
environmentTaskOperationSchema.parse(operation);
return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: "task",
runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
});
const capture = vi.fn(async (_event: PrpEvent) => {});
state.createBackend.mockClear(); state.createTransport.mockClear();
try {
// A fresh session has no durable source identity, so it has no recovery usage delivery.
await createRunnerdBackend({ db: leaseDb(current), execution: current, runnerInstanceId: "plugin-producer", runnerIngressAuthorized: true,
onAccountingEvent: capture, runnerExecutionTarget: { kind: "remote", transport: "sandbox", providerKey: "plugin",
leaseId: "plugin-lifecycle-lease", remoteCwd: "/unused", runnerTask: task } });
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
const recover = state.createTransport.mock.calls.at(-1)![0].onRecoveryAccountingEvent!;
const source: Parameters<NonNullable<RunnerTransportOptions["onRecoveryAccountingEvent"]>>[1] = { runnerInstanceId: "plugin-producer", environmentLeaseId: "plugin-lifecycle-lease", runId: "plugin-lifecycle-source",
normalizedSessionId: "plugin-lifecycle-session", turnId: "turn-source", itemId: "item-source" };
for (const eventType of ["session.resumed", "session.capabilities.updated", "session.goal.snapshot", "run.attached"]) {
await expect(recover({ eventType, runId: source.runId, sourceInstanceId: "plugin-producer" } as PrpEvent, source)).resolves.toBeUndefined();
}
await expect(recover({ eventType: "usage.reported", runId: source.runId, sourceInstanceId: "plugin-producer" } as PrpEvent, source))
.rejects.toThrow("native_recovery_usage_source_unproven");
expect(capture).not.toHaveBeenCalled();
} finally {
owner.mockRestore();
if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
await rm(directory, { recursive: true, force: true });
}
});
it("uses the canonical recorder for partial and completed usage when no onUsage callback is supplied", async () => {
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
state.execute.mockReset().mockImplementationOnce(async () => {
@@ -14322,6 +14322,10 @@ async function createRunnerdBackendWithinSessionClaim(
await current.acceptAccountingEvent(event);
} : undefined,
onRecoveryAccountingEvent: input.onAccountingEvent ? async (event: PrpEvent, source: Parameters<NonNullable<typeof recoveryUsage>>[1]) => {
// Only usage needs a proven recovery source. A lifecycle event that
// fails here closes the Runner connection without an ACK, and the
// Runner then replays that event without end.
if (!isNativeUsageAccountingEvent(event)) return;
const current = sessionToolAuthorityEpochs.get(sessionScopeId);
if (!current) throw new Error("native_recovery_usage_source_unproven");
await current.acceptRecoveryAccountingEvent(event, () => {