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

@sweetmantechsweetmantech 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/app#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/app#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/app#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

cursorBot 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

vercelBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreviewJul 23, 2026 11:25am

Request Review

@coderabbitai

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-aicubic-dev-aiBot 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 freeto 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

@sweetmantech