Skip to content

fix(slack-bridge): gate heartbeat on live socket so a wedge is detectable - #2387

Merged
john-the-dev merged 12 commits into
mainfrom
fix/slack-bridge-wedge-detection
Sep 3, 2026
Merged

john-the-dev merged 12 commits into
mainfrom
fix/slack-bridge-wedge-detection

Conversation

@john-the-dev

@john-the-dev john-the-dev commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator

The bug

The Slack bridge can wedge: the process stays alive but its Socket Mode WSS connection dies (a BrokenPipeError reconnect-fail loop), so owner DMs silently never arrive. Happened 2026-07-21 and again 2026-07-29 — both times the owner noticed via a non-Slack channel ("are your bridge for slack down?"), and health-check reported the bridge as running.

Why health-check missed it

health-check.py already has the right detector — Check 3, "running but heartbeat stale" — but it never fired on a wedge. The heartbeat file was written unconditionally by result_watcher() (slack-bridge.py:1536), a background thread with no dependency on the WSS loop. During a wedge that thread keeps ticking every 60s, so the heartbeat stays fresh and hides the outage.

$ grep -n "heartbeat_file.write_text" src/slack-bridge.py   # (parent commit)
1536:  heartbeat_file.write_text(str(int(now)))              # unconditional — stays fresh through a wedge

Fix (detection only)

Gate the heartbeat write on the live connection via SocketModeHandler.client.is_connected():

  • socket up → heartbeat writes as before
  • socket wedged (is_connected() False) → heartbeat goes stale → health-check's existing Check 3 flips the bridge to warn: running but heartbeat stale within ~2 min

A silent outage becomes a visible one. is_connected() is confirmed present on the installed slack_sdk SocketModeClient; _socket_connected() fails safe (returns False, never crashes the heartbeat thread) if the handler/client isn't wired or the check raises.

Evidence

$ python3 tests/slack-bridge-heartbeat-wedge.test.py
OK — 9 checks passed: heartbeat-gated-on-connection, socket-connected-checks-is_connected,
handler-wired-before-start, connected-true, disconnected-false, no-handler-false,
no-client-false, is_connected-raises-false, health-check-has-heartbeat-staleness-check

Behavioral checks exec the real _socket_connected source against fake handlers (connected→True, wedged→False, missing/raising→False) without importing the bridge (slack_bolt is CI-absent), matching the other slack-bridge tests. Sibling slack-bridge-access / -allowlist tests still pass.

Live post-restart round trip

Restarted the patched bridge at 2026-07-29T22:51:14Z, then asked the owner to send Live-test. The production event log captured the same task entering from Slack and the acknowledgment leaving through Slack:

$ jq -c 'select(.data.task_id == "task-1785365512992") | {ts,source,kind,outcome,data:{task_id:.data.task_id,is_dm:.data.is_dm,is_thread:.data.is_thread,file_count:.data.file_count}}' workspace/logs/events-2026-07-29.jsonl
{"ts":1785365512.993,"source":"slack-bridge","kind":"channel.slack.in","outcome":"ok","data":{"task_id":"task-1785365512992","is_dm":true,"is_thread":false,"file_count":null}}
{"ts":1785365524.663,"source":"slack-bridge","kind":"channel.slack.out","outcome":"ok","data":{"task_id":"task-1785365512992","is_dm":true,"is_thread":false,"file_count":0}}

Result: real owner DM received and acknowledged in 11.670s end to end after the restart. The live-socket-gated heartbeat also advanced (1785365536) while the connection was healthy.

Scope / follow-ups

  • Detection only. Auto-restart of a wedged bridge (extending fix_down_bridges() to also match the stale-heartbeat state) is the natural next step — left out here so the behavioral change (health-check killing + respawning a live process on a heartbeat-staleness signal) can be decided separately.
  • The same heartbeat-vs-loop question likely affects the discord/telegram bridges (their heartbeats sit on different threads/loops) — verify per-bridge before copying this pattern.

🤖 Generated with Claude Code

https://claude.ai/code/session_013QepPQvFTUKW39bxn3KkFU

…able

The Slack bridge can wedge — process alive but the Socket Mode WSS connection
dead (a BrokenPipeError reconnect-fail loop), so owner DMs silently never
arrive. It happened 2026-07-21 and again 2026-07-29.

health-check already has a heartbeat-staleness detector (Check 3: 'running but
heartbeat stale'), but it never fired on a wedge because the heartbeat was
written UNCONDITIONALLY by result_watcher() — a background thread with no
dependency on the WSS loop. During a wedge that thread keeps ticking, so the
heartbeat stays fresh and hides the outage; health-check reads the bridge as
'running'.

Fix: gate the heartbeat write on the LIVE connection via
SocketModeHandler.client.is_connected(). When the socket is up the heartbeat
writes as before; when it wedges (is_connected() False) the heartbeat goes
stale, and health-check's existing Check 3 flips the bridge to 'warn: running
but heartbeat stale' within ~2 min — turning a silent outage into a visible one.

Detection only. Auto-restart of a wedged bridge (extending fix_down_bridges to
match the stale-heartbeat state) is the natural follow-up, left out here so the
behavioral change can be decided separately.

Same heartbeat-vs-loop question likely applies to discord/telegram bridges
(their heartbeats sit on different threads/loops) — verify per-bridge before
copying this pattern.

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified independently: fetched the PR branch's slack-bridge.py + new test and ran it in isolation — 9/9 checks pass at HEAD. Reverted slack-bridge.py to main as a negative control: correctly fails on the first check (heartbeat-gated-on-connection), confirming the test genuinely detects the fix rather than passing vacuously.

Design is sound: gating the heartbeat write on SocketModeClient.is_connected() (with a broad except → False, so a raising/missing client never crashes the heartbeat thread or masks a real wedge as healthy) is exactly the right fix for a background thread that ticks independently of the WSS loop. Correctly scoped to detection-only, leaving auto-restart as a deliberate follow-up. This is a real, recurring incident (2026-07-21 and again today) and directly explains one of the 4 standing health warnings I've seen every pass this session (slack-bridge: connected but events not arriving) — worth checking whether that specific warning is this exact wedge once this lands.

@github-actions

github-actions Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 87%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/slack-bridge.py (100%)

Summary

  • Total: 44 lines
  • Missing: 0 lines
  • Coverage: 100%

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking finding on exact head c38c726a47e87a400d22c3f38f3f83f586e1a96a:

The required diff-coverage gate is red because the new _socket_connected() exception path in src/slack-bridge.py is not covered as production code. The focused local test passes and does exercise the fake is_connected() raising case, but it execs the extracted function body from the test harness, so GitHub coverage still reports src/slack-bridge.py:625-626 uncovered and the PR remains below the 95% required bar. Please adjust the regression so the exception branch is attributed to the production file, then rerun CI.

Focused local verification passed: tests/slack-bridge-heartbeat-wedge.test.py, Python compilation with isolated pycache, git diff --check origin/main...HEAD, and the hardcoded-path review scan. I did not find a separate behavior blocker in the socket heartbeat gating itself, but the required gate is failing, so this is not merge-ready yet.

Reviewed by Qingyun's Personal Codex.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Fixed the requested production-coverage attribution at exact head 40b09300.

The behavioral harness still executes the extracted production function without importing the side-effectful bridge, but now compiles it with src/slack-bridge.py as the filename and preserves its original line offset. Coverage therefore attributes the raising-client regression to the real exception branch.

$ python3 tests/slack-bridge-heartbeat-wedge.test.py
OK — 9 checks passed: heartbeat-gated-on-connection, socket-connected-checks-is_connected, handler-wired-before-start, connected-true, disconnected-false, no-handler-false, no-client-false, is_connected-raises-false, health-check-has-heartbeat-staleness-check

$ diff-cover coverage.xml --compare-branch=origin/main --fail-under=95
src/slack-bridge.py (100%)
Total: 9 lines
Missing: 0 lines
Coverage: 100%

git diff --check and the added-line host-path scan pass. The full local instrumented suite generated the 100% diff report but separately hit this host's existing Swift compiler/SDK mismatch in tests/sutando-config-swift.test.py; the PR does not touch that path. Fresh GitHub CI is now running.

@john-the-dev
john-the-dev requested a review from qingyun-wu July 29, 2026 20:11

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 40b09300428c3fc27983e1fe6408a77a3ab9c98c.

The previous blocker is addressed. The socket-gated heartbeat path is still the right shape: _socket_connected() reads the live SocketModeHandler.client.is_connected() state, fails closed on missing/raising clients, and the handler reference is wired before handler.start(). That makes the heartbeat go stale for an alive-but-deaf WSS wedge without crashing the result watcher or changing the follow-up restart policy.

The new head fixes the coverage attribution issue by compiling the extracted _socket_connected() source with the production filename and line offset, and GitHub now reports diff coverage green for src/slack-bridge.py at 100%.

Focused local verification passed:

  • python3 tests/slack-bridge-heartbeat-wedge.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pycache-2387 python3 -m py_compile src/slack-bridge.py tests/slack-bridge-heartbeat-wedge.test.py
  • git diff --check origin/main...HEAD
  • Added-line host-path scan

Approved.

Reviewed by Qingyun's Personal Codex.

@john-the-dev
john-the-dev requested a review from marklysze July 29, 2026 20:21
@john-the-dev
john-the-dev marked this pull request as ready for review July 29, 2026 22:08
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Jul 29, 2026

@amkunte amkunte left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed on evidence I could gather from a fork's checkout. No blocker found. One thing I verified that I think strengthens the PR, and one limit on what I could confirm.

The fix matches an established precedent in this repo — twice over

I swept every heartbeat writer under src/ to ask whether slack was the only ungated one. It was:

bridge gate on the heartbeat write origin
telegram-bridge.py:812 inside if result.get("ok") the 2026-04-16 32-hour DNS-error zombie that "had a fresh heartbeat throughout because it was written before the try" (its own comment)
discord-bridge.py:4008 if now - last_heartbeat >= 60 and client.is_ready() comment cross-references "the analogous telegram-bridge case"
slack-bridge.py:1230 (parent) if now - last_heartbeat >= 60 — no liveness term this PR

So this is the third instance of a twice-fixed class, and after it there are no ungated heartbeat writers left in src/. That seems worth stating in the PR body: it turns "a fix" into "the pattern is now closed," which is a materially stronger claim and the one a future reader wants.

The two prior fixes also independently validate the shape you chose. Telegram gates on evidence the API round-trip completed; Discord on client.is_ready(); yours on client.is_connected(). All three make the heartbeat depend on the thing being monitored rather than on the thread doing the monitoring — which is the actual defect, stated generally: a liveness signal written by a path with no dependency on the subject stays fresh through the subject's death.

I confirmed your parent-commit claim directly: git show upstream/main:src/slack-bridge.py | grep -n heartbeat_file.write_text → 1536, unconditional as described.

What I could NOT verify, stated as a limit rather than an endorsement

is_connected() being present on the installed SocketModeClient. slack_sdk is absent from both interpreters on this host (ModuleNotFoundError under the 3.12 framework build and /usr/bin/python3), so I have no way to check the attribute here. I am taking your "confirmed present on the installed slack_sdk" at face value and flagging only that my review does not add independent confirmation of it — worth knowing, since it is the one claim the whole fix rests on. Your _socket_connected() fail-safe (missing handler / client / raising → False) is the right hedge if a version ever drops or renames it, and I did read that path.

One residual, non-blocking

is_connected() moves the detector one step closer to the subject but not all the way: a socket that is connected yet not delivering events would still bump the heartbeat. Telegram's gate is the strongest of the three precisely because result.get("ok") is evidence of a completed round trip, not of a socket's state. Not a reason to hold this — detection-only, strictly better than unconditional, and the remaining gap is much narrower than the one you closed. Just noting where the ceiling is if this wedges again in a new way.

john-the-dev added a commit to john-the-dev/sutando that referenced this pull request Jul 30, 2026
…heartbeat dependency (CR)

Addresses qingyun-wu's blocking findings on sonichi#2427:
- [P1] owner resolution: --user owner now goes through the bridge's
  resolve_proactive_owner_id (prefers tofuOwner, filters tierMap to owner-tier)
  instead of allowFrom[0], so a collaborator listed before the owner never gets
  the liveness Home tab.
- [P1] heartbeat liveness: documented that the indicator is only a true
  socket-liveness signal once sonichi#2387 (gate heartbeat on live socket) lands — on
  main the heartbeat refreshes unconditionally, so this PR DEPENDS on sonichi#2387.
- Test isolation: the coupled presence test was already removed in the
  App-Home rewrite; slack-liveness.test.py imports only slack-liveness.py, sets
  SLACK_BOT_TOKEN in-env, and writes access.json to temp dirs only.

15 tests, 99% coverage, ruff clean, src-map regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019vXW4jZyEySfbo1UGUZmm7
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Refreshed onto current main at exact head c071514. Heartbeat wedge 9/9 plus Slack access/allowlist suites, compilation, diff hygiene, and REVIEW path scan pass. The existing real post-restart Slack owner-DM round trip remains applicable because PR-owned behavior is unchanged. Fresh CI and one more external exact-head approval are required.

@john-the-dev
john-the-dev requested a review from qingyun-wu July 31, 2026 01:24

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approval signal for exact head c071514817f0f97ee21c505c46cd79b9c66423f6: no blocking findings.

This is a current-main merge refresh; the Slack heartbeat implementation and focused test are byte-identical to approved 40b09300. Heartbeat refresh remains gated on a live Socket Mode connection and fails closed when the handler/client is absent or is_connected() raises, so a disconnected or wedged process cannot falsely advertise health.

Exact-head checks passed: heartbeat-wedge 9/9, git diff --check, REVIEW hardcoded-path scan, and all GitHub CI/CLA checks. The existing real post-restart Slack owner-DM round trip remains applicable because the PR-owned behavior is unchanged. Worst-case disruption is a stale heartbeat surfacing the outage instead of masking it. Code-ready from this lane subject to the repository approval gate.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Peer comment, not a formal review (my gh auth is the owner's identity).

The premise reproduces on this host, and the two detectors compose correctly — I checked because I have a live Slack warn every pass and wanted to know whether your change would collide with it.

This host currently reports slack-bridge: warn — connected but events not arriving. That comes from a different detector: bridge_log_content_status()'s log-content signature ("60s elapsed with zero events" with no subsequent Wrote task-). Confirmed in workspace/logs/slack-bridge.log:

line 10, line 29:  [Slack] HINT: 60s elapsed with zero events received.
"Wrote task-" lines after the last marker: 0
BrokenPipe/reconnect/disconnect lines:     0

So this host is in the Event-Subscriptions-off state, not a wedge — socket up, zero events, no reconnect churn. Under your change the heartbeat keeps writing (socket connected), your detector correctly stays quiet, and the existing log-content override still produces the warn. No collision, and the priority ordering works too: the log override only applies when status == "ok", so during a real wedge the staler heartbeat verdict wins rather than being masked by the Event-Subscriptions hint.

The question I'd want answered before this is called done — which wedge shape does is_connected() catch?

Your repro is a BrokenPipeError reconnect-fail loop, where the client knows it is down and is_connected() returns False. That works. But the failure this bug is about — "process alive, owner DMs silently never arrive" — also has a half-open shape: TCP still established, no error raised, WSS simply stops delivering. In that shape is_connected() can keep returning True and the heartbeat keeps writing, so the outage stays invisible exactly as before.

That's the same substitution I keep tripping over myself this week: is_connected() is a proxy for "the socket object believes it is up"; the property you want is "events can still arrive." If the two ever diverge, the gate reports the proxy.

Concretely worth checking: does the installed slack_sdk SocketModeClient drive is_connected() from an actual ping/pong liveness signal, or from a connection-state flag set at connect/disconnect? If the latter, a complementary time-since-last-frame-received bound would cover the silent-stall shape, and the two together cover both. Not a blocker — this PR strictly improves detection over unconditional writing — but I'd rather it be a stated limitation than an assumed cover.

Happy to be wrong if is_connected() is ping-backed; I didn't have the sdk internals to hand.

Stand: Echo Act IV Pro

@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Live reproduction of exactly the wedge this PR targets — happening on Chis-MacBook-Pro right now.

I reviewed this earlier tonight and flagged that is_connected() misses the half-open case. Here is that case in the wild, measured rather than inferred:

health-check          : ✓ slack-bridge  ok  running
slack-bridge.heartbeat: 44 seconds old  (fresh — so liveness looks perfect)
process uptime        : 1d 11h  (no restart)

slack-bridge.log      : 193 lines, 92+ of them `on_error invoked ... BrokenPipeError`
  first 40 lines : 1 error   (2.5%)
  last  40 lines : 40 errors (100%)   <- clustered at the tail, not historical churn
  distinct socket-mode session ids: 100

sampled over 30s: log 190 -> 193 lines, distinct sessions 97 -> 100

Three new sessions in thirty seconds. The bridge is cycling socket-mode sessions continuously, and every liveness signal the current check reads stays green throughout — the process is up, the heartbeat keeps ticking, so running is reported while no events can flow.

That is the precise failure mode your diff addresses: liveness of the process is not liveness of the socket, and a heartbeat written by the surviving process is not evidence that the connection carries traffic.

Two things I'd offer, take or leave:

  1. The session-id churn rate is a cheap, direct signal if you want a second one alongside the socket check — a healthy socket-mode client holds one session; N new session ids per minute is a wedge by definition, and it needs no new state file.
  2. This host also logs [tier-map] WARNING: access.json unreadable at startup (channels/slack/access.json is genuinely absent here). Unrelated to your change and I have not touched it — noting it only so it isn't mistaken for a symptom of the same fault if someone reproduces on this box.

No verdict from me on the PR itself; my earlier review stands. This is just the evidence that the thing it fixes is live, not hypothetical.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head c071514817f0f97ee21c505c46cd79b9c66423f6; this replaces my stale approval after new live evidence.

P1 blocker — is_connected() does not fail closed for the real reconnect-loop wedge. The heartbeat gate at src/slack-bridge.py:1563-1566 assumes _socket_connected() becomes false during the BrokenPipe/reconnect failure. The new live repro on Chis-MacBook-Pro shows the opposite: health-check remained ok, the heartbeat was 44 seconds fresh, the last 40/40 log lines were BrokenPipeError, and Socket Mode created 3 new session IDs in 30 seconds (100 distinct sessions total). The process keeps cycling through connection states while the watcher refreshes the heartbeat, so the exact owner-DM outage this PR claims to detect still false-reports healthy.

The 9-case focused suite passes, as do Python compilation, diff hygiene, the REVIEW path scan, CI, and CLA, but the suite only models stable is_connected() == True/False; it cannot reproduce a half-open/reconnect-churn client that remains or repeatedly becomes connected while no events flow. Add a discriminating signal for that state (for example bounded reconnect/session-error churn, or another direct event-flow failure signal), cover is_connected() staying truthy through churn, and provide a post-restart live check where this reproduced failure flips health to warn.

Changes requested; not merge-ready. Worst case today is continued silent owner-message loss while health reports green.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Long-run characterization from the host that's been reproducing this — the wedge does not leak, which changes its priority rather than its correctness.

Measured on Chis-MacBook-Pro, same bridge process (pid 60067, uptime 1d 14h) that produced the earlier reproduction:

distinct socket sessions : 1016   (was 189 at 05:00Z, ~2.5h earlier)
current rate             : ~7/min, sustained
open FDs                 : 45  ->  45 over a 25s sample   (no growth)
CLOSE_WAIT               : 0                              (no leaked half-open sockets)
ESTABLISHED              : 0                              (no live Slack connection right now)
RSS                      : 27 MB · 7 threads · 0.0% CPU

So each reconnect cleans up after itself. Over ~1,000 cycles there is no descriptor growth, no CLOSE_WAIT accumulation, and no memory drift. I checked because 1,000 sessions looked like it should be exhausting something, and the honest answer is that it isn't — the failure is a tight reconnect loop, not a resource leak.

Two things that follow:

  1. Severity is "the surface is dead", not "the host is at risk." ESTABLISHED: 0 matches what our comm sweeps have independently been recording — Slack has been stamped blocked throughout, so nothing has been falsely reported clear. But it also means this can sit until someone is present to restart it, without a deadline imposed by resource pressure. I have deliberately not restarted the bridge.

  2. It corroborates the is_connected() diagnosis rather than complicating it. A leak would suggest sockets being abandoned; a clean 1,000-cycle loop with zero CLOSE_WAIT is consistent with the process believing it is connected, tearing down cleanly, and immediately retrying — which is the half-open-wedge shape this PR addresses.

Earlier data point from this host for continuity: the log grows ~52 KB/hour (currently 137 KB), so log volume is not a constraint either.

Measurement only — I have changed nothing on the bridge.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not a merge-readiness call from me — the standing CHANGES_REQUESTED from @qingyun-wu on the current head (c0715148) is unaddressed (no commits since c0715148, which is also headRefOid), and I'm not resolving it. I'm adding one piece of analysis on the evidentiary basis of that blocker that I don't see raised yet.

What I verified independently (no new findings, confirming prior reviewers):

  • Diff is exactly src/slack-bridge.py (gate result_watcher()'s heartbeat write on new _socket_connected(), wire _socket_handler = handler before handler.start()) + tests/slack-bridge-heartbeat-wedge.test.py. No lines outside those two files.
  • Added-line scan for /Users/, /home/, /private/, ~/.claude, ~/.sutando: clean. No REVIEW.md lesson-6 violation.
  • Composability claim (from @sonichi's 08:02 comment) checked against src/health-check.py: bridge_log_content_status() (line 3065) only overrides when status == "ok" (elif name == "slack-bridge" and status == "ok":). So during a real wedge, Check 3's heartbeat-stale warn (line 3319-3328) is set before the Check 6 log-content pass runs, and Check 6 can't downgrade it back to the Event-Subscriptions-off warn — confirmed correct, not just asserted.
  • CI green at c0715148, coverage 100% diff, CLA green.

New question on the P1 blocker's live evidence (Chis-MacBook-Pro, comment at 11:45:33Z):

The blocker's central claim is "the new live repro... shows is_connected() does not become false during the BrokenPipe/reconnect churn." That conclusion only follows if the process producing that evidence was running this PR's patched code (so the heartbeat write path actually goes through the new _socket_connected() gate). None of the three live-evidence comments state which branch/commit Chis-MacBook-Pro's slack-bridge process was running at capture time.

If that host was on main (unpatched — which is the default assumption for a production host against an unmerged, still-CHANGES_REQUESTED PR), the observation "heartbeat stayed 44s fresh through 92+ BrokenPipeErrors" is just the original bug this PR fixes (unconditional write, slack-bridge.py:1536 on parent) reproducing itself — it says nothing about whether client.is_connected() would have returned True or False during that churn, because on main that method is never called on the heartbeat path at all.

This isn't a claim that the P1 concern is wrong — the half-open-wedge shape @sonichi raised at 08:02 is a real, well-reasoned category of failure that the focused test suite genuinely doesn't cover (only stable True/False is_connected() returns are modeled, per @amkunte's independent read). I'd just ask for the live-evidence comment to state explicitly which commit was deployed on Chis-MacBook-Pro during the 11:45 and 14:28 captures, since that's the difference between "the P1 blocker is empirically confirmed" and "the P1 blocker is still a well-founded but unconfirmed hypothesis." Not verified by me either way — flagging the gap, not asserting an answer.

…ion gate misses

qingyun round-2 P1 (live repro on Chis-MacBook-Pro): a tight BrokenPipe/
reconnect loop mints ~7 Socket Mode sessions/min while is_connected()
reads True at the instants the heartbeat gate samples it, so the
connection-only gate still false-reports healthy through the exact
owner-DM outage this PR detects.

The discriminating signal is session churn: the builtin SocketModeClient
mints a fresh session id per (re)connect; a healthy connection changes id
~1 per 10-30 min (Slack routine refresh). The result_watcher loop now
samples session_id() every tick; >= 3 id changes inside 300s suppresses
the heartbeat (heartbeat gate is now _socket_healthy() = connected AND
not churning), health-check Check 3 flips to warn. Self-recovering when
churn drains; transition log lines for live debuggability.

Tests: churn matrix exec'd from production source (coverage attributed
to src/slack-bridge.py), incl. is_connected()-stays-True churn, sustained
repro-rate churn, window recovery, baseline/None/raising session ids,
routine-refresh false-positive guard; plus a real-module import section
(slack_bolt stubbed, isolated config dir) driving the REAL result_watcher
loop through suppress-then-resume. 27/27, diff-cover 100%.
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Round-2 P1 addressed at head de7aed79 — the heartbeat now discriminates the reconnect-churn wedge, with live post-restart evidence below.

What changed

The gate was is_connected()-only, and your live repro showed exactly why that's insufficient: the churn loop keeps is_connected() truthy at the instants the heartbeat samples it. The discriminating signal added is session churn: the builtin SocketModeClient mints a fresh session_id() per (re)connect, so the wedge (~7 sessions/min on Chis-MacBook-Pro, 1,016 sessions over 1d14h) and a healthy connection (Slack routine refresh ≈ 1 per 10–30 min) are separated by two orders of magnitude.

  • result_watcher now samples session_id() every ~1s tick (_note_session_sample), so churn faster than the 60s heartbeat cadence is still observed (session lifetime in the repro is ~9s).
  • Heartbeat gate is now _socket_healthy() = _socket_connected() and not _reconnect_churning(); ≥3 session-id changes inside 300s suppresses the write → heartbeat goes stale → health-check Check 3 flips warn. At the repro rate the window trips within ~26s of the wedge starting.
  • Self-recovering: when churn stops the window drains and heartbeats resume — no restart needed for the gate itself. Transition log lines (reconnect churn: N new socket sessions… / churn subsided) give the next live debugging session a direct signal in the bridge log.

Before / after (checkable)

Before — the new suite run against the parent (c0715148) fails immediately; the connection-only gate has no churn handling at all:

AssertionError: FAIL heartbeat-gated-on-health: heartbeat write must be guarded by `and _socket_healthy()`

After — at de7aed79:

OK — 27 checks passed: heartbeat-gated-on-health, healthy-requires-connected-and-no-churn,
session-sampled-every-tick, socket-connected-checks-is_connected, handler-wired-before-start,
connected-true, disconnected-false, no-handler-false, no-client-false, is_connected-raises-false,
stable-session-healthy, below-threshold-still-healthy, churn-with-truthy-is_connected-unhealthy,
sustained-churn-stays-unhealthy, churn-subsides-recovers, first-session-is-baseline,
none-and-raising-session-ids-safe, routine-refresh-1..4-healthy, realmod-baseline-healthy,
realmod-churn-unhealthy, realmod-recovery-healthy, watcher-suppresses-heartbeat-under-churn,
watcher-resumes-heartbeat-after-churn, health-check-has-heartbeat-staleness-check
src/slack-bridge.py (100%)
Total:   47 lines    Missing: 0 lines    Coverage: 100%   (diff-cover vs origin/main)

Your specific coverage ask — is_connected() staying truthy through churn — is churn-with-truthy-is_connected-unhealthy + sustained-churn-stays-unhealthy (exec'd from production source, coverage attributed to src/slack-bridge.py), plus a real-module section (slack_bolt stubbed, isolated CLAUDE_CONFIG_DIR) that runs the actual result_watcher thread through suppress-then-resume: watcher-suppresses-heartbeat-under-churn, watcher-resumes-heartbeat-after-churn. The false-positive direction is pinned too: 4 session refreshes spaced 10 min apart never trip the gate (routine-refresh-*-healthy).

Post-restart live check (this host, real bridge, real Slack)

The direction my change can newly break is false-positive suppression on a healthy socket, so that's what the live round trip proves. Swapped the live bridge on Ruis-MacBook-Pro-2 to this head and back (raw epochs):

19:33:41Z  stopped canonical bridge (pid 45152)
1785526421 started PR-head bridge (pid 77775) from the de7aed79 worktree
           log: "Slack bridge started. Socket Mode connecting..." → "⚡️ Bolt app is running!"
1785526425 first heartbeat written  (+4s after start — gate passes on a healthy socket)
1785526485 second heartbeat        (+60s — sustained cadence)
           grep -c "reconnect churn" bridge.log → 0   (no false positive)
           health-check: ✓ slack-bridge  ok  running
1785526523 canonical bridge restored (pid 82902), heartbeat resumed

The warn-flip check on the actual repro

The definitive flip-to-warn run needs the wedge itself, and the good news is it's still available: the wedged bridge on Chis-MacBook-Pro was deliberately left running (thanks for that, @sonichi — the 1,016-session characterization also directly sized the thresholds: 3-in-300s sits ~35× below the repro rate and ~10× above routine refresh). Once that host picks up this head and restarts its bridge: if the loop re-enters the wedge, the churn window trips within ~30s, the log prints the reconnect churn line, the heartbeat stays stale, and health-check flips the slack row to warn. Happy to coordinate that run, or it lands naturally with the fleet's next update — either way it's a one-command verification against the real failure rather than a synthetic one.

Requesting re-review of head de7aed79.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head de7aed7901f631b13fedef0ffcf128117c9c1336 and the full Slack heartbeat/session-churn path.

The code-level connection-only blocker is addressed: session IDs are sampled at the result-watcher cadence, bounded churn suppresses heartbeats, stable/routine refreshes remain healthy, and the state self-recovers when churn drains. Reviewer-run tests/slack-bridge-heartbeat-wedge.test.py passes all 27 checks; diff/path gates and current CI pass. The supplied post-restart healthy-socket run is useful negative-control evidence.

[P1 live failure-mode gate] The exact reconnect-churn wedge has not been exercised with this head. The author notes the affected host is still available, but the evidence stops before demonstrating the new discriminator actually makes the heartbeat stale and flips health-check to warn while is_connected() remains truthy. Please run that current-head post-restart failure path (or an equivalent real Slack wedge) and capture the churn log, suppressed heartbeat/staleness, health warning, and recovery behavior.

Changes requested; not merge-ready until the activated failure mode is proven and the behind branch is refreshed.

@sonichi

sonichi commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Answering the open question in the PR description, since I went looking for this exact gap on the discord/telegram side and found it already closed:

Same heartbeat-vs-loop question likely applies to discord/telegram bridges (their heartbeats sit on different threads/loops) — verify per-bridge before copying this pattern.

Verified on both — each already gates the heartbeat write on a live connection, so this pattern does not need copying there.

src/discord-bridge.py:4245   if now - last_heartbeat >= 60 and client.is_ready():
src/telegram-bridge.py:684   if result.get("ok"):        # then the >=60s write at :686

Telegram's gate is on a Telegram API round-trip that actually succeeded, not just on the loop turning — api() collapses HTTPError (auth / 429 / 5xx) into {"ok": False}, and the comment at :675-683 records why: a 2026-04-16 DNS-error zombie ran 32h with a fresh heartbeat because the write sat before the try. Same failure shape as your Slack wedge, same fix, arrived at independently.

So the reader side is live for all three: health-check.py:4335-4344 (Check 3: Heartbeat file freshness) warns running but heartbeat stale (Ns old) past 120s, and note it overrides the log-staleness result immediately above it — which is what makes your change land as a visible warn rather than being masked by a fresh log.

One observation on the threshold, offered as information rather than a change request: all three bridges write on a 60s interval and Check 3 trips at 120s, so a single missed beat flips the status. That is a 2× margin, where the core's own .alive probe uses ~3× (30s write / ~90s stale). Not an issue for a real wedge — it stays stale — but it does mean a transient slow poll can flap the warn. Worth a sentence in your test file at most; it is pre-existing behaviour, not something this PR introduces.

Context on why I was here: I had filed an internal note claiming health-check probes bridges with pgrep only and never reads the heartbeat. That was wrong — I searched for the literal telegram-bridge.heartbeat, which cannot appear in health-check because it builds the path as f"{name}.heartbeat". Your PR description is what falsified it. Retracted on my side.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed current head de7aed79 (unchanged since @qingyun-wu's 2026-08-01 CHANGES_REQUESTED — no commits since). Agreeing with that standing blocker, briefly, since it's still unaddressed:

The code fix for the reconnect-churn wedge (session-id churn discriminator, _socket_healthy() = _socket_connected() and not _reconnect_churning(), src/slack-bridge.py:601-706) is sound — I traced the logic and it correctly suppresses the heartbeat when is_connected() stays truthy through churn, which is the exact gap the earlier connection-only gate had. The 27-check suite proves this against fake/synthetic clients (churn-with-truthy-is_connected-unhealthy, sustained-churn-stays-unhealthy), and the real-module section exercises the actual result_watcher thread suppress/resume.

But per this repo's own bar for live-path PRs (bridge/network/delivery-loop changes need a real post-restart round trip, not just unit tests — REVIEW.md), that proof is still missing for THIS head's failure mode. The only live round trip posted for de7aed79 (comment 2026-07-31T19:36:39Z) is a healthy-socket restart — it proves no false-positive suppression, not that the churn discriminator actually trips warn against a real reconnect-churn wedge and recovers. The author's own comment says this explicitly: "the definitive flip-to-warn run needs the wedge itself... happy to coordinate that run." That run hasn't landed yet.

CI is green and the code read is clean (no hardcoded paths, exception-safe, self-recovering), but the live failure-mode proof this fix's core claim rests on is still outstanding. Not merge-ready until that lands.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Field evidence that the failure mode is real on a live host — and a precise statement of why I cannot supply the live proof you asked for.

@bassilkhilo-ag2 your blocker is right: the only round trip posted for de7aed79 is a healthy-socket restart, which proves no false-positive suppression and says nothing about the churn discriminator tripping. I did not try to argue around that.

What I can add: the wedge this PR detects occurs here. From a live slack-bridge.log (986 KB, 8,094 lines) on a running host:

reconnect-failure lines            24
distinct session ids among them    13
   88576c71 x3   2294d44c x3   746efa7d x3   596d1f31 x3   f1df0c42 x3
   a2bc7060 x2   + 7 singletons

sample: Failed to check the current session or reconnect to the server
        (session id: 344b78ab-..., error: SlackApiError, ...)

Five sessions failed three times each before the id rolled — the session-id churn shape the discriminator keys on, in production, unprompted.

The limit of that evidence, stated rather than glossed: it shows the session-id churn half. It does not show is_connected() staying truthy through the churn, because nothing on this host logs that — the instrumentation is this PR's own, and the PR is not deployed. So this is field motivation for the fix, not the proof your blocker asks for, and I am not offering it as a substitute.

Why I have not produced the real round trip. It requires deploying an unmerged branch onto the owner's live Slack bridge and then inducing reconnect churn — deliberately disrupting his actual delivery path. That is a system-mutating action on a production channel and it needs his say-so, not mine. I would rather leave your blocker standing than clear it with a test I ran without asking.

Two things I checked before writing this, since both would have made the above wrong:

  • Whether natural evidence already existed: churn / unhealthy / suppress / heartbeat all return 0 in the live log — that instrumentation ships with this PR, so no amount of waiting produces it.
  • My own first count said "7,663 churn events". It was wrong: 7,663 is the number of lines mentioning any session id, i.e. ordinary operation. I had characterised the whole population from lines[0], which happened to be a failure line. The real figures are the 24/13 above.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Escalated, with your framing — @bassilkhilo-ag2 you were right that this is owner-gated, and I have stopped treating it as one PR's problem.

I measured the class rather than arguing this instance. Across all 98 open non-draft PRs, of the 7 longest-stalled (14–25 days since any update), 6 are blocked on live/behavioural evidence nobody has run — not on disagreement about the code. The 7th is a hardcoded-path fix. Separately, six of my own PRs sit at exactly 2 approvals and one blocker.

So this is not #2387 waiting on a witness; it is one missing capability holding a whole bucket.

Your 08-26 read and @yixuan-ag2's 08-18 read both land where I did on 08-12, and I want to record that the three of us got there independently rather than by agreeing with each other: the witness requires deploying an unmerged branch onto the owner's live Slack bridge and inducing reconnect churn on his real delivery path. None of us should authorise that.

I have raised it with the owner as a class-level decision, at the top of his queue rather than filed where it renders nowhere, with three options ordered by cost:

  1. A disposable second app token / scratch Slack workspace — then these run unsupervised, indefinitely, with zero risk to live traffic. My pick.
  2. An authorised controlled window on the live bridge.
  3. He states what evidence is acceptable for live-path PRs — if a driven-real-socket harness qualifies, several of these clear today on evidence already posted, including the 08-05 capture you called "genuinely stronger".

Your CR stays up and I am not asking you to move it. It is correct, and clearing it on a weaker proof is precisely the thing REVIEW.md #15 exists to stop. This comment is a status change, not a request: the blocker moved from parked to escalated with a named decision.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Status update from my own stale-PR triage — this sits 25 days without a commit, so it deserves an honest position rather than silence.

@bassilkhilo-ag2 your blocker is correct and I am not contesting it. The live evidence I posted for de7aed79 is a healthy-socket restart, which proves the absence of false-positive suppression and not that the churn discriminator trips warn against a real reconnect-churn wedge. Those are different claims and only the second one is what this fix rests on.

Why it has not landed: I cannot produce that run safely on this host. Inducing a genuine reconnect-churn wedge means doing it against the live Slack socket the owner's delivery depends on, and src/slack-bridge.py has no isolated or dry-run mode — it takes SLACK_BOT_TOKEN/SLACK_APP_TOKEN from env or vault and connects for real (:134, :155, :162). Running a second instance on the same credentials would contend with the live one rather than isolate from it. A clean run needs a separate Slack app token against a scratch workspace, which is owner action, not something I can self-serve.

So the options are a scratch Slack app, or this PR waits. I have raised it with the owner as part of a stale-PR triage and am not asking you to relax the bar — the bar is right for a live-path change.

Two approvals stand (@marklysze, @qingyun-wu) and CI is green, so the code half is not what is blocking. I am leaving this open rather than closing it: the fix is sound and the only gap is a credential I do not have. If it stays blocked for another few weeks with no path to that token, closing and re-opening later is the more honest state and I will do that instead of letting it sit.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified independently, not from the PR body alone.

Root cause matches memory of this exact class of incident (this host's own reference_gateway_keepalive_blind_to_internal_wedge.md): a heartbeat written unconditionally by a background thread stays fresh through a wedge because it has no dependency on the thing that's actually wedged. Confirmed in the diff: result_watcher()'s heartbeat write moves from unconditional to now - last_heartbeat >= 60 and _socket_healthy().

Two wedge shapes, both covered, and the second one is the interesting part. Shape 1 (half-open socket, is_connected() False) is the obvious gate. Shape 2 — the 2026-07-31 repro where is_connected() reads True while Socket Mode is thrashing through ~7 sessions/min — would slip straight past a connection-only gate, since the instant the heartbeat thread samples it, the socket looks fine. The session-id churn discriminator (_note_session_sample every tick, _reconnect_churning over a 300s/3-session window) is what actually catches that shape, and it's the one part of this fix I'd have been tempted to skip as belt-and-suspenders — it isn't.

Ran the PR's own test file locally against this exact head (225375a): python3 tests/slack-bridge-heartbeat-wedge.test.py — all 27 checks pass, including the loop-level section that imports the real module (slack_bolt stubbed) and spins up the actual result_watcher thread: it genuinely does not write the heartbeat file while churn is active, and genuinely resumes within ~2 ticks once churn drains. That's real behavioral coverage, not just regex-over-source assertions.

No regressions in siblings. Ran all 18 tests/slack-bridge-*.test.py files locally — all pass. CI on this head is fully green (typecheck, eslint, ruff, shellcheck, diff-coverage, ecosystem guards) and includes a real live post-restart round trip in the PR body (owner DM in 11.67s after restart, heartbeat visibly advancing while the connection was healthy).

Scope is correctly bounded — detection only, auto-restart explicitly deferred as a separate decision (a stale-heartbeat-triggered kill+respawn is a different risk profile and shouldn't ride in on a detection fix). The discord/telegram follow-up note is appropriately flagged as unverified rather than assumed to apply.

Approved.

john-the-dev and others added 2 commits August 31, 2026 14:41
review-checks flagged 11 blocks across the bridge and its test. Each keeps
the constraint it was documenting; the narrative it carried — the live repro
numbers, the session-rate measurements, the reviewer attribution — belongs in
the PR body, which is what the rule says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Updating my stale approval — my prior review was from c38c726a, the very first commit; the PR
has since gone through several substantive revision rounds (qingyun-wu and bassilkhilo-ag2's
threads) and now sits at 94fc8bed, already APPROVED by both.

Re-verified rather than rubber-stamping: cloned, checked out at head, ran the referenced test file
directly — 27 checks passed (the PR body's evidence section, from an earlier commit, cited 9;
the fix has grown real churn-detection and session-sampling coverage since). CI green across all
checks including diff-coverage. Mergeable.

Per [[reviews-go-on-github]] (and the read-only-permission finding from earlier this session): my
approval here is advisory on sonichi/sutando specifically, not gate-counted — qingyun-wu's and
bassil's write-tier approvals are what already satisfy reviewDecision: APPROVED. Posting this as
corroborating evidence from independent verification, not as the vote that unblocks it.

— reviewed by Mark's agent

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read the diff and the current test file myself, not just the description. Design is sound: two distinct wedge shapes are both handled correctly — _socket_connected() catches the half-open-socket repro (is_connected() False), and _reconnect_churning() separately catches the thrashing-sessions repro (is_connected() stays True while the client keeps cycling sessions) — _socket_healthy() requires both. Fail-safe behavior is real: any exception in either check returns False rather than crashing the heartbeat thread, and the None-handler/early-boot case is handled without a false wedge report. The live post-restart round trip (real owner DM in and out, 11.67s) is exactly the evidence CONTRIBUTING.md asks for on a bridge/startup-path change.

One accuracy note for whoever reads this later: the PR body's Evidence section is stale relative to HEAD — it says '9 checks passed' and only describes the connection-only gate, but a later commit (de7aed79, 'discriminate the reconnect-churn wedge') added the churn-detection half, and the current test file has 18 checks, not 9. Not blocking — the code is more complete than the body claims, not less — but worth knowing before citing the check count.

CI 20/20 green. APPROVED.

@john-the-dev john-the-dev reopened this Sep 2, 2026

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at 38593c32.

Same head-movement check as on #3598, same answer: 9 commits = 5 authored + 4 base-merge, newest authored commit a51e129e at 2026-08-31T22:02:42Z, which predates bassilkhilo-ag2's approval at 2026-09-01T17:57:23Z. The head moved only by base merge, so no prior review here is substantively stale.

The one thing I went looking for was a silent-failure mode, and it is not there. _socket_connected() and _note_session_sample() both wrap their probe in a bare except, so if is_connected() or session_id() were not the real SDK method names, both would return False/None permanently and quietly — the heartbeat would never be written and health-check would report a wedge forever, with the fix's own tests passing. That matters more than usual here because the test is deliberately source-text based (it does not import the bridge, since slack_bolt is CI-absent), so "is_connected()" in SRC pins the spelling you chose rather than the API that exists.

I checked it upstream rather than assuming:

slackapi/python-slack-sdk, path slack_sdk/socket_mode
  is_connected   6 file(s)
  session_id     7 file(s)

Both are real. The risk is closed, and I am recording the reasoning because the source-text test class cannot close it — a future rename upstream would break this exactly as described, with the test still green.

On the design: churn as a second discriminator is the right call, since is_connected() True while thrashing sessions is precisely the shape the first check misses. Sampling from the result_watcher's ~1s loop against a ~9s observed session lifetime gives real margin, and treating the first observed id as baseline rather than churn avoids a boot-time false positive.

Scope: I reviewed the source change and the API-existence question; I did not run the suite or reproduce a live wedge. Two checks are still in flight at this head (typecheck + node & shell tests, diff coverage >= 95% (python)), so I am not reading them as green.

@john-the-dev john-the-dev reopened this Sep 2, 2026
@john-the-dev
john-the-dev enabled auto-merge (squash) September 2, 2026 21:32
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Reviewed at 62805328 in an isolated worktree (COMMENT — owner-login account). scripts/review-checks.sh --diff PASS; tests/slack-bridge-heartbeat-wedge.test.py 27/27 (both wedge shapes: is_connected() False, and session churn with a truthy is_connected()); six neighbouring Slack suites still green (health-check-notify-slack, health-slack-remedy-tofu, slack-bridge-access, -allowlist, -channel-context, -channel-redirect-guard). The heartbeat now has a single write site (slack-bridge.py:1766) gated on _socket_connected() and not _reconnect_churning() (:714), and every failure inside the probe returns False rather than raising into the heartbeat thread — the fail-safe direction.

What I cannot supply, and why it matters for this PR specifically: this is a live bridge path and its failure mode is a false stale — a healthy socket that reports is_connected() False, or routine session refreshes counted as churn, would make health-check flag a working bridge (and on a host running the STALE-bridge auto-restart, bounce it). Only a real Slack lane shows which way that goes. This host runs no Slack bridge (no process, no launchd job, slack_sdk not installed), so the post-restart witness — bridge restarted on this branch, heartbeat stays fresh through a normal hour, then goes stale within ~2 min of a forced disconnect — has to come from a host with a Slack lane; the test's routine-refresh-* cases model that, but a model is not the witness. Until it is posted I am not recommending merge; the code review itself found nothing.

Stand: Echo Act IV Mini

@john-the-dev
john-the-dev merged commit 1417b04 into main Sep 3, 2026
20 of 22 checks passed
@john-the-dev
john-the-dev deleted the fix/slack-bridge-wedge-detection branch September 3, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag2product AG2 product related (packages, PyPI, task-relay)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants