-
Notifications
You must be signed in to change notification settings - Fork 607
[fix] Bound the session-records fetch and consume the persist-drop signal #5501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
35fbe66
fix(runner): bound the records fetch and consume the persist-drop signal
ardaerzin 3a0bbc5
Merge branch 'feat/sessions-last-message-only' into fix/sessions-reco…
bekossy 2a0c194
fix(runner): read numeric env config against an explicit domain
ardaerzin 6c90f60
fix(runner): hold the timer domain across derived delays, not just reads
ardaerzin 6a8163f
Merge branch 'release/v0.106.1' into fix/sessions-records-robustness
ardaerzin 49c8913
Merge branch 'release/v0.106.1' into fix/sessions-records-robustness
ardaerzin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| /** | ||
| * Numeric environment config read against an explicit domain. | ||
| * | ||
| * Every ops-tunable number in the runner is a timer delay, a byte budget, or an attempt count — | ||
| * each with a domain the consuming API actually accepts. Hand-rolled `Number(process.env.X ?? d)` | ||
| * reads do not encode that domain, and every way of getting it wrong fails *silently in the | ||
| * direction of "no protection at all"*: | ||
| * | ||
| * - junk ("30s", "5 min") parses to `NaN`; `setTimeout(fn, NaN)` fires on the next tick, so a | ||
| * coalescing window or poll cadence collapses into a busy path instead of falling back. | ||
| * - a sub-millisecond value floors to `0`; `AbortSignal.timeout(0)` aborts the request | ||
| * immediately, so every call fails and the feature degrades permanently and quietly. | ||
| * - a value above `TIMER_MAX_MS` overflows Node's 32-bit timer: the delay silently becomes 1 ms | ||
| * (a "very long timeout" turns into an instant one), and past 2^32-1 | ||
| * `AbortSignal.timeout` throws `ERR_OUT_OF_RANGE` outright. | ||
| * | ||
| * So the domain is stated once, here, and clamped into rather than trusted. A bad override is | ||
| * never fatal — it falls back or clamps and warns once (a hot path must not spam stderr) — | ||
| * because a typo in one env var must not break every run in the process. | ||
| */ | ||
|
|
||
| /** | ||
| * Node timers (`setTimeout`, `AbortSignal.timeout`) take a delay that fits in a 32-bit signed | ||
| * int. Above this the delay silently clamps to 1 ms; `AbortSignal.timeout` throws past 2^32-1. | ||
| * ~24.8 days — no runner timeout is legitimately longer. | ||
| */ | ||
| export const TIMER_MAX_MS = 2_147_483_647; | ||
|
|
||
| function defaultLog(msg: string): void { | ||
| process.stderr.write(`${msg}\n`); | ||
| } | ||
|
|
||
| /** Names already warned about, so a per-turn or per-poll read warns once, not every call. */ | ||
| const warnedNames = new Set<string>(); | ||
|
|
||
| /** Test-only: forget which names have warned, so a case can assert on the warning it triggers. */ | ||
| export function resetEnvWarnings(): void { | ||
| warnedNames.clear(); | ||
| } | ||
|
|
||
| export interface EnvIntOptions { | ||
| /** Lowest accepted value; anything below is clamped up to it. Defaults to 1 — zero is a | ||
| * degenerate delay/count everywhere in the runner, never a meaningful "disabled". */ | ||
| min?: number; | ||
| /** Highest accepted value; anything above is clamped down to it. */ | ||
| max?: number; | ||
| /** Where a bad-override warning goes. Defaults to stderr. */ | ||
| log?: (msg: string) => void; | ||
| } | ||
|
|
||
| /** | ||
| * Read `name` as an integer inside `[min, max]`, falling back to `fallback` (a trusted code | ||
| * constant, used as-is) when unset or unparseable. | ||
| * | ||
| * Unset or empty is the normal case and stays silent; a value that was *set but unusable* is | ||
| * reported once, since it means the operator's intent is not in effect. | ||
| */ | ||
| export function envInt( | ||
| name: string, | ||
| fallback: number, | ||
| { min = 1, max = Number.MAX_SAFE_INTEGER, log = defaultLog }: EnvIntOptions = {}, | ||
| ): number { | ||
| const raw = process.env[name]; | ||
| if (raw === undefined || raw.trim() === "") return fallback; | ||
|
|
||
| const parsed = Number(raw); | ||
| if (!Number.isFinite(parsed)) { | ||
| warnOnce(name, `unparseable value '${raw}'; using ${fallback}`, log); | ||
| return fallback; | ||
| } | ||
|
|
||
| // Truncate first, then clamp: a fractional value like 0.5 would otherwise floor to a 0 that | ||
| // passes a naive `> 0` check. | ||
| const truncated = Math.trunc(parsed); | ||
| if (truncated < min) { | ||
| warnOnce(name, `${raw} below minimum ${min}; clamped`, log); | ||
| return min; | ||
| } | ||
| if (truncated > max) { | ||
| warnOnce(name, `${raw} above maximum ${max}; clamped`, log); | ||
| return max; | ||
| } | ||
| return truncated; | ||
| } | ||
|
|
||
| /** | ||
| * Read `name` as a timer delay in ms. Same as `envInt` with the ceiling Node's timers impose, | ||
| * so an override can shorten or lengthen a timeout but can never turn it into "fires instantly". | ||
| */ | ||
| export function envTimerMs( | ||
| name: string, | ||
| fallbackMs: number, | ||
| options: EnvIntOptions = {}, | ||
| ): number { | ||
| return envInt(name, fallbackMs, { | ||
| ...options, | ||
| max: Math.min(options.max ?? TIMER_MAX_MS, TIMER_MAX_MS), | ||
| }); | ||
| } | ||
|
|
||
| function warnOnce(name: string, detail: string, log: (msg: string) => void): void { | ||
| if (warnedNames.has(name)) return; | ||
| warnedNames.add(name); | ||
| log(`[env] ${name}: ${detail}`); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| /** | ||
| * The numeric-env reader (src/env.ts). Each case here is a way a hand-rolled | ||
| * `Number(process.env.X ?? d)` read used to fail open — silently producing a value that | ||
| * disables the protection the env var exists to tune. | ||
| */ | ||
| import { describe, it, beforeEach, assert, vi } from "vitest"; | ||
|
|
||
| import { envInt, envTimerMs, resetEnvWarnings, TIMER_MAX_MS } from "../../src/env.ts"; | ||
|
|
||
| const NAME = "AGENTA_TEST_ENV_NUM"; | ||
|
|
||
| describe("envInt", () => { | ||
| beforeEach(() => { | ||
| resetEnvWarnings(); | ||
| }); | ||
|
|
||
| it("unset or blank falls back silently (the normal case)", () => { | ||
| const warns: string[] = []; | ||
| assert.equal(envInt(NAME, 500, { log: (m) => warns.push(m) }), 500); | ||
| vi.stubEnv(NAME, ""); | ||
| assert.equal(envInt(NAME, 500, { log: (m) => warns.push(m) }), 500); | ||
| vi.stubEnv(NAME, " "); | ||
| assert.equal(envInt(NAME, 500, { log: (m) => warns.push(m) }), 500); | ||
| assert.deepEqual(warns, []); | ||
| }); | ||
|
|
||
| it("a usable value wins", () => { | ||
| vi.stubEnv(NAME, "250"); | ||
| assert.equal(envInt(NAME, 500), 250); | ||
| }); | ||
|
|
||
| it("unparseable falls back and says so once", () => { | ||
| const warns: string[] = []; | ||
| vi.stubEnv(NAME, "30s"); | ||
| assert.equal(envInt(NAME, 500, { log: (m) => warns.push(m) }), 500); | ||
| assert.equal(envInt(NAME, 500, { log: (m) => warns.push(m) }), 500); | ||
| assert.equal(warns.length, 1, "a per-turn read must warn once, not every call"); | ||
| assert.match(warns[0], /unparseable value '30s'/); | ||
| }); | ||
|
|
||
| // The CodeRabbit finding on #5501, at the root: 0.5 truncates to 0, and a 0 ms | ||
| // AbortSignal.timeout aborts the request on the spot. | ||
| it("a sub-unit value clamps to the minimum instead of truncating to zero", () => { | ||
| vi.stubEnv(NAME, "0.5"); | ||
| assert.equal(envInt(NAME, 500, { min: 1 }), 1); | ||
| }); | ||
|
|
||
| it("zero and negatives clamp to the minimum", () => { | ||
| vi.stubEnv(NAME, "0"); | ||
| assert.equal(envInt(NAME, 500, { min: 1 }), 1); | ||
| vi.stubEnv(NAME, "-7"); | ||
| assert.equal(envInt(NAME, 500, { min: 1 }), 1); | ||
| }); | ||
|
|
||
| it("fractions above the minimum truncate", () => { | ||
| vi.stubEnv(NAME, "42.9"); | ||
| assert.equal(envInt(NAME, 500), 42); | ||
| }); | ||
|
|
||
| it("clamps down to an explicit maximum", () => { | ||
| const warns: string[] = []; | ||
| vi.stubEnv(NAME, "99"); | ||
| assert.equal(envInt(NAME, 6, { min: 1, max: 12, log: (m) => warns.push(m) }), 12); | ||
| assert.match(warns[0], /above maximum 12/); | ||
| }); | ||
| }); | ||
|
|
||
| describe("envTimerMs", () => { | ||
| beforeEach(() => { | ||
| resetEnvWarnings(); | ||
| }); | ||
|
|
||
| it("clamps to the 32-bit timer ceiling rather than overflowing to 1ms", () => { | ||
| vi.stubEnv(NAME, String(TIMER_MAX_MS + 1)); | ||
| assert.equal(envTimerMs(NAME, 5_000), TIMER_MAX_MS); | ||
| // Past 2^32-1 AbortSignal.timeout throws outright; the clamp keeps it constructible. | ||
| vi.stubEnv(NAME, "1e12"); | ||
| resetEnvWarnings(); | ||
| const ms = envTimerMs(NAME, 5_000); | ||
| assert.equal(ms, TIMER_MAX_MS); | ||
| assert.doesNotThrow(() => AbortSignal.timeout(ms)); | ||
| }); | ||
|
|
||
| it("keeps a caller's own maximum when it is tighter than the timer ceiling", () => { | ||
| vi.stubEnv(NAME, "60000"); | ||
| assert.equal(envTimerMs(NAME, 5_000, { max: 10_000 }), 10_000); | ||
| }); | ||
|
|
||
| it("never yields a delay that aborts immediately", () => { | ||
| for (const raw of ["0", "0.9", "-1", "abc", ""]) { | ||
| vi.stubEnv(NAME, raw); | ||
| resetEnvWarnings(); | ||
| assert.isAtLeast(envTimerMs(NAME, 5_000), 1, `raw=${raw}`); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.