Skip to content

Commit 1fced7a

Browse files
committed
fix(agent): clarify at-most-once emit semantics and harden rtk binary extraction
Rename rtkSavingsEmitted -> rtkSavingsAttempted (the flag is set before the await, so a failed attempt still prevents retries; the name now says so). Move emitRtkSavings after updateTaskRun on the error path so the "failed" status lands before the up-to-5s rtk gain probe. Extract the cached rtk binary atomically via a temp dir + rename so a killed build cannot leave a partial binary that existsSync accepts on subsequent runs. Add explicit maxBuffer to the execFileAsync call to make the 10 MB cap intentional. Add readability comments to the double-cleanup test. Generated-By: PostHog Code Task-Id: b86391cc-4634-4a2b-ae34-fe1f9c0626a0
1 parent 4e5352d commit 1fced7a

4 files changed

Lines changed: 35 additions & 14 deletions

File tree

packages/agent/build/rtk-binary.mjs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import {
66
createWriteStream,
77
existsSync,
88
mkdirSync,
9+
mkdtempSync,
910
readFileSync,
11+
renameSync,
1012
rmSync,
1113
writeFileSync,
1214
} from "node:fs";
15+
import { tmpdir } from "node:os";
1316
import { dirname, join, resolve } from "node:path";
1417
import { pipeline } from "node:stream/promises";
1518
import { setTimeout as sleep } from "node:timers/promises";
@@ -183,12 +186,21 @@ export async function ensureRtkBinary(destDir) {
183186
rmSync(archivePath, { force: true });
184187
throw error;
185188
}
186-
await extractArchive(archivePath, cacheDir, target);
187-
rmSync(archivePath, { force: true });
188-
if (!existsSync(cachedBinary)) {
189-
throw new Error(
190-
`rtk binary missing after extraction: ${cachedBinary} — the release archive layout for ${target} may have changed`,
191-
);
189+
// Extract into a temp dir first so a killed build cannot leave a partial
190+
// binary in cacheDir that existsSync accepts on the next run.
191+
const tmpDir = mkdtempSync(join(tmpdir(), "posthog-rtk-"));
192+
try {
193+
await extractArchive(archivePath, tmpDir, target);
194+
rmSync(archivePath, { force: true });
195+
const extractedBinary = join(tmpDir, binName);
196+
if (!existsSync(extractedBinary)) {
197+
throw new Error(
198+
`rtk binary missing after extraction: ${cachedBinary} — the release archive layout for ${target} may have changed`,
199+
);
200+
}
201+
renameSync(extractedBinary, cachedBinary);
202+
} finally {
203+
rmSync(tmpDir, { force: true, recursive: true });
192204
}
193205
}
194206

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,9 +517,13 @@ describe("AgentServer HTTP Mode", () => {
517517
const session = testServer.session;
518518
await testServer.cleanupSession({ completeEventStream: true });
519519
// A failed run hits both terminal seams; the savings must only count once.
520+
// Re-arm session: the first cleanup nulled this.session, so without this
521+
// the second cleanup exits early and never reaches emitRtkSavings.
520522
testServer.session = session;
521523
await testServer.cleanupSession({ completeEventStream: true });
522524

525+
// The once-only assertion is meaningful because the stubbed stop() does
526+
// not gate enqueue — rtkSavingsAttempted is the actual guard under test.
523527
expect(testServer.eventStreamSender.enqueue).toHaveBeenCalledOnce();
524528
expect(testServer.eventStreamSender.enqueue).toHaveBeenCalledWith(
525529
expect.objectContaining({

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export class AgentServer {
305305
private app: Hono;
306306
private posthogAPI: PostHogAPIClient;
307307
private eventStreamSender: TaskRunEventStreamSender | null = null;
308-
private rtkSavingsEmitted = false;
308+
private rtkSavingsAttempted = false;
309309
private questionRelayedToSlack = false;
310310
private adapterEmittedTurnComplete = false;
311311
private detectedPrUrl: string | null = null;
@@ -2840,11 +2840,6 @@ ${signedCommitInstructions}
28402840
error: errorMessage ?? "Agent error",
28412841
});
28422842

2843-
// Emit before the finally below stops the stream — failed runs still used
2844-
// RTK-compressed commands and must be counted. cleanupSession's emit runs
2845-
// after this stop on the error path, so it can't rely on that one.
2846-
await this.emitRtkSavings();
2847-
28482843
try {
28492844
await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
28502845
status,
@@ -2854,6 +2849,11 @@ ${signedCommitInstructions}
28542849
} catch (error) {
28552850
this.logger.error("Failed to signal task completion", error);
28562851
} finally {
2852+
// Emit before stop() — failed runs still used RTK-compressed commands
2853+
// and must be counted. cleanupSession's emit runs after this stop on
2854+
// the error path, so it can't rely on that one. Placed after
2855+
// updateTaskRun so the "failed" status lands promptly.
2856+
await this.emitRtkSavings();
28572857
await this.eventStreamSender?.stop();
28582858
}
28592859
}
@@ -3390,8 +3390,10 @@ ${signedCommitInstructions}
33903390
* before the stream is stopped, since enqueue is a no-op once stopped.
33913391
*/
33923392
private async emitRtkSavings(): Promise<void> {
3393-
if (!this.eventStreamSender || this.rtkSavingsEmitted) return;
3394-
this.rtkSavingsEmitted = true;
3393+
if (!this.eventStreamSender || this.rtkSavingsAttempted) return;
3394+
// Set before the await: a failed attempt still counts — prevents retry
3395+
// storms on the terminal path. The flag name reflects this intent.
3396+
this.rtkSavingsAttempted = true;
33953397

33963398
try {
33973399
const savings = await (

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ async function defaultRunGain(
5252
): Promise<string> {
5353
const { stdout } = await execFileAsync(binary, ["gain", "--format", "json"], {
5454
timeout: 5_000,
55+
// The daily array grows on long-lived hosts; cap the buffer explicitly so
56+
// ENOBUFS never silently swallows savings on desktop reuse.
57+
maxBuffer: 10 * 1024 * 1024,
5558
env,
5659
});
5760
return stdout;

0 commit comments

Comments
 (0)