Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions harness/src/leaf-result-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createClient, type RedisClientType } from 'redis';
import { swallowRedisErrors } from '@sh/session-backend';
import type { Verdict } from './verdict.js';
import type { LeafResult, LeafUsage } from './run-leaf.js';

Expand Down Expand Up @@ -89,6 +90,10 @@ export class RedisResultStore implements RedisLike {
private ready: Promise<void>;
constructor(url = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379') {
this.client = createClient({ url }) as RedisClientType;
// A socket error on an established connection is re-emitted on the client, and no listener means
// the process exits 1. See swallowRedisErrors (@sh/session-backend) for the proof and why this
// store's process-lifetime memoisation is what makes it reachable with no turn in flight.
swallowRedisErrors(this.client, 'leaf result store');
this.ready = this.client.connect().then(() => undefined);
}
async set(key: string, value: string, opts?: { EX?: number }): Promise<unknown> {
Expand Down
46 changes: 46 additions & 0 deletions harness/src/lease-timings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Integer env knob with a floor: `def` when unset, empty, unparseable, or below `min`.
*
* Replaces `Number(env.X ?? '20000')`, which misread two shapes that reach production — `??` does not
* catch an empty string (`Number('') === 0`) and anything unparseable yields `NaN`. For an interval
* both clamp to 1 ms, i.e. ~1000 Redis writes a second per in-flight turn. knative-server's `intEnv`
* (server.ts) is the same answer to the same class of bug, added after it emitted `Retry-After: NaN`;
* this one is harness-side because that one is not importable from here.
*/
function intEnv(env: NodeJS.ProcessEnv, name: string, def: number, min = 1): number {
const raw = env[name];
if (raw === undefined || raw.trim() === '') return def;
const n = Number(raw);
return Number.isFinite(n) && n >= min ? Math.floor(n) : def;
}

/** The three knobs a pool lease is taken and kept under. */
export interface LeaseTimings {
/** Soft cap on concurrent leases per sandbox. */
cap: number;
/** How long an acquired lease survives without a renewal. */
ttlMs: number;
/** Renewal interval, guaranteed to fit inside `ttlMs` (see below). */
heartbeatMs: number;
}

/**
* Read the lease knobs once, in one place, with the one relation between them enforced.
*
* Shared by `/turn` (acquireTurnSandbox) and all three leaf paths deliberately: they lease from the
* SAME store, and two conventions for one store is how a cap comes to mean different things depending
* on which path took the lease. Previously each of the four inlined its own `Number(...)`, so hardening
* one would have created exactly that divergence.
*
* The clamp is the part nothing enforced before. `heartbeatMs` and `ttlMs` were independent knobs, so
* `KAGENTI_SANDBOX_LEASE_TTL_MS=15000` on its own expired the lease before its first renewal: the
* sandbox returned to the pool mid-turn, and another turn could then take the same pod past cap.
* ttl/3 gives three renewal attempts inside a TTL and is exactly the default pairing
* (60000/3 = 20000), so a deployment overriding neither is byte-for-byte unaffected.
*/
export function leaseTimings(env: NodeJS.ProcessEnv): LeaseTimings {
const cap = intEnv(env, 'KAGENTI_SANDBOX_CAP', 20);
const ttlMs = intEnv(env, 'KAGENTI_SANDBOX_LEASE_TTL_MS', 60000);
const requested = intEnv(env, 'KAGENTI_SANDBOX_HEARTBEAT_MS', 20000);
return { cap, ttlMs, heartbeatMs: Math.max(1, Math.min(requested, Math.floor(ttlMs / 3))) };
}
5 changes: 5 additions & 0 deletions harness/src/pool-records.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createClient, type RedisClientType } from 'redis';
import { swallowRedisErrors } from '@sh/session-backend';

export interface SandboxRecord {
sandboxId: string;
Expand All @@ -25,6 +26,10 @@ export class RedisRecordStore implements RecordStore {
private ready: Promise<void>;
constructor(url = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379') {
this.client = createClient({ url }) as RedisClientType;
// A socket error on an established connection is re-emitted on the client, and no listener means
// the process exits 1. See swallowRedisErrors (@sh/session-backend) for the proof and why this
// store's process-lifetime memoisation is what makes it reachable with no turn in flight.
swallowRedisErrors(this.client, 'sandbox record store');
this.ready = this.client.connect().then(() => undefined);
}
async put(rec: SandboxRecord): Promise<void> {
Expand Down
22 changes: 13 additions & 9 deletions harness/src/run-leaf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SandboxPoolSaturatedError,
type SelectedSandbox,
} from './select-sandbox.js';
import { leaseTimings } from './lease-timings.js';
import { convergeWorkspace, cleanupWorkspace, captureWorkspaceDiff } from './converge.js';
import {
setupSwebenchWorkspace,
Expand Down Expand Up @@ -387,9 +388,10 @@ async function runPromptLeaf(
// single-pod resolution and returns null when nothing is configured.
let selected: SelectedSandbox | null;
try {
const { cap, ttlMs } = leaseTimings(process.env);
selected = await selectPoolSandbox(sandboxEnvironment(env), cwd, sid, {
cap: Number(process.env.KAGENTI_SANDBOX_CAP ?? '20'),
ttlMs: Number(process.env.KAGENTI_SANDBOX_LEASE_TTL_MS ?? '60000'),
cap,
ttlMs,
remoteSandbox: process.env.SH_REMOTE_SANDBOX === '1',
});
} catch (err) {
Expand Down Expand Up @@ -420,7 +422,7 @@ async function runPromptLeaf(
let overlayDigest: string | undefined;
try {
if (selected) {
const hbMs = Number(process.env.KAGENTI_SANDBOX_HEARTBEAT_MS ?? '20000');
const hbMs = leaseTimings(process.env).heartbeatMs;
const lease = selected;
heartbeat = setInterval(() => {
void lease.heartbeat();
Expand Down Expand Up @@ -572,9 +574,10 @@ export const realProduceSolve: ProduceSolve = async (env, config, capture) => {

// A solve leaf MUST have a real sandbox worktree — fail fast (before any Redis/session work) if the
// pool is unconfigured. selectPoolSandbox returns null when no sandbox is configured (see select-sandbox.ts).
const solveTimings = leaseTimings(process.env);
const selected = await selectPoolSandbox(sandboxEnvironment(env), cwd, sid, {
cap: Number(process.env.KAGENTI_SANDBOX_CAP ?? '20'),
ttlMs: Number(process.env.KAGENTI_SANDBOX_LEASE_TTL_MS ?? '60000'),
cap: solveTimings.cap,
ttlMs: solveTimings.ttlMs,
});
if (!selected) throw new Error('solve leaf requires a configured sandbox pool');

Expand Down Expand Up @@ -609,7 +612,7 @@ export const realProduceSolve: ProduceSolve = async (env, config, capture) => {
// A solve leaf edits files in its worktree; point the agent's sandbox cwd at that worktree so the
// model's edits (relative or absolute) land where captureWorkspaceDiff reads them.
const agentConfig = { ...selected.config, podCwd: workspaceRef };
const hbMs = Number(process.env.KAGENTI_SANDBOX_HEARTBEAT_MS ?? '20000');
const hbMs = leaseTimings(process.env).heartbeatMs;
heartbeat = setInterval(() => {
void selected.heartbeat();
}, hbMs);
Expand Down Expand Up @@ -719,9 +722,10 @@ export const realProduceVerdict: ProduceVerdict = async (item, env, config, capt
// verdict fast-path so a recovered verdict does not lease a pod. Returns null ⇒ no sandbox
// configured (local tools). Throws SandboxPoolSaturatedError when a configured pool is full.
const remoteSandbox = process.env.SH_REMOTE_SANDBOX === '1';
const promptTimings = leaseTimings(process.env);
const selected = await selectPoolSandbox(sandboxEnvironment(env), cwd, sid, {
cap: Number(process.env.KAGENTI_SANDBOX_CAP ?? '20'),
ttlMs: Number(process.env.KAGENTI_SANDBOX_LEASE_TTL_MS ?? '60000'),
cap: promptTimings.cap,
ttlMs: promptTimings.ttlMs,
remoteSandbox,
});
const converging = selected != null && !!env.repoUrl && !!env.ref;
Expand All @@ -746,7 +750,7 @@ export const realProduceVerdict: ProduceVerdict = async (item, env, config, capt
}
}
if (selected) {
const hbMs = Number(process.env.KAGENTI_SANDBOX_HEARTBEAT_MS ?? '20000');
const hbMs = leaseTimings(process.env).heartbeatMs;
heartbeat = setInterval(() => {
void selected.heartbeat();
}, hbMs);
Expand Down
Loading
Loading