Skip to content

Commit 380c385

Browse files
authored
perf(agent): overlap existing PR checkout with startup (#3684)
1 parent d2afabe commit 380c385

4 files changed

Lines changed: 711 additions & 18 deletions

File tree

packages/agent/src/server/agent-server.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
SSE_KEEPALIVE_INTERVAL_MS,
4545
} from "./agent-server";
4646
import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt";
47+
import type { ExistingPrCheckoutResult } from "./pr-checkout";
4748

4849
const mockedClaudeSdk = vi.hoisted(() => {
4950
const createSuccessResult = () => ({
@@ -232,6 +233,13 @@ interface TestableServer {
232233
inboxReportUrl?: string | null,
233234
): string;
234235
buildDetectedPrContext(prUrl: string): string;
236+
buildExistingPrCheckoutPromise(
237+
prUrl: string | null,
238+
): Promise<ExistingPrCheckoutResult> | null;
239+
logExistingPrCheckoutResult(
240+
prUrl: string | null,
241+
result: ExistingPrCheckoutResult,
242+
): void;
235243
buildSessionSystemPrompt(
236244
prUrl?: string | null,
237245
slackThreadUrl?: string | null,
@@ -4327,6 +4335,78 @@ describe("AgentServer HTTP Mode", () => {
43274335
delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
43284336
});
43294337
});
4338+
4339+
describe("buildExistingPrCheckoutPromise", () => {
4340+
const prUrl = "https://github.com/org/repo/pull/1";
4341+
4342+
afterEach(() => {
4343+
delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
4344+
});
4345+
4346+
// Guards the gating condition: a review-first run (no auto-publish) must
4347+
// not silently check out a PR branch the prompt told the agent to leave
4348+
// alone. Regressing the guard to always-checkout would fail here.
4349+
it("does not check out when auto-publish is off", () => {
4350+
const s = createServer();
4351+
const promise = (
4352+
s as unknown as TestableServer
4353+
).buildExistingPrCheckoutPromise(prUrl);
4354+
expect(promise).toBeNull();
4355+
});
4356+
4357+
it("does not check out when there is no prUrl", () => {
4358+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
4359+
const s = createServer();
4360+
const promise = (
4361+
s as unknown as TestableServer
4362+
).buildExistingPrCheckoutPromise(null);
4363+
expect(promise).toBeNull();
4364+
});
4365+
4366+
it("does not check out when createPr is false, even on a Slack-origin run", () => {
4367+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
4368+
const s = createServer({ createPr: false });
4369+
const promise = (
4370+
s as unknown as TestableServer
4371+
).buildExistingPrCheckoutPromise(prUrl);
4372+
expect(promise).toBeNull();
4373+
});
4374+
4375+
it("does not check out when no repository is connected", () => {
4376+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
4377+
const s = createServer({ repositoryPath: undefined });
4378+
const promise = (
4379+
s as unknown as TestableServer
4380+
).buildExistingPrCheckoutPromise(prUrl);
4381+
expect(promise).toBeNull();
4382+
});
4383+
4384+
it("starts a checkout when auto-publish is on for a Slack-origin run", () => {
4385+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
4386+
const s = createServer();
4387+
const promise = (
4388+
s as unknown as TestableServer
4389+
).buildExistingPrCheckoutPromise(prUrl);
4390+
expect(promise).toBeInstanceOf(Promise);
4391+
// Sanity: the promise resolves to a checkout result shape (it will fail
4392+
// against the synthetic URL with no real gh, which is fine — we only
4393+
// assert the promise was actually kicked off).
4394+
expect(typeof promise).toBe("object");
4395+
});
4396+
4397+
// Guards the failure fallback: a transient gh failure must surface as a
4398+
// warn, never throw or abort startup. Regressing the failed branch to
4399+
// `throw` would fail here.
4400+
it("logs a warning for a failed checkout result without throwing", () => {
4401+
const s = createServer();
4402+
expect(() =>
4403+
(s as unknown as TestableServer).logExistingPrCheckoutResult(prUrl, {
4404+
status: "failed",
4405+
error: "gh unavailable",
4406+
}),
4407+
).not.toThrow();
4408+
});
4409+
});
43304410
});
43314411

43324412
// Exercises getPendingUserPrompt directly (no HTTP server / git repo) so we can

packages/agent/src/server/agent-server.ts

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ import {
102102
import { TaskRunEventStreamSender } from "./event-stream-sender";
103103
import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
104104
import { type McpRelayResponse, McpRelayServer } from "./mcp-relay-server";
105+
import {
106+
checkoutExistingPullRequest,
107+
type ExistingPrCheckoutResult,
108+
} from "./pr-checkout";
105109
import { resolveRtkSavings } from "./rtk-savings";
106110
import { RunUsageAccumulator } from "./run-usage";
107111
import {
@@ -1625,28 +1629,55 @@ export class AgentServer {
16251629
};
16261630

16271631
await this.waitForRepoReady();
1628-
await this.installSkillBundleArtifacts(
1629-
payload.task_id,
1630-
payload.run_id,
1631-
this.getArtifactsById(preTaskRun?.artifacts, pendingUserArtifactIds),
1632-
);
1633-
1634-
const nativeResume = await this.prepareNativeResume(
1635-
payload,
1636-
posthogAPI,
1637-
preTaskRun,
1638-
runtimeAdapter,
1639-
sessionCwd,
1640-
initialPermissionMode,
1641-
);
1632+
const existingPrCheckoutPromise =
1633+
this.buildExistingPrCheckoutPromise(prUrl);
1634+
// Overlap the best-effort PR checkout with the rest of session setup. The
1635+
// checkout promise is always awaited in `finally` so a throw from
1636+
// installSkillBundleArtifacts / prepareNativeResume / startMcpRelayServer
1637+
// can never abandon an in-flight `gh pr checkout` that would keep mutating
1638+
// the working tree after session start has been abandoned — the awaited
1639+
// settle (plus the checkout's own abort-on-return) cancels it. The overlap
1640+
// is safe despite both touching repositoryPath: skill bundles install under
1641+
// `.posthog/skills/<runId>/...`, which is gitignored (untracked) in target
1642+
// repos, so `git checkout` — which only updates tracked files — cannot
1643+
// conflict with those writes or leave them associated with the wrong branch.
1644+
let nativeResume: { sessionId: string; warm: boolean } | null;
16421645
let effectiveSessionMeta: typeof sessionMeta & {
16431646
nativeGoal?: NonNullable<ResumeState["nativeGoal"]>;
16441647
} = sessionMeta;
1648+
let sessionMcpServers: RemoteMcpServer[];
1649+
try {
1650+
await this.installSkillBundleArtifacts(
1651+
payload.task_id,
1652+
payload.run_id,
1653+
this.getArtifactsById(preTaskRun?.artifacts, pendingUserArtifactIds),
1654+
);
16451655

1646-
const sessionMcpServers = [
1647-
...(this.config.mcpServers ?? []),
1648-
...(await this.startMcpRelayServer()),
1649-
];
1656+
nativeResume = await this.prepareNativeResume(
1657+
payload,
1658+
posthogAPI,
1659+
preTaskRun,
1660+
runtimeAdapter,
1661+
sessionCwd,
1662+
initialPermissionMode,
1663+
);
1664+
1665+
sessionMcpServers = [
1666+
...(this.config.mcpServers ?? []),
1667+
...(await this.startMcpRelayServer()),
1668+
];
1669+
} finally {
1670+
// Always consume the checkout result — on the success path this is the
1671+
// intended await; on a throw it ensures the in-flight checkout settles
1672+
// (and aborts its children) instead of mutating the tree in the
1673+
// background. checkoutExistingPullRequest never rejects.
1674+
if (existingPrCheckoutPromise) {
1675+
this.logExistingPrCheckoutResult(
1676+
prUrl,
1677+
await existingPrCheckoutPromise,
1678+
);
1679+
}
1680+
}
16501681

16511682
let acpSessionId: string | null = null;
16521683
if (nativeResume) {
@@ -3285,6 +3316,54 @@ export class AgentServer {
32853316
return `Continue working on the existing PR branch. If it is not already checked out, check it out with \`gh pr checkout ${prUrl}\`. Do not check it out again when it is already active.`;
32863317
}
32873318

3319+
/**
3320+
* Fire-and-overlap: starts the best-effort PR-branch checkout so it runs
3321+
* concurrently with the rest of session setup, returning the promise (or
3322+
* null when there is nothing to check out). Only runs when auto-publishing,
3323+
* matching the system-prompt fallback's gate: a review-first run must not
3324+
* silently check out a branch the prompt told the agent to leave alone.
3325+
*/
3326+
private buildExistingPrCheckoutPromise(
3327+
prUrl: string | null,
3328+
): Promise<ExistingPrCheckoutResult> | null {
3329+
if (!prUrl || !this.config.repositoryPath) {
3330+
return null;
3331+
}
3332+
if (!this.shouldAutoPublishCloudChanges()) {
3333+
return null;
3334+
}
3335+
return checkoutExistingPullRequest({
3336+
repositoryPath: this.config.repositoryPath,
3337+
prUrl,
3338+
});
3339+
}
3340+
3341+
/**
3342+
* Consume a pre-checkout result without throwing — a transient `gh` failure
3343+
* must fall back to the agent's own checkout (via the system-prompt
3344+
* instruction), never abort session start.
3345+
*/
3346+
private logExistingPrCheckoutResult(
3347+
prUrl: string | null,
3348+
result: ExistingPrCheckoutResult,
3349+
): void {
3350+
if (result.status === "failed") {
3351+
this.logger.warn(
3352+
"Existing PR pre-checkout failed; agent will retry if needed",
3353+
{
3354+
prUrl,
3355+
error: result.error,
3356+
},
3357+
);
3358+
} else {
3359+
this.logger.debug("Existing PR branch prepared before session start", {
3360+
prUrl,
3361+
branch: result.branch,
3362+
alreadyActive: result.status === "already_active",
3363+
});
3364+
}
3365+
}
3366+
32883367
private buildDetectedPrContext(prUrl: string): string {
32893368
if (!this.shouldAutoPublishCloudChanges()) {
32903369
return (

0 commit comments

Comments
 (0)