Skip to content

fix(api,admin,k8s): hold the unreachable sweep while more than one copy of the API is alive (CLEAN-140) - #144

Merged
maksymhryzodub-prog merged 2 commits into
mainfrom
fix/CLEAN-140-api-replica-guard
Oct 22, 2026
Merged

maksymhryzodub-prog merged 2 commits into
mainfrom
fix/CLEAN-140-api-replica-guard

Conversation

@maksymhryzodub-prog

@maksymhryzodub-prog maksymhryzodub-prog commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Jira: CLEAN-140. Two commits: the guard, and a small escaping fix in the Slack renderers the push security review caught (CLEAN-165 code).

What happened

On prod-mazda the Slack threads of incidents #4–#7 hold 3 300 "Failed again / Running again" pairs per agent over four days, two minutes apart. The seconds give it away: every unreachable write sits on one 30-second grid (:29.6 / :59.6) and every running write on another (:23.1 / :53.1) — two API processes, each sweeping against its own in-memory bridle hub. The copy without the agent's socket saw "pod running, hub absent" and after the 60 s grace wrote unreachable; the other copy wrote running back. The runtime never disconnected. k8s/platform/api runs 2 replicas with an HPA 2–5.

The API is single-process by design: the hub, chat routing (sendToAgent answers "Agent is not connected" on the copy without the socket) and MCP sessions all live in memory. This PR does not make several copies work together; it makes a second copy harmless to the status and loud.

What it does

  • The API registers itself. ApiInstance row per process (id, hostname, heartbeat every 10 s; live for 30 s; pruned after an hour; removed on shutdown). ApiInstanceService in the setting slice, exported for anyone who must trust the hub.
  • The sweep holds back. detectDrift reads liveCount() first. With more than one copy alive it writes no unreachable, keeps no observation window (it would count minutes the agent spent happily on the other copy and fire the moment this copy is alone), and warns once per five minutes: N copies of the API are alive … Run ranch-api as one replica (CLEAN-140). A vanished pod is still a failure — Kubernetes' word, not the hub's. A rolling update's one-minute overlap is covered by the same rule.
  • The owner sees it. apiReplicas on the destination view: Settings → Notifications shows a red box with the count and what it means; get_notification_destination carries the number.
  • Manifests. k8s/platform/api/deployment.yaml → replicas: 1 with the reason; hpa.yaml removed. The Mazda fork has the same two files; the patch is ready for that repo.
  • Docs. docs/operations/agent-events.md: the "Before switching on" check becomes "One copy of the API".
  • Also (second commit): renderRoot and every renderReply branch put the raw agent name into the Slack text fallback — the field notifications are built from — while renderOpened/renderClosed escaped it. Now all of them do; a name like <!channel> Bot cannot page from there. Spec added.

Verified

  • api: bun run build; jest for setting + agent/agent status + agent/event (252), and the full suite; Prettier + eslint --quiet on changed files. New specs: heartbeat on boot and every 10 s, the 30 s live window, hourly prune, failed heartbeat does not stop the timer, removal on shutdown; the sweep with 2 copies alive (no unreachable across three sweeps, one warning, the window restarts from scratch once alone, a vanished pod still fails); the view's apiReplicas.
  • admin: npx nuxt typecheck, bun test slices (195), bun run locale:check. SDK regenerated (apiReplicas only).
  • Live, two API processes on one local Postgres: both registered a row, both reported apiReplicas: 2 on GET /agent-events/destination, both logged the warning exactly once over 90 s of sweeps, heartbeats refreshed every 10 s. One process killed hard (taskkill /F, a crashed pod): its row froze, and 40 s later the survivor reported apiReplicas: 1. The startup sweep still marked pod-less agents failed. Not seen live: the unreachable write itself needs a Running+Ready pod, which the local run has none of — that branch is the unit-tested one.

Twin consoles

admin changed (the Notifications form's one box). app has no agent/event sub-slice: nothing to mirror.

For DevOps

The code alone ends the incident spam: with this merged, a second replica no longer produces unreachable, incidents or Slack messages. Going to one replica (replicas: 1, no HPA, as the manifests now say) is the recommendation, because with two copies the pre-existing problems stay: a chat may land on the copy without the agent’s socket and answer "Agent is not connected", MCP sessions are per process, and unreachable detection is off while the red box shows. That trade-off is the cluster owner’s call; the console will say which state it is in.

🤖 Generated with Claude Code

@maksymhryzodub-prog
maksymhryzodub-prog force-pushed the fix/CLEAN-140-api-replica-guard branch from 306aa56 to 3c067ce Compare October 21, 2026 10:19
maksymhryzodub-prog and others added 2 commits October 21, 2026 13:20
…d thread messages (CLEAN-165)

renderOpened and renderClosed already escaped it; renderRoot and every
renderReply branch put the raw name into `text`, the field Slack uses for
notifications, so a name like "<!channel> Bot" could page from there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…py of the API is alive (CLEAN-140)

Two API replicas against one in-memory bridle hub flipped every prod-mazda
agent unreachable/running every two minutes for four days, one Slack
message per flip. Each API process now registers itself in Postgres with a
heartbeat; while more than one is live the drift sweep writes no
unreachable, keeps no window and warns once per five minutes. Settings ->
Notifications and get_notification_destination show the replica count with
a warning. The platform manifests run ranch-api as one replica, no HPA.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@maksymhryzodub-prog
maksymhryzodub-prog force-pushed the fix/CLEAN-140-api-replica-guard branch from 3c067ce to 515b415 Compare October 21, 2026 10:21
@maksymhryzodub-prog
maksymhryzodub-prog changed the base branch from feat/CLEAN-167-environment-label to main October 21, 2026 10:21
@maksymhryzodub-prog
maksymhryzodub-prog merged commit b54c7e7 into main Oct 22, 2026
2 checks passed
maksymhryzodub-prog added a commit that referenced this pull request Oct 26, 2026
…174) (#146)

Every workflow on main that installs dependencies has been red since the
2026-10-22 push (b54c7e7, #144):

  error: lockfile had changes, but lockfile is frozen

No commit caused it. bun.lock last changed in 40eaf56 (CLEAN-112) and no
manifest has touched a dependency line since. What moved is the bun version:
every workflow pulls bun through oven-sh/setup-bun with bun-version: latest,
and all three Dockerfiles build FROM oven/bun:1 - both floating, and both
rolled from 1.4.3 to 1.4.4 between the green run on 2026-10-21 and the red one
the next day. On the exact files each install sees, 1.4.3 passes
--frozen-lockfile and 1.4.4 fails, so the lock had been drifting for a while
and only the older bun tolerated it.

Three workflows were affected. ci.yaml runs on every pull request, so any PR
opened now is red before its own code is looked at. build-images.yaml fails
twice - the locale gate on the runner and build-api in Docker - and i18n,
build-app, build-admin and deploy sit behind them, so main has shipped no image
since 2026-10-21. publish-cli.yaml installs at the repo root too, so the next
version bump would have failed to publish.

The drift is hoisted build-toolchain binaries and nothing else: the top-level
@esbuild/* platform packages collapse 0.27.7 to 0.25.12 with the matching vite,
rollup, lightningcss and nitropack/rollup entries. 78 insertions, 220
deletions, no application dependency, and the repo declares no overrides,
resolutions or patchedDependencies that a regenerated lock could silently drop.

Regenerated with bun 1.4.4, the version CI and the images now run. The result
passes --frozen-lockfile under both 1.4.4 and 1.4.3, in the five-file shape the
Dockerfiles copy and in the full checkout ci.yaml installs, so nobody has to
upgrade a local bun and all three workflows go green without touching them.

The builder stages are pinned to oven/bun:1.4.4 so the next bun release cannot
repeat this inside an image. Pinning api/Dockerfile's builder also pins the bun
that installs the sibling runtime repo, the third frozen install in that image.
The api runtime stage stays on oven/bun:1-slim: it runs no install, and pinning
it would freeze its security patches. packageManager said bun@1.2.12, which had
not been true for a long time; it now matches the images.

setup-bun still says bun-version: latest in all four workflow steps, so CI
remains exposed to the next release. Pinning those lives in a follow-up commit -
pushing it needs a token with the workflow scope, which the current one lacks.

Checked under 1.4.4 and healthy, so left alone: cli/bun.lock, and the
CleanSlice/runtime lock that build-api clones into the context.

The same lock blob sat on the Mazda fork and is fixed there in
connectsolutions/mazda-ai-ranch#46 (CLEAN-173); the regenerated lock here is
byte-identical to that one, as are the three Dockerfiles and package.json, so
the next catch-up merge has nothing to resolve.

Gates - the whole ci.yaml job, run locally: frozen install green on 1.4.4 and
1.4.3 in both shapes; prisma-import + generate; api build + generate:swagger;
turbo typecheck app + admin; i18n:check 7 pairs in sync; locale:check 962 files
no findings; lint 0 errors; api jest 156 suites, 2165 pass, 0 fail; admin 206
pass; app 147 pass; scripts 94 pass. Plus both nuxt production builds, the step
that failed inside the image: admin 23.7 MB, app 23.5 MB.
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