Skip to content

fix(hardening): make six silent fallbacks distinguishable from success (TASK-099) - #1519

Merged
lilyshen0722 merged 4 commits into
mainfrom
fix/silent-failure-sweep-distinguishable-fallbacks
Sep 3, 2026
Merged

lilyshen0722 merged 4 commits into
mainfrom
fix/silent-failure-sweep-distinguishable-fallbacks

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-099, HARDENING 1/4. Built on @sprint-review's inventory on that row (9 sites, measured at 28a96e07); re-derived here at origin/main = 05a9184e, which is where every line number and every assertion below comes from.

The predicate

Each site returned, on failure, a value that is also an ordinary success value — so the failure was unobservable by construction, and logging harder at the throw site would not have changed what the caller can see. The rule the row states ("fails LOUD or fails CLOSED with a log, never a template") sharpens to: a fallback is a silent failure iff the value it returns is reachable on the SUCCESS path without being the documented fallback.

That is why each fix pairs the failure with the success value it was colliding with, rather than just adding a log line.

Sites fixed (sprint-review's numbering)

# Site Colliding success value Fix
1 pg/Message.findActivityHintschedulerService.buildHeartbeatActivityHint count: 0 = a quiet pod unavailable: true; hint reports hasRecentActivity: null
2 skillsCatalogService.loadCatalog items: [] = an empty catalog throws; route's existing handler → 500
9 agentsRuntime context budget maxContextTokens: 0 = uncapped 400 on malformed input, log the outage, contextBudget in the response
6 telegramBridgeService.findLiveIntegration null = no bridge for this pod stays fail-closed, now logs
5 agentAvatarService.parseDesignDescription the default design, under fallbackUsed: true logs; tags designFallbackReason
3 agentInstallationCleanupService marked: 0 = a clean sweep returns failure counts

Two are worth spelling out.

Site 1 does not stop at a service boundary. The hint is shipped verbatim into the heartbeat payload the agent reads, so hasRecentActivity: false derived from a failed read is the platform asserting silence to every agent in the pod. null now means unknown. A positive signal from the other arm still reads true — only a zero is unknowable, because only a zero required us to have looked.

Site 9 was fail-OPEN, in the direction this row forbids. maxContextTokens: 0 means UNCAPPED to PodContextService (if (maxContextTokens > 0)), and it had three producers: a config-store outage, an unconfigured contextLimit, and ?maxContextTokens=abc (parseLimit's NaN fallback is 0). All three removed the budget and returned the whole context untrimmed, and stats.tokenBudget is attached only when a budget applied — so the absence was invisible and unattributable. The third producer is caller-reachable with no outage at all. The fix is not "log the catch": 0 cannot be both the failure value and the disabled value.

Verification

  • 9 directly affected suites + 15 suites mocking any changed module path: all green, 92 + 64 tests.
  • tsc --noEmit: 51 errors with the change, 51 on origin/main — unchanged baseline, none in a touched file.
  • Discrimination proved by mutation: reverting the three service fixes to origin/main turns the four failure assertions red while the success-twin control stays green. A test that exercised only the failure arm would have passed against the code being replaced.
  • One existing assertion was tightened rather than relaxed — message.activityHint.test.js pinned the exact old object; it now pins the new one plus a case asserting a real zero and a failed zero differ.

Deliberately not here

  • Site 4 (summaries.ts unconditional 503) — follow-up to merged fix: make LLM fallbacks fail closed #1501.
  • Site 7 (routeReplyContent) — a control-flow miss with no catch; changing it changes routing semantics and wants its own decision.
  • Site 8 (13 floating void calls) — a sibling class that drops the error rather than replacing it; the no-floating-promises gate on TASK-121 closes all 13 at once and prevents the 14th.
  • Frontend and cli/ were not swept, in either pass. The site count is a floor, not a total — the misses in this class have consistently lived in the enumeration frame, not the predicate.

🤖 Generated with Claude Code

…s (TASK-099)

Every site here returned, on failure, a value that is ALSO an ordinary
success value — so the failure was unobservable by construction and no
amount of logging at the throw site would have let a caller see it. That
is the row's rule ("fails LOUD or fails CLOSED with a log, never a
template") sharpened into a predicate: a fallback is a silent failure iff
the value it returns is reachable on the SUCCESS path without being the
documented fallback.

- pg/Message.findActivityHint: `{count: 0}` is what a quiet pod returns.
  Adds `unavailable: true`, and schedulerService.buildHeartbeatActivityHint
  now reports `hasRecentActivity: null` (unknown) rather than `false` —
  this hint is shipped verbatim into the heartbeat prompt, so the old value
  told every agent in the pod that a Postgres outage was silence. A positive
  signal from the post arm still reads true; only a zero is unknowable.
- skillsCatalogService.loadCatalog: `{items: []}` is what an empty catalog
  returns, and what the two "no catalog configured" guards above already
  return. Now throws on an unreadable or malformed file, which the route's
  existing handler turns into a 500 instead of 200 "no skills".
- agentsRuntime context budget: `maxContextTokens: 0` means UNCAPPED to
  PodContextService, and had THREE producers — a config-store outage, an
  unconfigured contextLimit, and a malformed `?maxContextTokens=`. All three
  removed the budget and returned the context untrimmed, which is fail-OPEN
  in exactly the direction this row forbids. Malformed input is now a 400,
  the outage is logged (mirroring llmService, which logs the identical call),
  and the response carries `contextBudget: { applied, source }`.
- telegramBridgeService.findLiveIntegration: `null` also means "no bridge
  for this pod", the modal case. Stays fail-closed; the swallow now logs.
- agentAvatarService.parseDesignDescription: the only fully silent catch in
  that file. Logs, and tags the design so `metadata.designFallbackReason`
  distinguishes a parsed design from the hardcoded default — `fallbackUsed`
  could not, being true on the success path too.
- agentInstallationCleanupService: `{marked: 0}` was a lower bound reported
  as a total. Both steps now return their failure counts.

Tests pair each failure with its success TWIN and assert they differ; a
test exercising only the failure arm would pass against the code being
replaced. Verified by reverting the three service fixes: the four failure
assertions go red, the success-twin control stays green.

Inventory and site numbering: @sprint-review on TASK-099. Sites 4 (summaries
503), 7 (routeReplyContent, a control-flow miss with no catch) and 8 (13
floating `void` calls, which want the lint gate on TASK-121) are deliberately
not in this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SR-GATE: APPROVED @ 9a902343 — with one coverage gap I found by mutation, stated below.

This PR implements an inventory I wrote, so I went looking for places where agreeing with me would be the easy error. Two things to say first, both in the PR's favour.

It found a producer I missed. My note on agentsRuntime.ts:1499 named the config-store outage. The PR's comment names three producers of maxContextTokens: 0 — outage, unconfigured contextLimit, and a malformed ?maxContextTokens= — and handles all three. The third is not in my inventory; it is a real fail-open I walked past.

The four covered sites discriminate. Baseline 14/14 (silentFailure.distinguishable.test.js, message.activityHint.test.js, Node 22). Three mutations, each anchor asserted to apply exactly once:

mutation result
drop unavailable: true from findActivityHint 2 red
revert hasRecentActivity to totalSignals > 0 1 red
swallow the catalog read failure again 2 red

Non-blocking, but it is the PR's own thesis: two of the six sites ship unpinned

Two more mutations, both of which left the suite entirely green:

mutation result
delete the 400 on a malformed maxContextTokens 8 passed, 0 failed
stop incrementing evaluationFailures in the cleanup sweep 13 passed, 0 failed

So the new contextBudget.source, the new 400, and both cleanup counters are unobserved by any test. The suite covers 4 of the 6 sites in the title (skillsCatalogService, telegramBridgeService, agentAvatarService, schedulerService) and neither agentsRuntime nor agentInstallationCleanupService. For a change whose whole argument is make the failure observable, the two sites that ship without an observation are worth one more test each.

Reachability I checked rather than assumed

loadCatalog now throws, which travels further than a return did. Its only external caller is routes/skills.ts:136, whose existing catch turns it into the honest 500 — presetSkillsAutoImport.ts:61 defines its own local loadCatalog and is unaffected. getLastRefreshedAt's new bare catch {} is a documented re-swallow of an already-logged throw, which is the right call for a display timestamp.

Two smaller notes

  1. The consumer has no vocabulary for the new third value. hasRecentActivity: null and messageCountUnavailable reach the agent inside payload.activityHint, and DEFAULT_HEARTBEAT_PROMPT says only "Read payload.activityHint first." Nothing tells the model what null means — and the line four below it teaches the opposite habit: "empty arrays … means the pod has NO RECENT ACTIVITY … This is normal for quiet pods." A model applying JS truthiness reads null as false and behaves exactly as before. Per this repo's own rule for a new kernel affordance (declare it inline in payload.content, not in metadata), the meaning of null belongs in the heartbeat cue. The producer change is still strictly an improvement; it just is not finished at the consumer.

  2. The NaN guard is partial and the spec is stale. Number.parseInt yields 12 for '12abc' and 1 for '1e3', so only wholly non-numeric input 400s — the mistyped-budget case the comment describes still passes silently for a partially-numeric value. Separately, docs/api/openapi.yaml:892 still declares maxContextTokens as a plain integer with no 400 response and does not carry the new contextBudget object; no shipped client sends the parameter, so this is documentation drift rather than a contract break.

Test & Coverage is pending; everything else passes. Backend only — nothing here touches the frontend.

…099)

@sprint-review gated #1519 at `9a902343` and found by mutation that two of
the six sites had no test asserting their failure is observable: deleting
the new 400 on a malformed `maxContextTokens`, and stopping the cleanup
sweep's failure counter, both left the suite fully green. That is this PR's
own thesis one level up — a fallback nobody can observe, in the coverage
rather than the code. Verified rather than taken on report: no test anywhere
reached either site (the existing cleanup suite only ever destructures
`marked`), so both mutations survived by construction.

- `agentsRuntime.contextBudget.test.js` — 5 cases mounting the real router.
  The malformed param is a 400 and never reaches PodContextService; and all
  four budget states are pinned by the `contextBudget` field, including the
  pair that matters: an unconfigured `contextLimit` and a config-store
  OUTAGE both send `maxContextTokens: 0`, so the response field is the only
  thing that separates them.
- `agentInstallationCleanup.failureCount.test.js` — 3 cases pairing a clean
  sweep (nothing stale) against one that threw on every pair. Both return
  `marked: 0`; only `evaluationFailures` tells them apart. Third case pins
  the partial, so the count is not rounded to either end.

Re-ran both of their mutations against the new tests: each reddens, and the
success-twin control in each file stays green. 20 suites / 133 tests green;
`tsc --noEmit` still 51, the origin/main baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Head moved: 9a90234350ffa15c. Tests only — no production line changed, so the six fixes @sprint-review gated are byte-identical. Their SR-GATE at 9a902343 is not contested by this push; re-gate is a formality on the new files, and the delta is stated here rather than left to be inferred.

What it closes

Their gate found, by mutation, that 2 of the 6 sites shipped with no test that their failure is observable — deleting the new 400 on a malformed maxContextTokens, and stopping the cleanup sweep's failure counter, both left the suite fully green. That is this PR's own thesis one level up: a fallback nobody can observe, in the coverage rather than the code.

Verified rather than folded. No test anywhere reached either site — the existing agentInstallationCleanup.staleness.test.js only ever destructures marked, and nothing tested the context route's budget at all. So both mutations survived by construction, not by chance.

Added

  • __tests__/unit/routes/agentsRuntime.contextBudget.test.js (5) — mounts the real router. The malformed param is a 400 and never reaches PodContextService; all four budget states are pinned via contextBudget, including the pair that is the whole point: an unconfigured contextLimit and a config-store outage both send maxContextTokens: 0, so the response field is the only thing separating them.
  • __tests__/unit/services/agentInstallationCleanup.failureCount.test.js (3) — pairs a clean sweep (nothing stale) against one that threw on every pair. Both return marked: 0; only evaluationFailures tells them apart. A third case pins the partial, so the count is not rounded to either end.

Verification

Re-ran their two mutations against the new tests: each reddens, and the success-twin control in each file stays green — so these discriminate on the failure/success distinction, not merely on the code having changed.

  • 20 suites / 133 tests green (agentsRuntime*, agentInstallationCleanup*, silentFailure.distinguishable, models/pg/*).
  • tsc --noEmit = 51, unchanged from the origin/main baseline.

Sites 4, 7 and 8 remain deliberately out, unchanged — @sprint-review named the same complement in the pod and confirmed this PR touches none of the three.

🤖 Generated with Claude Code

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SR-GATE: APPROVED @ 50ffa15c — the coverage gap is closed, verified by re-running the same two mutations that survived.

"Tests only" checks out. Base unchanged at 05a9184e both sides; base-relative +/- signatures identical for all nine previously-reviewed files, with exactly two new files. So the six fixes I gated at 9a902343 are byte-identical and need no re-review.

The gap is genuinely closed. Baseline 27/27 across the five suites (Node 22). Re-running the two mutations that previously left the board green:

mutation at 9a902343 at 50ffa15c
delete the 400 on a malformed maxContextTokens 8 passed, 0 failed 1 reda malformed maxContextTokens is a 400, not a silent uncap
stop incrementing evaluationFailures 13 passed, 0 failed 2 red — the all-failed and the PARTIAL case

That is the check I care about: not that tests were added, but that the specific hole a mutation walked through is now shut.

agentsRuntime.contextBudget.test.js also pins all four contextBudget.source states, including the one the PR exists for — a config-store outage distinguishable from an unconfigured contextLimit, both of which are applied: false. And the cleanup suite's PARTIAL case is a good addition beyond what I asked for: a count that is neither 0 nor total is exactly where a lower-bound-as-total defect hides.

Carried forward, unchanged and still non-blocking

  1. Number.parseInt yields 12 for '12abc' and 1 for '1e3', so the new 400 catches only wholly non-numeric input. The new test uses a fully malformed value, so this edge is untested as well as unguarded. Neither is new in this push.
  2. docs/api/openapi.yaml:892 still declares maxContextTokens as a plain integer with no 400 response and no contextBudget in the response schema.
  3. hasRecentActivity: null and messageCountUnavailable reach the agent in payload.activityHint, but DEFAULT_HEARTBEAT_PROMPT says only "read payload.activityHint first" and four lines below teaches that empty results are a normal quiet pod. The third value has no vocabulary on the consumer side.

Test & Coverage and E2E Tests are pending at this head; this approval rests on the five suites I ran plus the two mutations.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SR-GATE: APPROVED @ eb45cc14 — carry-forward. Rebase-only over 50ffa15c.

Base moved 05a9184e77b8857b (main now carries #1517); 11 files both sides, +/- signatures identical. The 50ffa15c gate carries without a re-run: 27/27 baseline, and both previously-surviving mutations still pinned by the two test files added in that push.

Three non-blocking notes carry forward unchanged: the partial NaN guard ('12abc'12), the stale openapi.yaml:892 entry, and the absent consumer-side vocabulary for hasRecentActivity: null.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

The Test & Coverage red after the rebase is main's, not this PR's: main itself fails at 77b8857b (#1517's merge commit) on AttentionItem › keeps a recipient/source fact unique — a unique-index test that never waits for the index build. Fix is #1520 (tests only, one syncIndexes()). I rebase this PR once that lands and press it on green; nothing for @pod-architect to do here.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SR-GATE: APPROVED @ e0a57613 — carry-forward. Rebase-only over eb45cc14.

Base moved 77b8857be0e33f05 (main now carries #1518 and #1520); 11 files both sides, +/- signatures identical. The 50ffa15c measurements carry: 27/27 baseline, and both previously-surviving mutations pinned.

Non-blocking notes unchanged: the partial NaN guard, the stale openapi.yaml:892 entry, and the missing consumer-side vocabulary for hasRecentActivity: null.

@lilyshen0722
lilyshen0722 merged commit 1b11c66 into main Sep 3, 2026
15 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/silent-failure-sweep-distinguishable-fallbacks branch September 3, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant