Family anchor. This is one of three poles of the same missing primitive. See "The family" below — the fix should be a shared bounded-retry-with-reason primitive, not three local patches.
The family: one missing primitive, three different signs
Three independently-discovered failures turn out to be the same gap — no shared notion of bounded retry that records why it stopped.
|
pole |
retry behaviour |
symptom |
| #16222 (this) |
embedding write canary |
none — failure is never cached, so it retries at probe frequency |
provider saturated, unrecoverable without intervention |
| #16224 |
TenantRepoSync |
infinite backoff — 4/4 repos suppressed, no retry in 25+ hours, survives an orchestrator restart |
KB permanently at zero behind sweeps reporting completed |
| #16223 |
miniSummary backfill |
infinite retry on silently-empty generations |
sustained multi-core burn with no user load |
The canary retries when it must not. TenantRepoSync stops retrying and never resumes. The backfill retries something that can never succeed. Each was patched-in-isolation-shaped when found alone; together they describe one primitive nobody has: retry with a bounded attempt budget, a recorded stop reason, and a guaranteed resumption condition.
Two secondary gaps fall out of the same place and are worth carrying with the family:
- No stop reason is retained.
TenantRepoSync shows three of four repos with seven consecutive failures each and lastErrorCode: null — a counter that increments without recording a cause turns a one-command fix into a guessing exercise.
- Green surfaces over a dead lane. A suppressed lane reports
status: completed, lastExitCode: 0, lastError: null, with a lastSuccessAt seconds old — because the sweep genuinely succeeded at attempting nothing. Silence from backoff is indistinguishable from recovery, and selfHeal recorded zero events ever against a permanently starved lane. "Required lane suppressed beyond N hours with zero successes" is a detectable condition that nothing currently detects.
Implementation of the family is declared by @neo-opus-vega following #16208.
Context
ai/scripts/diagnostics/mcpHealthcheck.mjs is the process container healthchecks run. It calls the MCP healthcheck tool and exits non-zero unless the returned status is healthy.
The Memory Core healthcheck tool performs an embedding write canary as part of that call, bounded by embeddingWriteCanaryTimeoutMs (default 30000, ai/mcp/server/memory-core/configBase.mjs:386).
HealthService.#getEmbeddingWriteCanary caches the canary result for 60s — but only on success:
if (result.status === 'healthy') {
this.#embeddingWriteCanaryCache = {key, checkedAt: now, result};
} else {
this.#embeddingWriteCanaryCache = null;
}Problem
That asymmetry creates two regimes with very different costs, and the expensive one is self-sustaining.
Healthy: the canary runs at most once per 60s regardless of probe frequency. A 10s container interval costs ~1,440 canaries/day. Bounded and fine.
Failing: every failure nulls the cache, so the next probe runs the canary again. With a typical interval: 10s against a 30000 ms deadline, attempts overlap and accumulate — interval < timeout makes concurrent canaries reachable by construction.
The failing regime cannot exit itself. Repopulating the cache requires a healthy canary, which requires a responsive provider, which requires load to drop — but the load is now one uncached canary per probe, plus whatever overlapping attempts are still in flight. A transient slowdown latches into a permanent one.
The asymmetry is deliberate, and this supersedes its stated rationale. HealthService.mjs:909-911:
"Cache only healthy canaries so degraded probes still retry immediately, while a recent success avoids adding pressure to the interactive embedding path during diagnostics."
That reasoning holds when failures are rare and retry-worthy. Under provider saturation they are neither — and the goal the comment names, sparing the interactive embedding path, is precisely what the behaviour defeats. Whoever implements this should retire that assumption explicitly rather than silently inverting it.
Overlap is structural, not just reachable. The container probe and the in-server canary have independent lifetimes. A compose probe with timeout: 10s against a 30s canary deadline means Docker kills the probe at 10s while the server-side canary keeps running to completion. Abandoned canaries therefore stack inside the server no matter what the probe script does — so the single-flight join point has to live inside #getEmbeddingWriteCanary, not in mcpHealthcheck.mjs. Client-side guarding cannot reach this.
It is also undiagnosable from inside the affected service: the Memory Core reports degraded on its own probe timing out, while the cause is its own probe cadence contending for a provider that may be shared with sibling containers.
Evidence: two deployments, same build, opposite regimes
| deployment |
probes/24h |
avg duration |
canary state |
| A |
7,635 |
0.94 s |
healthy → cached |
| B |
1,133 |
13.77 s |
failing → uncached |
B makes 6x fewer probes yet spends 15x longer per call. Its maxDurationMs of 37,749 exceeds even the 30,000 ms deadline. That is ~4.3 hours/day of wall time inside liveness probes, and it is the signature of the canary running on essentially every call rather than once a minute.
The user-visible cost on B, same window — semantic recall contends with canaries for the same provider:
query_raw_memories avg 53.7 s max 295 s
query_summaries avg 114.3 s max 196 s
A memory-mining sweep during this state looks like a broken tool.
Architectural Reality
A liveness check answers "is this process able to serve?". It should be cheap, bounded, and side-effect free.
An embedding write canary answers a different and genuinely valuable question: "is the write path to the embedding provider working end to end?". Worth asking — but not at liveness frequency, and not from the probe whose failure restarts the container.
Coupling them makes the expensive question inherit the cheap question's cadence. The 60s cache was the mitigation for that, and it works exactly until the moment it is most needed: the cache is bypassed precisely when the provider is already struggling.
Note the interaction with retries in a typical compose healthcheck: sustained self-inflicted timeouts accumulate toward an unhealthy verdict, so this can escalate from degraded to a restart loop.
Fix
Ordered by how much each contributes to breaking the cycle:
Back off a failing canary. Failure must not retry at probe frequency. Cache the failure with a short TTL, or apply exponential backoff. This alone breaks the loop and is the minimal change.
Single-flight the canary, inside #getEmbeddingWriteCanary. Never let an attempt overlap its predecessor. The join point must be server-side: a probe killed at its own timeout does not stop the canary it started, so client-side guarding cannot prevent server-side stacking.
A proven shape already exists in this codebase — PR #16209 shipped exactly this: a promise assigned synchronously, before any await, so concurrent callers join rather than race, cleared on settle so failures retry fresh and a rejection is never cached. RecorderService.ensureStore() on dev is lift-ready, and it came with a measured falsifier worth copying: 32 parallel calls produced 32 opens before the change and 1 after. The equivalent here is N concurrent healthchecks under a saturated provider producing 1 canary attempt, not N.
Decouple the canary from routine liveness. Default healthcheck to the cheap path, run the canary on its own slow cadence with the last result reported. Subsumes 1 and 2, and is the right end state — a liveness probe should perform no inference at all.
Report the reason. A degraded verdict should distinguish "provider saturated, backing off" from "provider unreachable". Today those are indistinguishable, and only one is self-inflicted.
Document a sane container probe interval. 10s is oversampling for a probe permitted 30s. Minutes is the right order for this class of service. Note this is a mitigation: it lengthens the loop without breaking it, so it does not substitute for 1 and 2.
Out of scope
- #13435 (decoupling the in-container healthcheck from the gitlab-pat user-token gate) — adjacent, different concern.
Acceptance criteria
- A container liveness probe issues no embedding request; verified by tool metrics showing healthcheck calls with no corresponding embedding-provider activity.
- Under a saturated provider and sustained probing, canary attempts decrease rather than running once per probe, and no attempt overlaps another.
- A deployment that has entered the degraded state recovers without operator intervention once the provider becomes responsive.
- The embedding write canary remains available and is exercised on an explicitly configured cadence independent of the liveness interval.
- A
degraded verdict caused by canary timeout carries its reason, distinguishing provider saturation from provider failure.
Contract Ledger (claimer-authored section — @neo-opus-vega, per intake-derived-ledger convention; body owner may reshape)
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Docs |
Evidence |
healthcheck.embeddingWriteCanaryCadenceMs (60000) |
MC configBase.mjs |
producer attempt period; probes never trigger runs, so container probe intervals are free to differ (documented on the leaf) |
<= 0 disables the producer |
leaf JSDoc |
lifecycle spec |
healthcheck.embeddingWriteCanaryHealthyTtlMs (60000) |
same |
staleness floor only: healthy older than 3 · max(cadence, this) degrades |
always read |
leaf JSDoc |
staleness spec |
healthcheck.embeddingWriteCanaryFailureTtlMs / ...MaxMs |
same |
failure backoff base / ceiling |
— |
leaf JSDoc |
gate backoff/cap specs |
| producer lifecycle |
HealthService.startEmbeddingWriteCanary() / stopEmbeddingWriteCanary() |
server boot starts it; process exit stop fences queued ticks; clearCache() preserves it; start-after-stop replaces it; probes cannot create or run it |
never-started + positive cadence → named non-degrading detail |
method JSDoc |
reader-purity, fence, preservation, restart specs |
| health projection |
#applyEmbeddingWriteCanary — every return path (fresh, cached-fast, cached-refresh, all unhealthy early returns, outer catch) |
pending → non-degrading detail; failed → degraded with backing off Nms, streak S; stale-healthy → degraded; never mutates the cached payload; identity preserved when nothing degrades |
— |
method JSDoc |
overlay + early-path specs |
| shared primitive |
ai/services/shared/boundedRetryGate.mjs |
GLOBAL single-flight (max one run across all keys/rotations, ever), both-outcome cache, capped exponential backoff, bounded attempt budget with retained terminal stopReason + named resumption (runNow() / key rotation), coalesced generation rotation with drain |
maxFailureStreak: Infinity = liveness semantics (canary default) |
module JSDoc |
14-test gate suite incl. A→B→A max-one-run and terminal/resumption |
Live-evidence residuals (AC-mapped, owner @neo-opus-vega)
- AC 1 + AC 2 (live) — saturated-plane receipt: probes-run-zero-inference and attempts-DECREASE observed on a running deployment at the active rebuild-run boundary.
- AC 3 (live) — autonomous recovery observed once the provider drains, no operator action.
- AC 4 (docs) — probe-interval-vs-cadence guidance: landed on the cadence config leaf JSDoc (probes free to differ; N probes per window cost zero inference).
The family: one missing primitive, three different signs
Three independently-discovered failures turn out to be the same gap — no shared notion of bounded retry that records why it stopped.
TenantRepoSynccompletedThe canary retries when it must not.
TenantRepoSyncstops retrying and never resumes. The backfill retries something that can never succeed. Each was patched-in-isolation-shaped when found alone; together they describe one primitive nobody has: retry with a bounded attempt budget, a recorded stop reason, and a guaranteed resumption condition.Two secondary gaps fall out of the same place and are worth carrying with the family:
TenantRepoSyncshows three of four repos with seven consecutive failures each andlastErrorCode: null— a counter that increments without recording a cause turns a one-command fix into a guessing exercise.status: completed,lastExitCode: 0,lastError: null, with alastSuccessAtseconds old — because the sweep genuinely succeeded at attempting nothing. Silence from backoff is indistinguishable from recovery, andselfHealrecorded zero events ever against a permanently starved lane. "Required lane suppressed beyond N hours with zero successes" is a detectable condition that nothing currently detects.Implementation of the family is declared by @neo-opus-vega following #16208.
Context
ai/scripts/diagnostics/mcpHealthcheck.mjsis the process container healthchecks run. It calls the MCPhealthchecktool and exits non-zero unless the returned status ishealthy.The Memory Core
healthchecktool performs an embedding write canary as part of that call, bounded byembeddingWriteCanaryTimeoutMs(default30000,ai/mcp/server/memory-core/configBase.mjs:386).HealthService.#getEmbeddingWriteCanarycaches the canary result for 60s — but only on success:if (result.status === 'healthy') { this.#embeddingWriteCanaryCache = {key, checkedAt: now, result}; } else { this.#embeddingWriteCanaryCache = null; // failures are NOT cached }Problem
That asymmetry creates two regimes with very different costs, and the expensive one is self-sustaining.
Healthy: the canary runs at most once per 60s regardless of probe frequency. A 10s container interval costs ~1,440 canaries/day. Bounded and fine.
Failing: every failure nulls the cache, so the next probe runs the canary again. With a typical
interval: 10sagainst a30000ms deadline, attempts overlap and accumulate —interval < timeoutmakes concurrent canaries reachable by construction.The failing regime cannot exit itself. Repopulating the cache requires a healthy canary, which requires a responsive provider, which requires load to drop — but the load is now one uncached canary per probe, plus whatever overlapping attempts are still in flight. A transient slowdown latches into a permanent one.
The asymmetry is deliberate, and this supersedes its stated rationale.
HealthService.mjs:909-911:That reasoning holds when failures are rare and retry-worthy. Under provider saturation they are neither — and the goal the comment names, sparing the interactive embedding path, is precisely what the behaviour defeats. Whoever implements this should retire that assumption explicitly rather than silently inverting it.
Overlap is structural, not just reachable. The container probe and the in-server canary have independent lifetimes. A compose probe with
timeout: 10sagainst a 30s canary deadline means Docker kills the probe at 10s while the server-side canary keeps running to completion. Abandoned canaries therefore stack inside the server no matter what the probe script does — so the single-flight join point has to live inside#getEmbeddingWriteCanary, not inmcpHealthcheck.mjs. Client-side guarding cannot reach this.It is also undiagnosable from inside the affected service: the Memory Core reports
degradedon its own probe timing out, while the cause is its own probe cadence contending for a provider that may be shared with sibling containers.Evidence: two deployments, same build, opposite regimes
B makes 6x fewer probes yet spends 15x longer per call. Its
maxDurationMsof 37,749 exceeds even the 30,000 ms deadline. That is ~4.3 hours/day of wall time inside liveness probes, and it is the signature of the canary running on essentially every call rather than once a minute.The user-visible cost on B, same window — semantic recall contends with canaries for the same provider:
A memory-mining sweep during this state looks like a broken tool.
Architectural Reality
A liveness check answers "is this process able to serve?". It should be cheap, bounded, and side-effect free.
An embedding write canary answers a different and genuinely valuable question: "is the write path to the embedding provider working end to end?". Worth asking — but not at liveness frequency, and not from the probe whose failure restarts the container.
Coupling them makes the expensive question inherit the cheap question's cadence. The 60s cache was the mitigation for that, and it works exactly until the moment it is most needed: the cache is bypassed precisely when the provider is already struggling.
Note the interaction with
retriesin a typical compose healthcheck: sustained self-inflicted timeouts accumulate toward an unhealthy verdict, so this can escalate from degraded to a restart loop.Fix
Ordered by how much each contributes to breaking the cycle:
Back off a failing canary. Failure must not retry at probe frequency. Cache the failure with a short TTL, or apply exponential backoff. This alone breaks the loop and is the minimal change.
Single-flight the canary, inside
#getEmbeddingWriteCanary. Never let an attempt overlap its predecessor. The join point must be server-side: a probe killed at its owntimeoutdoes not stop the canary it started, so client-side guarding cannot prevent server-side stacking.A proven shape already exists in this codebase — PR #16209 shipped exactly this: a promise assigned synchronously, before any
await, so concurrent callers join rather than race, cleared on settle so failures retry fresh and a rejection is never cached.RecorderService.ensureStore()ondevis lift-ready, and it came with a measured falsifier worth copying: 32 parallel calls produced 32 opens before the change and 1 after. The equivalent here is N concurrent healthchecks under a saturated provider producing 1 canary attempt, not N.Decouple the canary from routine liveness. Default
healthcheckto the cheap path, run the canary on its own slow cadence with the last result reported. Subsumes 1 and 2, and is the right end state — a liveness probe should perform no inference at all.Report the reason. A
degradedverdict should distinguish "provider saturated, backing off" from "provider unreachable". Today those are indistinguishable, and only one is self-inflicted.Document a sane container probe interval. 10s is oversampling for a probe permitted 30s. Minutes is the right order for this class of service. Note this is a mitigation: it lengthens the loop without breaking it, so it does not substitute for 1 and 2.
Out of scope
Acceptance criteria
degradedverdict caused by canary timeout carries its reason, distinguishing provider saturation from provider failure.Contract Ledger (claimer-authored section — @neo-opus-vega, per intake-derived-ledger convention; body owner may reshape)
healthcheck.embeddingWriteCanaryCadenceMs(60000)configBase.mjs<= 0disables the producerhealthcheck.embeddingWriteCanaryHealthyTtlMs(60000)3 · max(cadence, this)degradeshealthcheck.embeddingWriteCanaryFailureTtlMs/...MaxMsHealthService.startEmbeddingWriteCanary()/stopEmbeddingWriteCanary()process exitstop fences queued ticks;clearCache()preserves it; start-after-stop replaces it; probes cannot create or run it#applyEmbeddingWriteCanary— every return path (fresh, cached-fast, cached-refresh, all unhealthy early returns, outer catch)backing off Nms, streak S; stale-healthy → degraded; never mutates the cached payload; identity preserved when nothing degradesai/services/shared/boundedRetryGate.mjsstopReason+ named resumption (runNow()/ key rotation), coalesced generation rotation with drainmaxFailureStreak: Infinity= liveness semantics (canary default)Live-evidence residuals (AC-mapped, owner @neo-opus-vega)