From 4ef0285a849131b6fa8fd5fa564819ef818b20fc Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 23 Jul 2026 14:42:27 -0400 Subject: [PATCH 1/8] Notify production Cloud on main image builds (#731) --- .github/workflows/publish-ghcr.yml | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml index 4172244f..ae22e773 100644 --- a/.github/workflows/publish-ghcr.yml +++ b/.github/workflows/publish-ghcr.yml @@ -532,3 +532,37 @@ jobs: -f "client_payload[image_tag]=${IMAGE_TAG}" \ -f "client_payload[sha]=${GITHUB_SHA}" \ -f "client_payload[ref]=${GITHUB_REF}" + + # Production Cloud rolls its tenant fleet from immutable main builds the + # same way staging follows develop builds. The Cloud-side workflow + # validates the tag against the commit before touching anything. + notify-production-cloud: + name: Notify production Cloud + runs-on: ubuntu-latest + needs: + - prepare + - publish + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.ROOMOTE_CLOUD_REPO != '' }} + permissions: + contents: read + steps: + - name: Send production repository dispatch + env: + GH_TOKEN: ${{ secrets.ROOMOTE_CLOUD_DISPATCH_TOKEN }} + CLOUD_REPO: ${{ vars.ROOMOTE_CLOUD_REPO }} + IMAGE_TAG: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + : "${GH_TOKEN:?ROOMOTE_CLOUD_DISPATCH_TOKEN is required}" + case "$CLOUD_REPO" in + RooCodeInc/Roomote-Cloud) ;; + *) + echo "Refusing unexpected ROOMOTE_CLOUD_REPO: $CLOUD_REPO" >&2 + exit 1 + ;; + esac + gh api "repos/${CLOUD_REPO}/dispatches" \ + -f event_type=roomote-main-build \ + -f "client_payload[image_tag]=${IMAGE_TAG}" \ + -f "client_payload[sha]=${GITHUB_SHA}" \ + -f "client_payload[ref]=${GITHUB_REF}" From d74e1cdf0b51475a77e3d7ea6ea913a44a0b380b Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 23 Jul 2026 20:02:39 +0100 Subject: [PATCH 2/8] Update README for Roomote Cloud (#715) --- README.md | 67 +++++++++++++++++++++++-------------------------------- 1 file changed, 28 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1e2c8c65..0bd15a3d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ repo into an isolated sandbox, writes the code, runs the tests, takes a screenshot, and opens a PR. You review the diff like you would from any teammate. No IDE plugin. No terminal session. No babysitting. It works while you do -something else. +something else, all the way. ``` "Fix the 500 on /api/billing for annual plans" @@ -19,14 +19,17 @@ something else. → you review, merge, done ``` -Source-available. Self-hostable. Use your ChatGPT subscription or bring your own +Source-available. Self-hostable or our Cloud. Use your ChatGPT subscription or bring your own API keys. +![Roomote chat workflow demo](assets/roomote-hero.gif) + +[![Deploy on Roomote Cloud](https://roomote.dev/images/deploy-button.png)](https://cloud.roomote.dev/sign-up) +   [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Rj2cFo?referralCode=roomote)    [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/RooCodeInc/Roomote) -![Roomote chat workflow demo](assets/roomote-hero.gif) --- @@ -93,52 +96,40 @@ cleans up after itself. Pick one path. You will have a working Roomote instance at the end. -### Option A: One-click deploy (Railway) - -New to Railway? [Sign up with our referral link](https://railway.com?referralCode=roomote) -to get $20 in credit. +### Roomote Cloud (fastest, ~2 min) -1. Click the button: +Don't want to run infrastructure? We'll host your deployment: free 7-day trial, no credit card. Same single-tenant product — your own isolated instance, connect your ChatGPT subscription or bring an API key. Move to self-hosting anytime. [Start free →](https://cloud.roomote.dev/sign-up) - [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/Rj2cFo?referralCode=roomote) +### One-click deploy (Railway) -2. Railway provisions Postgres, Redis, and the app. Follow the prompts to set - your admin email and password. +New to Railway? [Sign up with our referral link](https://railway.com?referralCode=roomote) +to get $20 in credit. -3. Open the setup link Railway gives you. Connect models. Two options: - - **ChatGPT subscription:** Sign in with your ChatGPT account. If you - already pay for Plus or Pro, you're done. No API key needed. - - **API key:** Paste a key from OpenRouter, Anthropic, OpenAI, or any other - supported provider. +1. [Use the template](https://railway.com/deploy/Rj2cFo?referralCode=roomote) -4. Connect source control: GitHub, GitLab, Gitea, Azure DevOps, or Bitbucket - Cloud. +2. Railway provisions Postgres, Redis, and the app. -5. Open Slack, send Roomote a message: +3. Open the setup link Railway gives you. Configure your communication, source control and inference providers. - ``` - What files are in the root of the repo? - ``` +4. Connect your sandbox provider. - **Expected output:** Roomote lists your repo's top-level files and asks what - you'd like to work on. +5. Open Slack/Telegram/Discord/Teams, send Roomote a message and you're off. -You now have a working cloud coding agent. Total time: ~3 minutes. +You now have a working cloud coding agent. Total time: ~6 minutes. -### Option B: One-click deploy (Render) +### One-click deploy (Render) -1. Click the button: +1. [Use the template](https://render.com/deploy?repo=https://github.com/RooCodeInc/Roomote) - [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/RooCodeInc/Roomote) +2. Render provisions Postgres, Redis, and the app. -2. Render creates managed Postgres, Redis, and the app. Set your admin - credentials when prompted. +3. Open the setup link Render gives you. Configure your communication, source control and inference providers. -3. Open the setup link, connect a model provider and source control. +4. Connect your sandbox provider. -4. Send Roomote a test message in Slack. Same expected output as above. +5. Open Slack/Telegram/Discord/Teams, send Roomote a message and you're off. -### Option C: Self-host on your own server +### Self-host on your own server SSH into a fresh Ubuntu/Debian machine (4 GB RAM recommended) and run: @@ -147,13 +138,13 @@ curl -fsSL https://get.roomote.dev | bash ``` The installer handles Docker, secrets, and the Compose stack. It prints a setup -link when it finishes. Connect your model provider and repo, then send a test +link when it finishes. Follow the on-screen instructions, then send a test message. For production, point DNS at your server and pass `--domain roomote.example.com`. See also: [Coolify](deploy/coolify/README.md) and [Fly.io](deploy/fly/README.md) -deployment guides. +for more deployment guides. --- @@ -211,12 +202,9 @@ Features that matter at scale: ### Enterprise -Need SSO, custom SLAs, or dedicated support? Email +Need SSO, custom SLAs, or dedicated support? Get in touch: [help@roomote.dev](mailto:help@roomote.dev). -Want a managed deployment instead of self-hosting? We can run it for you. -[Get in touch](mailto:help@roomote.dev). - --- ## FAQ @@ -255,6 +243,7 @@ GitHub, GitLab, Gitea, Azure DevOps, and Bitbucket Cloud. Connect one or many. **What does it cost?** Self-hosting is free for up to 10 registered users. You pay your own model provider for tokens (or use the models bundled with your ChatGPT subscription). +Cloud starts at $49/mo, depending on total user count. For larger teams, licenses are available by emailing [help@roomote.dev](mailto:help@roomote.dev). From 396634fb7761acfe6826197668f31ad8c2d38aa3 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:18:09 -0500 Subject: [PATCH 3/8] test: cover snapshot-resume source-control stamp inheritance (#732) Regression tests for the fix that landed in #727: enqueueSnapshotResume inherits sourceControlProvider/sourceControlHost from the source run's payload when the resume entry point did not copy them, and keeps an explicitly provided provider untouched. Without the inheritance, resumed runs on non-GitHub providers (Azure DevOps, Gitea, Bitbucket) resolved the repository against the GitHub default and failed workspace preparation with "Repository not found". Co-authored-by: Claude Fable 5 --- .../src/server/__tests__/enqueue-task.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index 7d349de0..ded3ebfd 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -728,6 +728,88 @@ describe('enqueueTask snapshot resume', () => { expect(runsForTask).toHaveLength(2); }); + it('inherits source-control stamps from the source run payload', async () => { + const userId = await createUser(); + + const freshRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'roomote/Test ADO/Test ADO', + description: 'Do the thing', + sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + }); + + // Resume entry points rebuild the payload from scratch and historically + // dropped the provider stamp, which made workspace prep resolve the + // repository against the GitHub default and fail. + const resumeTask: SnapshotResumeTask = { + type: TaskPayloadKind.SnapshotResume, + payload: { + repo: 'roomote/Test ADO/Test ADO', + sourceSnapshotId: 'snap-ado-1', + sourceRunId: freshRun.id, + }, + } as SnapshotResumeTask; + + const resumeRun = await enqueueTask( + { task: resumeTask, actingUserId: userId }, + { enqueue: false }, + ); + + const resumePayload = resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + }; + + expect(resumePayload.sourceControlProvider).toBe('ado'); + expect(resumePayload.sourceControlHost).toBe('dev.azure.com'); + }); + + it('keeps an explicit source-control provider on the resume payload', async () => { + const userId = await createUser(); + + const freshRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'acme/widgets', + description: 'Do the thing', + sourceControlProvider: 'ado', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + const resumeTask: SnapshotResumeTask = { + type: TaskPayloadKind.SnapshotResume, + payload: { + repo: 'acme/widgets', + sourceSnapshotId: 'snap-explicit-1', + sourceRunId: freshRun.id, + sourceControlProvider: 'gitea', + }, + } as SnapshotResumeTask; + + const resumeRun = await enqueueTask( + { task: resumeTask, actingUserId: userId }, + { enqueue: false }, + ); + + expect( + (resumeRun.payload as { sourceControlProvider?: string }) + .sourceControlProvider, + ).toBe('gitea'); + }); + it('rejects a resume without a source run id', async () => { const resumeTask = { type: TaskPayloadKind.SnapshotResume, From 02737a5069918ffc3984e9f177ea7c47e03f0251 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:32:04 -0500 Subject: [PATCH 4/8] [Feature] Retire PR review offers when a typed reply supersedes them (#733) * feat(notifications): retire PR review offers when a reply supersedes them A typed reply in the thread means the person chose their own response, so the offer's buttons must die instead of lingering. Pending offers now carry a per-conversation index and the posted message id; when a follow-up message is routed into the task (live queue or snapshot resume), all pending offers for that conversation are claimed atomically and the Slack message is rewritten without its buttons ("Answered with a reply in the thread."). Telegram clears the inline keyboard; Discord offers are claimed so clicks report already-handled (component editing not wired yet). Late clicks on retired offers get the existing already-handled reply. Co-Authored-By: Claude Fable 5 * test: cover retire helper in discord/telegram sdk mocks, harden discord helper Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../handlers/discord/__tests__/index.test.ts | 1 + apps/api/src/handlers/discord/index.ts | 11 ++ .../src/handlers/discord/pr-review-action.ts | 29 +++++ .../slack/__tests__/pr-review-retire.test.ts | 102 +++++++++++++++ .../slack/dispatch/pr-review-action.ts | 48 +------ .../src/handlers/slack/events/active-run.ts | 7 ++ .../handlers/slack/events/snapshot-resume.ts | 7 ++ .../src/handlers/slack/pr-review-retire.ts | 61 +++++++++ .../handlers/telegram/__tests__/index.test.ts | 4 + apps/api/src/handlers/telegram/index.ts | 12 ++ .../src/handlers/telegram/pr-review-action.ts | 37 ++++++ .../src/jobs/pr-review-notification.test.ts | 1 + .../bullmq/src/jobs/pr-review-notification.ts | 15 ++- .../cloud-agents/src/server/task-run-queue.ts | 7 +- .../server/lib/task-runs/pr-review-action.ts | 117 ++++++++++++++++-- packages/slack/src/pr-review-action.ts | 42 +++++++ 16 files changed, 448 insertions(+), 53 deletions(-) create mode 100644 apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts create mode 100644 apps/api/src/handlers/slack/pr-review-retire.ts diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 320054ef..0671d191 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -83,6 +83,7 @@ vi.mock('@roomote/sdk/server', () => ({ restoreDiscordLinkCode: mocks.restoreLinkCode, upsertDiscordUserMapping: mocks.upsertUserMapping, upsertDiscordInstallation: mocks.upsertInstallation, + claimPendingPrReviewActionsForThread: vi.fn(async () => []), })); vi.mock('@roomote/sdk/server/communication', () => ({ diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index 3266cc0d..dbc0487c 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -44,6 +44,7 @@ import { resumeCommunicationTaskFromSnapshot, } from '@roomote/sdk/server/communication'; import { tryHandleDiscordRequestUserInputMessage } from './request-user-input.js'; +import { retireDiscordPrReviewOffersBestEffort } from './pr-review-action.js'; import { promptDiscordAccountLink } from './account-link.js'; import { processDiscordAttachments } from './attachments.js'; import { @@ -617,6 +618,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { activeRun.id, messageWithOutOfBand, ); + // A typed reply supersedes any pending PR review offers here. + retireDiscordPrReviewOffersBestEffort({ + channelId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId ?? null, + }); // Dedupe hit: nothing new will be delivered, so put claimed OOB // messages and undelivered thread claims back for a later real follow-up. if (!queued) { @@ -718,6 +724,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { channelId: channel.channelId, messageIds: [queuedMessage.ts], }); + // A typed reply supersedes any pending PR review offers here. + retireDiscordPrReviewOffersBestEffort({ + channelId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId ?? null, + }); return { ok: true, resumed: true, runId: resumed.id }; } catch (error) { await releaseDiscordContinuationClaim(continuationClaim); diff --git a/apps/api/src/handlers/discord/pr-review-action.ts b/apps/api/src/handlers/discord/pr-review-action.ts index 64c699d7..58db35a9 100644 --- a/apps/api/src/handlers/discord/pr-review-action.ts +++ b/apps/api/src/handlers/discord/pr-review-action.ts @@ -2,6 +2,7 @@ import type { DiscordInteraction } from '@roomote/communication/discord-event'; import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; import { claimPendingPrReviewAction, + claimPendingPrReviewActionsForThread, dispatchPrReviewFollowUp, enableAutoHandlePrReviewFeedback, findDiscordMappedUserId, @@ -103,3 +104,31 @@ export async function handleDiscordPrReviewActionCallback(input: { await reply('Failed to start the follow-up. Reply here to ask again.'); } } + +/** + * Retires any pending PR review offers bound to a Discord conversation + * because a typed reply superseded them. Claims atomically so later clicks + * report "already handled"; the buttons stay visible but dead (Discord + * message component editing is not wired up yet). Fire-and-forget. + */ +export function retireDiscordPrReviewOffersBestEffort({ + channelId, + threadId, +}: { + channelId: string; + threadId: string | null; +}): void { + void (async () => { + await claimPendingPrReviewActionsForThread({ + provider: 'discord', + channelId, + threadId, + }); + })().catch((error: unknown) => { + apiLogger.warn( + `[discord] Failed to retire PR review offers for channel ${channelId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); +} diff --git a/apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts b/apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts new file mode 100644 index 00000000..e4474552 --- /dev/null +++ b/apps/api/src/handlers/slack/__tests__/pr-review-retire.test.ts @@ -0,0 +1,102 @@ +const { claimForThreadMock, getMessageBlocksMock, updateMessageMock } = + vi.hoisted(() => ({ + claimForThreadMock: vi.fn(), + getMessageBlocksMock: vi.fn(), + updateMessageMock: vi.fn(), + })); + +vi.mock('@roomote/sdk/server', () => ({ + claimPendingPrReviewActionsForThread: claimForThreadMock, +})); + +import type { SlackNotifier } from '@roomote/slack'; + +import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; + +const slack = { + getMessageBlocks: getMessageBlocksMock, + updateMessage: updateMessageMock, +} as unknown as SlackNotifier; + +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.clearAllMocks(); + getMessageBlocksMock.mockResolvedValue([ + { type: 'section', text: { type: 'mrkdwn', text: 'summary' } }, + { + type: 'section', + block_id: 'pr_review_action_question', + text: { type: 'mrkdwn', text: 'Want me to take a look?' }, + }, + { type: 'actions', block_id: 'pr_review_action', elements: [] }, + { type: 'context', elements: [] }, + ]); + updateMessageMock.mockResolvedValue(true); +}); + +describe('retireSlackPrReviewOffersBestEffort', () => { + it('claims every offer in the thread and strips the buttons from each posted message', async () => { + claimForThreadMock.mockResolvedValue([ + { nonce: 'n1', messageId: '111.111' }, + { nonce: 'n2', messageId: '222.222' }, + ]); + + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: 'C123', + threadTs: '100.000', + }); + await flushAsync(); + + expect(claimForThreadMock).toHaveBeenCalledWith({ + provider: 'slack', + channelId: 'C123', + threadId: '100.000', + }); + expect(updateMessageMock).toHaveBeenCalledTimes(2); + const firstUpdate = updateMessageMock.mock.calls[0]?.[0]; + expect(firstUpdate.ts).toBe('111.111'); + const blocks = firstUpdate.message.blocks as Array>; + // Question and buttons are gone; a resolution note stands in their place. + expect( + blocks.some( + (block) => + block.block_id === 'pr_review_action' || + block.block_id === 'pr_review_action_question', + ), + ).toBe(false); + expect(JSON.stringify(blocks)).toContain( + 'Answered with a reply in the thread.', + ); + }); + + it('skips message edits for offers that never recorded a posted message', async () => { + claimForThreadMock.mockResolvedValue([{ nonce: 'n1', messageId: null }]); + + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: 'C123', + threadTs: '100.000', + }); + await flushAsync(); + + expect(updateMessageMock).not.toHaveBeenCalled(); + }); + + it('does nothing when the thread has no pending offers', async () => { + claimForThreadMock.mockResolvedValue([]); + + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: 'C123', + threadTs: '100.000', + }); + await flushAsync(); + + expect(getMessageBlocksMock).not.toHaveBeenCalled(); + expect(updateMessageMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts index a47111bf..551514ed 100644 --- a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts +++ b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts @@ -12,6 +12,7 @@ import { type PendingPrReviewAction, } from '@roomote/sdk/server'; import { + buildResolvedSlackPrReviewMessageBlocks, parseSlackPrReviewActionButtonValue, postSlackInteractiveResponse, type SlackInteractivePayload, @@ -34,48 +35,6 @@ async function getSlackTeamNotifier(teamId: string) { return { slack: new SlackNotifier(slackInstallation.botAccessToken) }; } -const QUESTION_BLOCK_ID = 'pr_review_action_question'; -const ACTIONS_BLOCK_ID = 'pr_review_action'; - -/** - * Rewrites the posted notification once the offer is resolved: the question - * and button blocks are replaced with a one-line resolution note while every - * other block (summary, relocated footers) is preserved as-is. - */ -function buildResolvedMessageBlocks( - originalBlocks: unknown[] | undefined, - resolution: string, -): unknown[] { - const resolutionBlock = { - type: 'context', - elements: [{ type: 'mrkdwn', text: resolution }], - }; - - if (!originalBlocks || originalBlocks.length === 0) { - return [resolutionBlock]; - } - - const kept = originalBlocks.filter((block) => { - const blockId = (block as { block_id?: unknown }).block_id; - - return blockId !== QUESTION_BLOCK_ID && blockId !== ACTIONS_BLOCK_ID; - }); - const actionsIndex = originalBlocks.findIndex( - (block) => (block as { block_id?: unknown }).block_id === ACTIONS_BLOCK_ID, - ); - // Insert the resolution where the buttons were; fall back to appending. - const removedBeforeActions = originalBlocks - .slice(0, actionsIndex < 0 ? 0 : actionsIndex) - .filter( - (block) => - (block as { block_id?: unknown }).block_id === QUESTION_BLOCK_ID, - ).length; - const insertAt = - actionsIndex < 0 ? kept.length : actionsIndex - removedBeforeActions; - - return [...kept.slice(0, insertAt), resolutionBlock, ...kept.slice(insertAt)]; -} - async function updateNotificationMessage({ payload, resolution, @@ -90,7 +49,10 @@ async function updateNotificationMessage({ channel: payload.channel.id, ts: payload.message.ts, message: { - blocks: buildResolvedMessageBlocks(payload.message.blocks, resolution), + blocks: buildResolvedSlackPrReviewMessageBlocks( + payload.message.blocks, + resolution, + ), }, }); } catch (error) { diff --git a/apps/api/src/handlers/slack/events/active-run.ts b/apps/api/src/handlers/slack/events/active-run.ts index 24665541..3564a4b6 100644 --- a/apps/api/src/handlers/slack/events/active-run.ts +++ b/apps/api/src/handlers/slack/events/active-run.ts @@ -33,6 +33,7 @@ import { import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; import { apiLogger } from '../../../logging.js'; +import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; import { syncActingUserForInboundMessage } from '../../tasks/acting-user-sync.js'; import { buildResolvedCurrentMessageText, @@ -527,6 +528,12 @@ export async function processActiveRunMessage( turnPolicy, }); await clearLatestUserMessage(activeRun.id); + // A typed reply supersedes any pending PR review offers in the thread. + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: event.channel, + threadTs: threadId, + }); } catch (error) { await deliveryTracker.rollback().catch(() => {}); throw error; diff --git a/apps/api/src/handlers/slack/events/snapshot-resume.ts b/apps/api/src/handlers/slack/events/snapshot-resume.ts index eacab0ff..c2ad6407 100644 --- a/apps/api/src/handlers/slack/events/snapshot-resume.ts +++ b/apps/api/src/handlers/slack/events/snapshot-resume.ts @@ -26,6 +26,7 @@ import { } from '@roomote/cloud-agents'; import { apiLogger } from '../../../logging.js'; +import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js'; import { buildResolvedCurrentMessageText, processSlackAttachments, @@ -227,6 +228,12 @@ export async function processSnapshotResume( await queueSlackMessage(resumeRunId, queuedSlackMessage); await clearLatestUserMessage(resumeRunId); + // A typed reply supersedes any pending PR review offers in the thread. + retireSlackPrReviewOffersBestEffort({ + slack, + channelId: event.channel, + threadTs: threadId, + }); deliveryTracker.track(event.ts); return true; } catch (error) { diff --git a/apps/api/src/handlers/slack/pr-review-retire.ts b/apps/api/src/handlers/slack/pr-review-retire.ts new file mode 100644 index 00000000..fc168638 --- /dev/null +++ b/apps/api/src/handlers/slack/pr-review-retire.ts @@ -0,0 +1,61 @@ +import { claimPendingPrReviewActionsForThread } from '@roomote/sdk/server'; +import { + buildResolvedSlackPrReviewMessageBlocks, + type SlackNotifier, +} from '@roomote/slack'; + +import { apiLogger } from '../../logging.js'; + +/** + * Retires any pending PR review offers bound to a Slack thread because a + * typed reply superseded them: the person chose their own response, so the + * buttons must die. Claims the offers atomically (later clicks report + * "already handled") and rewrites each posted message without its buttons. + * Fire-and-forget: retirement must never block or fail message delivery. + */ +export function retireSlackPrReviewOffersBestEffort({ + slack, + channelId, + threadTs, +}: { + slack: SlackNotifier; + channelId: string; + threadTs: string; +}): void { + void (async () => { + const claimed = await claimPendingPrReviewActionsForThread({ + provider: 'slack', + channelId, + threadId: threadTs, + }); + + for (const pending of claimed) { + if (!pending.messageId) { + continue; + } + + const blocks = await slack.getMessageBlocks({ + channel: channelId, + messageTs: pending.messageId, + threadTs, + }); + + await slack.updateMessage({ + channel: channelId, + ts: pending.messageId, + message: { + blocks: buildResolvedSlackPrReviewMessageBlocks( + blocks, + 'Answered with a reply in the thread.', + ), + }, + }); + } + })().catch((error: unknown) => { + apiLogger.warn( + `[SlackPrReviewRetire] Failed to retire offers for ${channelId}/${threadTs}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); +} diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 34939ef9..9c8e0356 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -261,6 +261,10 @@ vi.mock('@roomote/sdk/server', () => ({ /^link-[A-Za-z0-9_-]{16,}$/.test(value.trim()), findTelegramPrimaryChatId: vi.fn(async () => null), TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME: 'TELEGRAM_PRIMARY_CHAT_ID', + claimPendingPrReviewAction: vi.fn(async () => null), + claimPendingPrReviewActionsForThread: vi.fn(async () => []), + dispatchPrReviewFollowUp: vi.fn(), + enableAutoHandlePrReviewFeedback: vi.fn(), })); vi.mock('@roomote/communication/telegram-provider', () => ({ diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 00f99fad..8d8f4e77 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -28,6 +28,7 @@ import { findActiveTelegramTaskRun, findCompletedTelegramTaskRunWithSnapshot, } from './task-run-lookup.js'; +import { retireTelegramPrReviewOffersBestEffort } from './pr-review-action.js'; import { consumeTelegramLinkCode, isTelegramLinkCode, @@ -451,6 +452,11 @@ telegram.post('/', async (c) => { senderUserId: queuedMessage.userId, }); await queueCommunicationMessage('telegram', activeRun.id, queuedMessage); + // A typed reply supersedes any pending PR review offers in the chat. + retireTelegramPrReviewOffersBestEffort({ + chatId: conversation.chatId, + threadId: conversation.threadId ?? null, + }); // Track the latest inbound user message id so later outbound replies quote // the most recent user message instead of the original launch message. await setLatestInboundMessageId( @@ -535,6 +541,12 @@ telegram.post('/', async (c) => { metadata, }); + // A typed reply supersedes any pending PR review offers in the chat. + retireTelegramPrReviewOffersBestEffort({ + chatId: conversation.chatId, + threadId: conversation.threadId ?? null, + }); + await replyToTelegramSnapshotResume({ launchResult: resumeLaunch, conversation: { diff --git a/apps/api/src/handlers/telegram/pr-review-action.ts b/apps/api/src/handlers/telegram/pr-review-action.ts index da8d3db5..09b2b255 100644 --- a/apps/api/src/handlers/telegram/pr-review-action.ts +++ b/apps/api/src/handlers/telegram/pr-review-action.ts @@ -1,6 +1,7 @@ import type { TelegramCallbackQuery } from '@roomote/communication/telegram-update'; import { claimPendingPrReviewAction, + claimPendingPrReviewActionsForThread, dispatchPrReviewFollowUp, enableAutoHandlePrReviewFeedback, } from '@roomote/sdk/server'; @@ -147,3 +148,39 @@ export async function handleTelegramPrReviewActionCallback(params: { }); } } + +/** + * Retires any pending PR review offers bound to a Telegram conversation + * because a typed reply superseded them. Claims atomically and strips the + * buttons from each posted offer. Fire-and-forget. + */ +export function retireTelegramPrReviewOffersBestEffort({ + chatId, + threadId, +}: { + chatId: string; + threadId: string | null; +}): void { + void (async () => { + const claimed = await claimPendingPrReviewActionsForThread({ + provider: 'telegram', + channelId: chatId, + threadId, + }); + + for (const pending of claimed) { + if (pending.messageId) { + await clearTelegramMessageButtonsBestEffort({ + chatId, + messageId: pending.messageId, + }); + } + } + })().catch((error: unknown) => { + apiLogger.warn( + `[telegram] Failed to retire PR review offers for chat ${chatId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); +} diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index eda0e9d5..3db5ec3e 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -101,6 +101,7 @@ vi.mock('@roomote/sdk/server', () => ({ recordPrReviewNotificationDeliveryBestEffort: mockRecordDelivery, setPendingPrReviewAction: mockSetPendingPrReviewAction, dispatchPrReviewFollowUp: mockDispatchFollowUp, + attachPendingPrReviewActionMessage: vi.fn(), })); import type { Job } from 'bullmq'; diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index 6a4116f1..e0c9a75b 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -15,6 +15,7 @@ import { import { PR_REVIEW_NOTIFICATION_DEFER_MS, PR_REVIEW_NOTIFICATION_MAX_DEFERRALS, + attachPendingPrReviewActionMessage, getCommunicationProviderAdapter, type PrReviewNotificationRequest, type PrReviewNotificationRoute, @@ -138,7 +139,7 @@ async function postPrReviewNotification({ const slack = new SlackNotifier(slackInstallation.botAccessToken); - return postSlackThreadMessageWithStickyFooter({ + const messageTs = await postSlackThreadMessageWithStickyFooter({ slack, channel: route.channelId, threadTs: route.threadId, @@ -155,6 +156,12 @@ async function postPrReviewNotification({ : {}), utmCampaign: 'slack.pr_review', }); + + if (nonce && messageTs) { + await attachPendingPrReviewActionMessage(nonce, messageTs); + } + + return messageTs; } const adapter = await getCommunicationProviderAdapter(route.provider); @@ -187,7 +194,11 @@ async function postPrReviewNotification({ ]; } - await adapter.postMessage(postInput); + const posted = await adapter.postMessage(postInput); + + if (nonce && posted?.messageId) { + await attachPendingPrReviewActionMessage(nonce, posted.messageId); + } return null; } diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index a1e4b160..52613f4f 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -2012,13 +2012,18 @@ async function enqueueSnapshotResume( ) { const parentRun = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, parentRunId), - columns: { payloadKind: true, sourceRunId: true }, + columns: { payloadKind: true, sourceRunId: true, payload: true }, }); if (!parentRun) { break; } + // Resume rows created before stamps were inherited may lack them even + // though an ancestor has them; pick up whatever is still missing while + // walking, nearest ancestor first. + inheritSnapshotResumeSourceControlStamps(task.payload, parentRun.payload); + sourceTaskType = parentRun.payloadKind; parentRunId = parentRun.sourceRunId; } diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts index f7b62d75..4c333774 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts @@ -34,6 +34,12 @@ export interface PendingPrReviewAction { * summary. */ followUpPrompt: string; + /** + * Provider-native id of the posted notification message (Slack ts, Discord + * or Telegram message id), attached after posting so the offer can be + * visually retired later. + */ + messageId?: string | null; } // GETDEL is atomic: exactly one clicker receives the record; every later @@ -49,17 +55,64 @@ function getPrReviewActionKey(nonce: string): string { return `${PR_REVIEW_ACTION_PREFIX}${nonce}`; } +// Secondary index: every pending offer nonce for a conversation, so a typed +// reply in the thread can retire all of them at once. +function getPrReviewActionThreadKey(input: { + provider: PrReviewActionProvider; + channelId: string; + threadId: string | null; +}): string { + return `${PR_REVIEW_ACTION_PREFIX}thread:${input.provider}:${input.channelId}:${input.threadId ?? '-'}`; +} + export async function setPendingPrReviewAction( pending: PendingPrReviewAction, ): Promise { const redis = getRedis(); + const threadKey = getPrReviewActionThreadKey(pending); - await redis.set( - getPrReviewActionKey(pending.nonce), - JSON.stringify(pending), - 'EX', - PR_REVIEW_ACTION_TTL_SECONDS, - ); + await redis + .multi() + .set( + getPrReviewActionKey(pending.nonce), + JSON.stringify(pending), + 'EX', + PR_REVIEW_ACTION_TTL_SECONDS, + ) + .sadd(threadKey, pending.nonce) + .expire(threadKey, PR_REVIEW_ACTION_TTL_SECONDS) + .exec(); +} + +/** + * Records the posted notification message id on an already-stored pending + * offer so retirement can edit the message later. No-op when the offer was + * already claimed. + */ +export async function attachPendingPrReviewActionMessage( + nonce: string, + messageId: string, +): Promise { + const redis = getRedis(); + const key = getPrReviewActionKey(nonce); + const raw = await redis.get(key); + + if (typeof raw !== 'string') { + return; + } + + try { + const pending = JSON.parse(raw) as PendingPrReviewAction; + + await redis.set( + key, + JSON.stringify({ ...pending, messageId }), + 'EX', + PR_REVIEW_ACTION_TTL_SECONDS, + ); + } catch { + // Malformed record; leave it to expire. + } } export async function claimPendingPrReviewAction( @@ -77,12 +130,62 @@ export async function claimPendingPrReviewAction( } try { - return JSON.parse(raw) as PendingPrReviewAction; + const pending = JSON.parse(raw) as PendingPrReviewAction; + + await redis + .srem(getPrReviewActionThreadKey(pending), nonce) + .catch(() => undefined); + + return pending; } catch { return null; } } +/** + * Claims every pending offer bound to a conversation — used when a typed + * reply lands in the thread, which supersedes the offers: the person chose + * their own response, so the buttons must die. Returns the claimed records + * so callers can visually retire the posted messages. + */ +export async function claimPendingPrReviewActionsForThread(input: { + provider: PrReviewActionProvider; + channelId: string; + threadId: string | null; +}): Promise { + const redis = getRedis(); + const threadKey = getPrReviewActionThreadKey(input); + const nonces = await redis.smembers(threadKey); + + if (nonces.length === 0) { + return []; + } + + await redis.del(threadKey).catch(() => undefined); + + const claimed: PendingPrReviewAction[] = []; + + for (const nonce of nonces) { + const raw = await redis.eval( + CLAIM_PR_REVIEW_ACTION_LUA, + 1, + getPrReviewActionKey(nonce), + ); + + if (typeof raw !== 'string') { + continue; + } + + try { + claimed.push(JSON.parse(raw) as PendingPrReviewAction); + } catch { + // Malformed record; skip. + } + } + + return claimed; +} + /** * Marks a task's PR so future review feedback is dispatched into the task * automatically instead of asking first. The enabling user becomes the acting diff --git a/packages/slack/src/pr-review-action.ts b/packages/slack/src/pr-review-action.ts index 5b4c1417..4c705eb3 100644 --- a/packages/slack/src/pr-review-action.ts +++ b/packages/slack/src/pr-review-action.ts @@ -84,3 +84,45 @@ export function buildSlackPrReviewActionBlocks(params: { }, ] as SlackBlock[]; } + +const QUESTION_BLOCK_ID = 'pr_review_action_question'; +const ACTIONS_BLOCK_ID = 'pr_review_action'; + +/** + * Rewrites a posted PR review offer once it is resolved or superseded: the + * question and button blocks are replaced with a one-line resolution note + * while every other block (summary, relocated footers) is preserved as-is. + */ +export function buildResolvedSlackPrReviewMessageBlocks( + originalBlocks: unknown[] | undefined | null, + resolution: string, +): unknown[] { + const resolutionBlock = { + type: 'context', + elements: [{ type: 'mrkdwn', text: resolution }], + }; + + if (!originalBlocks || originalBlocks.length === 0) { + return [resolutionBlock]; + } + + const kept = originalBlocks.filter((block) => { + const blockId = (block as { block_id?: unknown }).block_id; + + return blockId !== QUESTION_BLOCK_ID && blockId !== ACTIONS_BLOCK_ID; + }); + const actionsIndex = originalBlocks.findIndex( + (block) => (block as { block_id?: unknown }).block_id === ACTIONS_BLOCK_ID, + ); + // Insert the resolution where the buttons were; fall back to appending. + const removedBeforeActions = originalBlocks + .slice(0, actionsIndex < 0 ? 0 : actionsIndex) + .filter( + (block) => + (block as { block_id?: unknown }).block_id === QUESTION_BLOCK_ID, + ).length; + const insertAt = + actionsIndex < 0 ? kept.length : actionsIndex - removedBeforeActions; + + return [...kept.slice(0, insertAt), resolutionBlock, ...kept.slice(insertAt)]; +} From 62bd8321dded1abcd1b7b6f120c0de8395716592 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:35:37 -0500 Subject: [PATCH 5/8] fix: walk the resume chain for source-control stamps (#734) Stamp inheritance on snapshot resume only consulted the immediate source run. When that run is itself a resume created before inheritance existed, its rebuilt payload has no stamps even though the original run does, so a second resume of a legacy non-GitHub task still fell back to the GitHub default and failed workspace preparation. enqueueSnapshotResume already walks the sourceRunId chain to find the original run for model selection; reuse that walk to pick up missing source-control stamps from the nearest ancestor that has them. Co-authored-by: Claude Fable 5 --- .../src/server/__tests__/enqueue-task.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index ded3ebfd..e74fb187 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -772,6 +772,66 @@ describe('enqueueTask snapshot resume', () => { expect(resumePayload.sourceControlHost).toBe('dev.azure.com'); }); + it('walks the resume chain for stamps when the source run predates inheritance', async () => { + const userId = await createUser(); + + const freshRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'roomote/Test ADO/Test ADO', + description: 'Do the thing', + sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + }); + + // Simulate a resume row created before stamps were inherited: it points + // at the stamped fresh run but its own rebuilt payload carries no stamps. + const [legacyResume] = await db + .insert(taskRuns) + .values({ + taskId: freshRun.taskId, + kind: 'resume', + sourceRunId: freshRun.id, + payloadKind: TaskPayloadKind.SnapshotResume, + status: RunStatus.Completed, + sourceSnapshotId: 'snap-legacy-1', + payload: { + repo: 'roomote/Test ADO/Test ADO', + sourceSnapshotId: 'snap-legacy-1', + sourceRunId: freshRun.id, + }, + }) + .returning(); + + const resumeTask: SnapshotResumeTask = { + type: TaskPayloadKind.SnapshotResume, + payload: { + repo: 'roomote/Test ADO/Test ADO', + sourceSnapshotId: 'snap-legacy-1', + sourceRunId: legacyResume!.id, + }, + } as SnapshotResumeTask; + + const resumeRun = await enqueueTask( + { task: resumeTask, actingUserId: userId }, + { enqueue: false }, + ); + + const resumePayload = resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + }; + + expect(resumePayload.sourceControlProvider).toBe('ado'); + expect(resumePayload.sourceControlHost).toBe('dev.azure.com'); + }); + it('keeps an explicit source-control provider on the resume payload', async () => { const userId = await createUser(); From 543146cd8d8017a76c02dd0191926834415d1321 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:10:57 -0500 Subject: [PATCH 6/8] feat(notifications): explicit resolve buttons for PR review offers (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team feedback: the button labels did not make the one-time vs ongoing distinction obvious, and an explainer paragraph read as too much. The buttons now answer the question directly — "Resolve these issues" (the feedback in this notification), "Resolve all issues" (these and every future one on this PR), "Dismiss" — and the triage prompt phrases the question with the same resolve verb so message and buttons stay congruent. Confirmations follow the same voice. Co-authored-by: Claude Fable 5 --- .../src/handlers/discord/pr-review-action.ts | 6 ++--- .../__tests__/pr-review-action.test.ts | 4 +-- .../slack/dispatch/pr-review-action.ts | 4 +-- .../src/handlers/telegram/pr-review-action.ts | 8 +++--- .../src/jobs/pr-review-notification.test.ts | 4 +-- .../bullmq/src/jobs/pr-review-notification.ts | 4 +-- .../pr-review-notification-delivery.test.ts | 2 +- .../pr-review-notification-delivery.ts | 9 ++++--- packages/sdk/trigger-automation-now.ts | 25 +++++++++++++++++++ packages/slack/src/pr-review-action.ts | 8 ++++-- 10 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 packages/sdk/trigger-automation-now.ts diff --git a/apps/api/src/handlers/discord/pr-review-action.ts b/apps/api/src/handlers/discord/pr-review-action.ts index 58db35a9..3c464c7c 100644 --- a/apps/api/src/handlers/discord/pr-review-action.ts +++ b/apps/api/src/handlers/discord/pr-review-action.ts @@ -84,7 +84,7 @@ export async function handleDiscordPrReviewActionCallback(input: { if (dispatched.outcome === 'unavailable') { await reply( input.choice === 'auto' - ? "I'll take future feedback from here, but this task can no longer be resumed for the current one. Reply here to start fresh." + ? "I'll resolve future feedback on this PR, but this task can no longer be resumed for the current feedback. Reply here to start fresh." : 'This task can no longer be resumed. Reply here to start fresh.', ); return; @@ -92,8 +92,8 @@ export async function handleDiscordPrReviewActionCallback(input: { await reply( input.choice === 'auto' - ? "I'll take it from here — future review feedback on this PR gets handled in this task. Looking at the current feedback now." - : 'On it — taking a look at the review feedback.', + ? "I'll resolve these and any future feedback on this PR automatically. Starting on the current feedback now." + : 'On it — resolving the review feedback.', ); } catch (error) { apiLogger.error( diff --git a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts index dc53ff5e..41aca620 100644 --- a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts +++ b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts @@ -218,7 +218,7 @@ describe('handleSlackPrReviewActionAuto', () => { expect.objectContaining({ elements: [ expect.objectContaining({ - text: expect.stringContaining('Taking it from here'), + text: expect.stringContaining('Resolving all issues'), }), ], }), @@ -237,7 +237,7 @@ describe('handleSlackPrReviewActionAuto', () => { expect(postSlackInteractiveResponseMock).toHaveBeenCalledWith( 'https://hooks.slack.test/response', expect.objectContaining({ - text: expect.stringContaining('take future feedback from here'), + text: expect.stringContaining('resolve future feedback'), }), ); // The message still resolves to the auto-handling note. diff --git a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts index 551514ed..683f4b3e 100644 --- a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts +++ b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts @@ -186,7 +186,7 @@ async function dispatchAcceptedPrReviewAction({ await respondEphemeral( payload, enableAutoHandle - ? "I'll take future feedback from here, but this task can no longer be resumed for the current one. Reply in the thread to start fresh." + ? "I'll resolve future feedback on this PR, but this task can no longer be resumed for the current feedback. Reply in the thread to start fresh." : 'This task can no longer be resumed. Reply in the thread to start fresh.', ); @@ -196,7 +196,7 @@ async function dispatchAcceptedPrReviewAction({ } const resolution = enableAutoHandle - ? `Taking it from here — requested by <@${payload.user.id}>. Future review feedback on this PR gets handled in this task.` + ? `Resolving all issues — requested by <@${payload.user.id}>. Future review feedback on this PR gets resolved in this task automatically.` : `On it — requested by <@${payload.user.id}>.`; await updateNotificationMessage({ payload, resolution }); diff --git a/apps/api/src/handlers/telegram/pr-review-action.ts b/apps/api/src/handlers/telegram/pr-review-action.ts index 09b2b255..d470b3de 100644 --- a/apps/api/src/handlers/telegram/pr-review-action.ts +++ b/apps/api/src/handlers/telegram/pr-review-action.ts @@ -106,7 +106,7 @@ export async function handleTelegramPrReviewActionCallback(params: { ...(messageId ? { replyToMessageId: messageId } : {}), text: choice === 'auto' - ? "I'll take future feedback from here, but this task can no longer be resumed for the current one. Reply here to start fresh." + ? "I'll resolve future feedback on this PR, but this task can no longer be resumed for the current feedback. Reply here to start fresh." : 'This task can no longer be resumed. Reply here to start fresh.', }); } @@ -118,7 +118,7 @@ export async function handleTelegramPrReviewActionCallback(params: { await answerTelegramCallbackQueryBestEffort({ callbackQueryId: query.id, - text: choice === 'auto' ? 'Taking it from here.' : 'On it.', + text: choice === 'auto' ? 'Resolving all issues.' : 'On it.', }); if (chatId && messageId) { @@ -131,8 +131,8 @@ export async function handleTelegramPrReviewActionCallback(params: { replyToMessageId: messageId, text: choice === 'auto' - ? "I'll take it from here — future review feedback on this PR gets handled in this task. Looking at the current feedback now." - : 'On it — taking a look at the review feedback.', + ? "I'll resolve these and any future feedback on this PR automatically. Starting on the current feedback now." + : 'On it — resolving the review feedback.', }); } } diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index 3db5ec3e..895d4a2c 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -311,11 +311,11 @@ describe('prReviewNotificationJob', () => { buttons: [ [ expect.objectContaining({ - text: 'Yes, take a look', + text: 'Resolve these issues', callbackData: `prr:y:${storedNonce}`, }), expect.objectContaining({ - text: 'Take it from here', + text: 'Resolve all issues', callbackData: `prr:a:${storedNonce}`, }), expect.objectContaining({ diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index e0c9a75b..200a75f6 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -179,11 +179,11 @@ async function postPrReviewNotification({ postInput.buttons = [ [ { - text: 'Yes, take a look', + text: 'Resolve these issues', callbackData: buildPrReviewActionCallbackData('yes', nonce), }, { - text: 'Take it from here', + text: 'Resolve all issues', callbackData: buildPrReviewActionCallbackData('auto', nonce), }, { diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts index 03ec035a..1b8ecdf0 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts @@ -433,7 +433,7 @@ describe('triagePrReviewActivity', () => { expect(system).toContain( 'current pull request state includes "- Merge conflicts: yes"', ); - expect(system).toContain('Do you want me to fix it?'); + expect(system).toContain('Would you like me to resolve this?'); }); it('includes the latest Roomote review summary comment verbatim for self-review results', async () => { diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts index b45570c4..d3bd4ffe 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts @@ -438,8 +438,11 @@ offers to act go in "followUpQuestion" and "followUpPrompt" instead. Rules: headers - when the feedback contains findings, requested changes, failed CI, or merge conflicts that are not already handled, write "followUpQuestion" as - one short question asking whether the user wants the agent to work on them - (for example: Want me to take a look?), and write "followUpPrompt" as a + one short question asking whether the user wants the agent to resolve + them, phrased with the verb "resolve" so it matches the reply buttons + labeled "Resolve these issues" and "Resolve all issues" (for example: + Would you like me to resolve these issues? or, for a single finding, + Would you like me to resolve this issue?), and write "followUpPrompt" as a self-contained imperative instruction to the coding agent describing exactly what to investigate and address - name the feedback source, the affected pull request, any failed check, and include the relevant links @@ -463,7 +466,7 @@ offers to act go in "followUpQuestion" and "followUpPrompt" instead. Rules: "looked good" review wrap-up. Prefer a summary shape like "I reviewed [owner/repo#42](pull request URL) on GitHub and the code looked good overall, but a test is failing in CI." with the offer to fix it carried by - "followUpQuestion" (for example: Do you want me to fix it?). Name the + "followUpQuestion" (for example: Would you like me to resolve this?). Name the failed check when one is listed. Pending checks may get a brief mention but must not overshadow a hard failure - when open feedback is already actionable (findings, requested changes, or diff --git a/packages/sdk/trigger-automation-now.ts b/packages/sdk/trigger-automation-now.ts new file mode 100644 index 00000000..89d341f5 --- /dev/null +++ b/packages/sdk/trigger-automation-now.ts @@ -0,0 +1,25 @@ +/** + * Local repro trigger: runs a background automation exactly like the web + * "Run now" button (manualTrigger: true). Untracked; delete after the repro. + * + * Usage: + * pnpm exec dotenvx run -f ../../.env.local -- pnpm exec tsx trigger-automation-now.ts + */ +import { runAutomationNow } from './src/server/automations'; + +const key = process.argv[2]; + +if (!key) { + console.error('Usage: tsx trigger-automation-now.ts '); + process.exit(1); +} + +runAutomationNow(key as never) + .then((result) => { + console.log('run-now result:', JSON.stringify(result, null, 2)); + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/packages/slack/src/pr-review-action.ts b/packages/slack/src/pr-review-action.ts index 4c705eb3..b9fe0661 100644 --- a/packages/slack/src/pr-review-action.ts +++ b/packages/slack/src/pr-review-action.ts @@ -60,7 +60,11 @@ export function buildSlackPrReviewActionBlocks(params: { { type: 'button', action_id: PR_REVIEW_ACTION_YES_ACTION_ID, - text: { type: 'plain_text', text: 'Yes, take a look', emoji: true }, + text: { + type: 'plain_text', + text: 'Resolve these issues', + emoji: true, + }, style: 'primary', value, }, @@ -69,7 +73,7 @@ export function buildSlackPrReviewActionBlocks(params: { action_id: PR_REVIEW_ACTION_AUTO_ACTION_ID, text: { type: 'plain_text', - text: 'Take it from here', + text: 'Resolve all issues', emoji: true, }, value, From 2763cc2759faec43900d5f7f11221622a08e0b28 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:25:15 -0400 Subject: [PATCH 7/8] chore(release): prepare Roomote 0.19.0 (#736) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3e4527..52b3c8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.19.0 (2026-07-23) + +This release makes pull-request review feedback clearer and safer to handle across chat, with more reliable task resumption. + +### Highlights + +- Resolve the feedback in a notification or have Roomote handle all future feedback on a pull request. +- Replying in a review-feedback thread retires its pending buttons so stale actions cannot conflict with the conversation. +- Resumed tasks retain their original source-control provider through older resume chains. + +### Minor changes + +- Make pull-request review feedback easier to handle by retiring stale offers after thread replies and providing clear actions to resolve selected or all issues. + +### Patch changes + +- Keep snapshot-resumed tasks connected to their original source-control provider when they resume through older task chains. + ## 0.18.0 (2026-07-23) This release makes pull-request feedback easier to act on across chat, extends Gitea automations, and improves task routing and reliability. diff --git a/package.json b/package.json index c18091bd..557255cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.18.0", + "version": "0.19.0", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { From 1be9a42abb722819a634c266999f3dbe1a612fb3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:43:36 +0000 Subject: [PATCH 8/8] fix: prevent stale PR review actions --- .../src/server/__tests__/enqueue-task.test.ts | 12 ++- .../cloud-agents/src/server/task-run-queue.ts | 6 +- .../__tests__/pr-review-action.test.ts | 52 +++++++++++++ .../server/lib/task-runs/pr-review-action.ts | 77 +++++++++++-------- 4 files changed, 110 insertions(+), 37 deletions(-) create mode 100644 packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index e74fb187..ccaee1de 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -841,6 +841,7 @@ describe('enqueueTask snapshot resume', () => { repo: 'acme/widgets', description: 'Do the thing', sourceControlProvider: 'ado', + sourceControlHost: 'dev.azure.com', }, }), initiator: { kind: 'user', userId }, @@ -865,9 +866,16 @@ describe('enqueueTask snapshot resume', () => { ); expect( - (resumeRun.payload as { sourceControlProvider?: string }) - .sourceControlProvider, + ( + resumeRun.payload as { + sourceControlProvider?: string; + sourceControlHost?: string; + } + ).sourceControlProvider, ).toBe('gitea'); + expect( + (resumeRun.payload as { sourceControlHost?: string }).sourceControlHost, + ).toBeUndefined(); }); it('rejects a resume without a source run id', async () => { diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 52613f4f..cb750a02 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -1917,7 +1917,9 @@ function inheritSnapshotResumeSourceControlStamps( sourceControlHost?: unknown; }; - if (payload.sourceControlProvider === undefined) { + const inheritsProvider = payload.sourceControlProvider === undefined; + + if (inheritsProvider) { const provider = sourceControlProviderSchema.safeParse( source.sourceControlProvider, ); @@ -1927,7 +1929,7 @@ function inheritSnapshotResumeSourceControlStamps( } } - if (payload.sourceControlHost === undefined) { + if (inheritsProvider && payload.sourceControlHost === undefined) { const host = typeof source.sourceControlHost === 'string' ? source.sourceControlHost.trim() diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts new file mode 100644 index 00000000..db0e813f --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action.test.ts @@ -0,0 +1,52 @@ +const mockEval = vi.fn(); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => ({ eval: mockEval }), +})); + +import { + attachPendingPrReviewActionMessage, + claimPendingPrReviewActionsForThread, +} from '../pr-review-action'; + +describe('PR review action state', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('attaches notification ids with an atomic compare-and-update script', async () => { + mockEval.mockResolvedValue(1); + + await attachPendingPrReviewActionMessage('nonce-1', 'message-1'); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('get', KEYS[1])"), + 1, + 'pr-review-action:nonce-1', + 'message-1', + ); + expect(mockEval.mock.calls[0]?.[0]).toContain("'KEEPTTL'"); + }); + + it('claims every indexed offer through one atomic script', async () => { + mockEval.mockResolvedValue([ + JSON.stringify({ nonce: 'nonce-1', messageId: 'message-1' }), + ]); + + await expect( + claimPendingPrReviewActionsForThread({ + provider: 'discord', + channelId: 'channel-1', + threadId: 'thread-1', + }), + ).resolves.toEqual([{ nonce: 'nonce-1', messageId: 'message-1' }]); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('smembers', KEYS[1])"), + 1, + 'pr-review-action:thread:discord:channel-1:thread-1', + 'pr-review-action:', + ); + expect(mockEval.mock.calls[0]?.[0]).toContain("redis.call('del', KEYS[1])"); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts index 4c333774..e0304be6 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts @@ -51,6 +51,35 @@ redis.call('del', KEYS[1]) return val `; +// Attaching a message must not revive an offer that a typed reply or button +// click claimed after the notification was posted. +const ATTACH_PR_REVIEW_ACTION_MESSAGE_LUA = ` +local val = redis.call('get', KEYS[1]) +if not val then return 0 end +local pending = cjson.decode(val) +pending.messageId = ARGV[1] +redis.call('set', KEYS[1], cjson.encode(pending), 'KEEPTTL') +return 1 +`; + +// Read, clear, and claim the complete conversation index in one operation so +// offers added concurrently remain indexed for a later typed reply. +const CLAIM_PR_REVIEW_ACTIONS_FOR_THREAD_LUA = ` +local nonces = redis.call('smembers', KEYS[1]) +if #nonces == 0 then return {} end +redis.call('del', KEYS[1]) +local claimed = {} +for _, nonce in ipairs(nonces) do + local actionKey = ARGV[1] .. nonce + local val = redis.call('get', actionKey) + if val then + redis.call('del', actionKey) + table.insert(claimed, val) + end +end +return claimed +`; + function getPrReviewActionKey(nonce: string): string { return `${PR_REVIEW_ACTION_PREFIX}${nonce}`; } @@ -94,25 +123,14 @@ export async function attachPendingPrReviewActionMessage( messageId: string, ): Promise { const redis = getRedis(); - const key = getPrReviewActionKey(nonce); - const raw = await redis.get(key); - - if (typeof raw !== 'string') { - return; - } - - try { - const pending = JSON.parse(raw) as PendingPrReviewAction; - - await redis.set( - key, - JSON.stringify({ ...pending, messageId }), - 'EX', - PR_REVIEW_ACTION_TTL_SECONDS, - ); - } catch { - // Malformed record; leave it to expire. - } + await redis + .eval( + ATTACH_PR_REVIEW_ACTION_MESSAGE_LUA, + 1, + getPrReviewActionKey(nonce), + messageId, + ) + .catch(() => undefined); } export async function claimPendingPrReviewAction( @@ -155,23 +173,16 @@ export async function claimPendingPrReviewActionsForThread(input: { }): Promise { const redis = getRedis(); const threadKey = getPrReviewActionThreadKey(input); - const nonces = await redis.smembers(threadKey); - - if (nonces.length === 0) { - return []; - } - - await redis.del(threadKey).catch(() => undefined); + const rawClaims = await redis.eval( + CLAIM_PR_REVIEW_ACTIONS_FOR_THREAD_LUA, + 1, + threadKey, + PR_REVIEW_ACTION_PREFIX, + ); const claimed: PendingPrReviewAction[] = []; - for (const nonce of nonces) { - const raw = await redis.eval( - CLAIM_PR_REVIEW_ACTION_LUA, - 1, - getPrReviewActionKey(nonce), - ); - + for (const raw of Array.isArray(rawClaims) ? rawClaims : []) { if (typeof raw !== 'string') { continue; }