Skip to content

feat(chat/runs): alert-only zombie-owner check on scheduled runs - #783

Open
sweetmantech wants to merge 1 commit into
mainfrom
feat/zombie-owner-alert
Open

feat(chat/runs): alert-only zombie-owner check on scheduled runs#783
sweetmantech wants to merge 1 commit into
mainfrom
feat/zombie-owner-alert

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

handleStartChatRun starts scheduled runs with no check that a human still uses the account — an abandoned account keeps generating (and billing) long after the owner left (recoupable/chat#1885, "Split out — billing & engagement integrity").

This fires a deduped Telegram alert when the owner's last role='user' message is older than 45 days (or they've never sent one). The run is NOT blocked — alert-only, per the issue spec.

Changes

  • getLatestUserMessageAt (lib/supabase/chat_messages/) — reads the owner's most recent role='user' message, walking chat_messages → chats → sessions via PostgREST inner embeds filtered on sessions.account_id.
  • isOwnerInactive — pure 45-day threshold predicate; null (never messaged) counts as inactive; exactly-at-threshold is still active.
  • markZombieOwnerAlerted — per-owner Redis SET NX EX dedup marker (30-day window) so daily scheduled runs don't spam; fails open on a Redis error so an infra blip never silences a real alert.
  • alertZombieOwner — orchestrates the read → detect → dedup → Telegram send; never throws.
  • handleStartChatRun — schedules the check via after() so it never blocks the 202 or the run.

Scope note

Detection uses chat_messages.role (the durable workflow chat surface the scheduled-run journey writes to) — the table with an explicit role column the issue references.

Verification

  • TDD RED→GREEN for every unit:
    • isOwnerInactive — null→inactive, >45d→inactive, <45d/at-threshold/today→active, threshold is 45.
    • markZombieOwnerAlerted — SET NX+EX returns true first time, false when marker exists, fails open on Redis error.
    • alertZombieOwner — alerts on >45d and on never-messaged, silent when active, deduped when marker claimed, never throws on dependency failure.
    • handleStartChatRun — fires the alert with the run's owner/chat/session after start; a throwing alert does not block the 202.
  • Full lib/chat/runs domain suite green — 35 tests passing.
  • tsc --noEmit: no errors in changed files.
  • eslint + prettier clean on all changed files.

Targets main.

Relates to recoupable/chat#1885 (split-out billing & engagement integrity).

🤖 Generated with Claude Code


Summary by cubic

Adds an alert-only zombie-owner check for scheduled chat runs. If the owner hasn’t sent a user message in 45+ days (or never), we send a deduped Telegram alert after the run starts without blocking it.

  • New Features
    • getLatestUserMessageAt: reads the owner’s latest role='user' message via Supabase embeds (chat_messages → chats → sessions filtered by sessions.account_id).
    • isOwnerInactive: pure 45-day predicate; null counts inactive; exactly 45 days is still active.
    • markZombieOwnerAlerted: per-owner Redis dedup (SET NX EX, 30-day TTL); fails open on Redis errors to avoid missing real alerts.
    • alertZombieOwner: orchestrates read → detect → dedup → Telegram send; never throws. Wired into handleStartChatRun via Next after() so it never delays the 202 or the run.

Written for commit 5117ce8. Summary will update on new commits.

Review in cubic

`handleStartChatRun` starts scheduled runs with no check that a human still
uses the account, so an abandoned account keeps generating (and billing) long
after the owner left (recoupable/chat#1885).

Fire a DEDUPED Telegram alert when the owner's last `role='user'` message is
older than 45 days (or they've never sent one). The run is NOT blocked — this
is alert-only.

- `getLatestUserMessageAt` (`lib/supabase/chat_messages/`) — reads the owner's
  most recent `role='user'` message via `chat_messages → chats → sessions`
  inner embeds filtered on `sessions.account_id`.
- `isOwnerInactive` — pure 45-day threshold predicate (null → inactive).
- `markZombieOwnerAlerted` — per-owner Redis `SET NX EX` dedup marker (30-day
  window) so daily runs don't spam; fails open on Redis error.
- `alertZombieOwner` — orchestrates the three + the Telegram send; never throws.
- `handleStartChatRun` — schedules the check via `after()` so it never blocks
  the 202 or the run.

TDD: RED→GREEN for the >45d detection, the dedup marker, the orchestrator, and
the handler wiring.

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

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 23, 2026 11:25am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: efb10125-0488-426f-8923-eaff71285432

📥 Commits

Reviewing files that changed from the base of the PR and between 31567ad and 5117ce8.

⛔ Files ignored due to path filters (4)
  • lib/chat/runs/__tests__/alertZombieOwner.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/runs/__tests__/handleStartChatRun.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/runs/__tests__/isOwnerInactive.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/runs/__tests__/markZombieOwnerAlerted.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (5)
  • lib/chat/runs/alertZombieOwner.ts
  • lib/chat/runs/handleStartChatRun.ts
  • lib/chat/runs/isOwnerInactive.ts
  • lib/chat/runs/markZombieOwnerAlerted.ts
  • lib/supabase/chat_messages/getLatestUserMessageAt.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/zombie-owner-alert

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 9 files

Confidence score: 3/5

  • In lib/chat/runs/alertZombieOwner.ts, claiming the dedup marker before sendMessage means a transient Telegram error can block owner alerts for the full dedup window, so real zombie runs may go unreported until the window expires — release/rollback the claimed marker when send fails (or only claim after a successful send).
  • In lib/chat/runs/__tests__/handleStartChatRun.test.ts, the handler’s after() path calls alertZombieOwner(...) without awaiting or handling its Promise, so a rejection can surface as an unhandled promise rejection and create flaky behavior or process-level warnings/failures — await it or attach explicit error handling in the callback.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/chat/runs/alertZombieOwner.ts">

<violation number="1" location="lib/chat/runs/alertZombieOwner.ts:36">
P2: A transient Telegram failure suppresses this owner's alert for the full dedup window: the marker is claimed before the send, then retained when `sendMessage` rejects. Consider releasing the claimed marker on send failure (or otherwise marking only successful delivery) so a later scheduled run can retry.</violation>
</file>

<file name="lib/chat/runs/__tests__/handleStartChatRun.test.ts">

<violation number="1" location="lib/chat/runs/__tests__/handleStartChatRun.test.ts:120">
P2: The `after()` callback in the handler fires `alertZombieOwner(...)` and discards its Promise. When the `alertZombieOwner` mock rejects (as the second test does), the rejection becomes an unhandled promise rejection in the test environment, which can cause test flakiness or warnings in stricter runners. Add `.catch(() => {})` to the `alertZombieOwner` call inside `after()` so both production and test environments are safe regardless of the implementation contract.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Scheduler as External Scheduler
    participant API as handleStartChatRun
    participant After as after() Hook
    participant Alert as alertZombieOwner
    participant DB as Supabase
    participant Redis as Redis
    participant Telegram as Telegram Bot API

    Note over Scheduler,Telegram: Zombie-Owner Alert Flow for Scheduled Runs

    Scheduler->>API: POST /api/chat/runs
    API->>API: validateChatRunRequest()
    API->>API: provisionRunSession()
    API->>API: start workflow with accountId, chatId, sessionId
    API-->>Scheduler: 202 Accepted (run started)

    Note over API,After: Post-response (non-blocking via after())
    API->>After: schedule alertZombieOwner(accountId, chatId, sessionId)

    After->>Alert: alertZombieOwner(params)

    Alert->>DB: getLatestUserMessageAt(accountId)
    Note over Alert,DB: chat_messages → chats → sessions via PostgREST embeds
    DB-->>Alert: latest user message timestamp or null

    alt Owner never messaged OR message > 45 days old
        Alert->>Alert: isOwnerInactive() → true

        Alert->>Redis: markZombieOwnerAlerted(accountId)
        Note over Alert,Redis: SET zombie-owner-alert:{id} 1 EX 2592000 NX

        alt First alert in dedup window (Redis returns OK)
            Redis-->>Alert: true
            Alert->>Telegram: sendMessage(zombie alert text)
            Note over Alert,Telegram: Deduped markdown message with account/chat/session details
            Telegram-->>Alert: sent
        else Marker already exists (duplicate)
            Redis-->>Alert: null
            Note over Alert,Alert: Skip - already alerted this window
        end
    else Owner active (message within 45 days)
        Note over Alert,Alert: Skip - owner still active
    end

    opt Redis error (infra blip)
        Redis-->>Alert: throws
        Note over Alert: Fails open - returns true to avoid missing real alert
        Alert->>Telegram: sendMessage(zombie alert text)
    end

    opt DB error
        DB-->>Alert: error
        Note over Alert: Logs error, returns null (safe default)
        Note over Alert,Alert: getLatestUserMessageAt returns null → treats as never messaged
    end

    opt Any dependency throws
        Note over Alert: Surrounding try/catch catches and logs
        Note over Alert,Alert: Never throws - run continues unaffected
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if (!isOwnerInactive(lastUserMessageAt, new Date())) return;

// Dedup before sending so repeated scheduled runs don't spam.
const shouldSend = await markZombieOwnerAlerted(accountId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A transient Telegram failure suppresses this owner's alert for the full dedup window: the marker is claimed before the send, then retained when sendMessage rejects. Consider releasing the claimed marker on send failure (or otherwise marking only successful delivery) so a later scheduled run can retry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/runs/alertZombieOwner.ts, line 36:

<comment>A transient Telegram failure suppresses this owner's alert for the full dedup window: the marker is claimed before the send, then retained when `sendMessage` rejects. Consider releasing the claimed marker on send failure (or otherwise marking only successful delivery) so a later scheduled run can retry.</comment>

<file context>
@@ -0,0 +1,56 @@
+    if (!isOwnerInactive(lastUserMessageAt, new Date())) return;
+
+    // Dedup before sending so repeated scheduled runs don't spam.
+    const shouldSend = await markZombieOwnerAlerted(accountId);
+    if (!shouldSend) return;
+
</file context>

});

it("does not block the run when the zombie-owner alert throws", async () => {
vi.mocked(alertZombieOwner).mockRejectedValueOnce(new Error("alert boom"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The after() callback in the handler fires alertZombieOwner(...) and discards its Promise. When the alertZombieOwner mock rejects (as the second test does), the rejection becomes an unhandled promise rejection in the test environment, which can cause test flakiness or warnings in stricter runners. Add .catch(() => {}) to the alertZombieOwner call inside after() so both production and test environments are safe regardless of the implementation contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/runs/__tests__/handleStartChatRun.test.ts, line 120:

<comment>The `after()` callback in the handler fires `alertZombieOwner(...)` and discards its Promise. When the `alertZombieOwner` mock rejects (as the second test does), the rejection becomes an unhandled promise rejection in the test environment, which can cause test flakiness or warnings in stricter runners. Add `.catch(() => {})` to the `alertZombieOwner` call inside `after()` so both production and test environments are safe regardless of the implementation contract.</comment>

<file context>
@@ -98,6 +107,23 @@ describe("handleStartChatRun", () => {
+  });
+
+  it("does not block the run when the zombie-owner alert throws", async () => {
+    vi.mocked(alertZombieOwner).mockRejectedValueOnce(new Error("alert boom"));
+
+    const res = await handleStartChatRun(req());
</file context>

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