@openparachute/agent
Advanced tools
+118
| /** | ||
| * A tiny exponential-backoff circuit breaker for the daemon's repeating loops | ||
| * (agent#187 — the P0 port-exhaustion engine). The reconcile / poll / scheduler | ||
| * loops all run at a FIXED interval; when the thing they call keeps failing (auth | ||
| * broke underneath them, the vault is unreachable), a fixed interval means they | ||
| * hammer the failing dependency forever with zero widening — during the 2026-07-02 | ||
| * incident two loops 401'd 859× and 444× at their normal cadence. | ||
| * | ||
| * This is the shared widening primitive: a caller consults {@link Backoff.ready} | ||
| * before each attempt, calls {@link Backoff.fail} on a failure (widening the | ||
| * cooldown), and {@link Backoff.succeed} on a success (closing the breaker). The | ||
| * fixed-interval tick keeps ticking; the breaker just makes most ticks a cheap | ||
| * no-op while a dependency is down, so the failing call is retried on an | ||
| * exponential schedule (base → base·2 → base·4 → … → cap) with jitter instead of | ||
| * every interval. | ||
| * | ||
| * Deterministic by construction: the clock (`now`) and the jitter source | ||
| * (`random`) are injectable, so tests step a fake clock and assert the exact | ||
| * progression. No real `Date.now()` / `Math.random()` appears in the logic. | ||
| */ | ||
| export interface BackoffConfig { | ||
| /** Cooldown after the FIRST consecutive failure (ms). Default 60s. */ | ||
| baseMs?: number; | ||
| /** Maximum cooldown — the widening caps here (ms). Default 30m. */ | ||
| capMs?: number; | ||
| /** Exponential factor between consecutive failures. Default 2. */ | ||
| factor?: number; | ||
| /** | ||
| * Jitter as a fraction (0..1) of the computed delay, added on top (so the real | ||
| * delay is `delay + delay·jitter·random()` — always ≥ the base delay, never less, | ||
| * so the cap is a floor-preserving ceiling). Default 0.2. Set 0 for a pure schedule. | ||
| */ | ||
| jitter?: number; | ||
| /** Monotonic-ish clock in ms. Default `Date.now`. Injected for tests. */ | ||
| now?: () => number; | ||
| /** Jitter source in [0,1). Default `Math.random`. Injected for tests. */ | ||
| random?: () => number; | ||
| } | ||
| /** Resolve a base/cap pair from env with sane defaults (no new REQUIRED config). */ | ||
| export function backoffConfigFromEnv(env: Record<string, string | undefined>): { | ||
| baseMs: number; | ||
| capMs: number; | ||
| } { | ||
| const baseMs = parseInt(env.PARACHUTE_AGENT_BACKOFF_BASE_MS ?? "", 10) || 60_000; | ||
| const capMs = parseInt(env.PARACHUTE_AGENT_BACKOFF_CAP_MS ?? "", 10) || 1_800_000; | ||
| return { baseMs, capMs: Math.max(capMs, baseMs) }; | ||
| } | ||
| export class Backoff { | ||
| private readonly baseMs: number; | ||
| private readonly capMs: number; | ||
| private readonly factor: number; | ||
| private readonly jitter: number; | ||
| private readonly now: () => number; | ||
| private readonly random: () => number; | ||
| /** Consecutive failures since the last success (0 = breaker closed). */ | ||
| private failures = 0; | ||
| /** Epoch-ms before which no attempt is allowed (0 = ready now). */ | ||
| private openUntilMs = 0; | ||
| constructor(cfg?: BackoffConfig) { | ||
| this.baseMs = cfg?.baseMs ?? 60_000; | ||
| this.capMs = Math.max(cfg?.capMs ?? 1_800_000, this.baseMs); | ||
| this.factor = cfg?.factor ?? 2; | ||
| this.jitter = cfg?.jitter ?? 0.2; | ||
| this.now = cfg?.now ?? Date.now; | ||
| this.random = cfg?.random ?? Math.random; | ||
| } | ||
| /** True when an attempt is allowed (the breaker is closed, or its cooldown elapsed). */ | ||
| ready(): boolean { | ||
| return this.now() >= this.openUntilMs; | ||
| } | ||
| /** Milliseconds until the next allowed attempt (0 when ready). */ | ||
| remainingMs(): number { | ||
| return Math.max(0, this.openUntilMs - this.now()); | ||
| } | ||
| /** Consecutive-failure count (for log lines / tests). */ | ||
| get consecutiveFailures(): number { | ||
| return this.failures; | ||
| } | ||
| /** Whether the breaker is currently open (in a cooldown window). */ | ||
| get isOpen(): boolean { | ||
| return this.failures > 0 && !this.ready(); | ||
| } | ||
| /** | ||
| * Record a failure → widen the cooldown exponentially (capped) with jitter, and | ||
| * return the applied delay (ms) so the caller can log "backing off Ns". The next | ||
| * {@link ready} returns false until `now + delay`. | ||
| */ | ||
| fail(): number { | ||
| this.failures += 1; | ||
| const exp = this.baseMs * this.factor ** (this.failures - 1); | ||
| const capped = Math.min(this.capMs, exp); | ||
| const delay = capped + capped * this.jitter * this.random(); | ||
| this.openUntilMs = this.now() + delay; | ||
| return delay; | ||
| } | ||
| /** | ||
| * Record a success → close the breaker (reset the failure count + cooldown). | ||
| * Returns true when it had been widened (≥1 prior failure), so the caller can | ||
| * emit a one-line "recovered" note only on an actual recovery. | ||
| */ | ||
| succeed(): boolean { | ||
| const wasOpen = this.failures > 0; | ||
| this.failures = 0; | ||
| this.openUntilMs = 0; | ||
| return wasOpen; | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "@openparachute/agent", | ||
| "version": "0.2.5-rc.1", | ||
| "version": "0.2.5-rc.2", | ||
| "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.", | ||
@@ -5,0 +5,0 @@ "license": "AGPL-3.0", |
+30
-1
@@ -39,2 +39,3 @@ /** | ||
| import type { Job } from "./jobs.ts"; | ||
| import { Backoff, type BackoffConfig } from "./backoff.ts"; | ||
@@ -70,2 +71,11 @@ /** Load the current jobs (the vault-native store queries the vault). Async. */ | ||
| log?: { warn: (msg: string) => void; error: (msg: string) => void }; | ||
| /** | ||
| * Circuit-breaker tuning for the `loadJobs` failure loop (agent#187). When | ||
| * `loadJobs` keeps failing (auth broke underneath the daemon, vault unreachable), | ||
| * the fixed tick otherwise re-hits it every interval forever; this widens the retry | ||
| * exponentially (base → cap) instead. `now` is overridden to the runner's own clock | ||
| * so a fake-clock test drives the breaker in lockstep with the tick. Sane defaults — | ||
| * no config required. | ||
| */ | ||
| loadBackoff?: BackoffConfig; | ||
| } | ||
@@ -105,2 +115,4 @@ | ||
| private handle: { cancel: () => void } | undefined; | ||
| /** Circuit breaker for the `loadJobs` failure loop (agent#187). */ | ||
| private readonly loadBackoff: Backoff; | ||
@@ -118,2 +130,8 @@ constructor(opts: RunnerOptions) { | ||
| }; | ||
| // Share the runner's clock so the breaker and the tick advance together (tests step | ||
| // one fake clock). Caller-supplied base/cap/jitter still apply; `now` is authoritative. | ||
| this.loadBackoff = new Backoff({ | ||
| ...opts.loadBackoff, | ||
| now: () => this.now().getTime(), | ||
| }); | ||
| } | ||
@@ -167,2 +185,5 @@ | ||
| const at = this.now(); | ||
| // BACKOFF GATE (agent#187): while the breaker is open (loadJobs has been failing), most | ||
| // ticks are a cheap no-op — we don't re-hit the failing dependency every interval. | ||
| if (!this.loadBackoff.ready()) return; | ||
| let jobs: Job[]; | ||
@@ -172,5 +193,13 @@ try { | ||
| } catch (err) { | ||
| this.log.error(`runner: loadJobs failed (skipping this tick): ${(err as Error).message}`); | ||
| const delay = this.loadBackoff.fail(); | ||
| this.log.error( | ||
| `runner: loadJobs failed (skipping this tick; backing off ${Math.round(delay / 1000)}s after ` + | ||
| `${this.loadBackoff.consecutiveFailures} consecutive failure(s)): ${(err as Error).message}`, | ||
| ); | ||
| return; | ||
| } | ||
| // A clean load closes the breaker; log once on an actual recovery (was widened). | ||
| if (this.loadBackoff.succeed()) { | ||
| this.log.warn(`runner: loadJobs recovered — resuming normal ${Math.round(this.intervalMs / 1000)}s cadence.`); | ||
| } | ||
@@ -177,0 +206,0 @@ // Prune horizons for jobs that no longer exist (deleted), so the map can't grow. |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1565817
1.35%58
1.75%24823
1.63%32
3.23%