From 15a3e7e0c63ebb372e94d539138b0c6bd9d42122 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 11:31:05 +0000 Subject: [PATCH 1/7] feat: start custom automations as Fast sessions --- .../__tests__/custom-automations.test.ts | 285 +++++------------- .../server/automations/custom-automations.ts | 236 +++------------ .../lib/fast-agent-parent-event.test.ts | 35 ++- .../src/server/lib/fast-agent-parent-event.ts | 39 +-- .../manage-custom-automations-tool.test.ts | 4 +- .../src/manage-custom-automations-tool.ts | 4 +- 6 files changed, 157 insertions(+), 446 deletions(-) diff --git a/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts b/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts index fee0f6d5e..0bf973ea0 100644 --- a/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts +++ b/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts @@ -83,12 +83,6 @@ vi.mock('@roomote/db/server', () => ({ })); vi.mock('../destination', () => ({ - buildDestinationPromptContext: vi.fn(() => ({ - channelTag: 'slack_channel_id', - postToolName: 'post_to_channel', - surfaceLabel: 'Slack', - })), - buildDestinationTaskPayloadFields: vi.fn(() => ({})), findTeamsConversationRoute: vi.fn(), listConnectedCommunicationProviders: vi.fn(async () => ['slack', 'teams']), })); @@ -123,7 +117,7 @@ import { releaseCustomAutomationLaunchClaim, tryClaimCustomAutomationLaunch, } from '@roomote/db/server'; -import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types'; +import { ALL_REPOSITORIES } from '@roomote/types'; import { findUserDirectMessageDestination } from '../../lib/user-direct-message'; import { @@ -131,7 +125,6 @@ import { runCustomAutomationNow, } from '../custom-automations'; import { - buildDestinationTaskPayloadFields, findTeamsConversationRoute, listConnectedCommunicationProviders, } from '../destination'; @@ -151,7 +144,7 @@ const automation = { targetKind: 'slack_channel', externalRef: 'C123', }, - createdByUserId: null, + createdByUserId: 'user-1', lastRunAt: null, lastSucceededAt: null, lastFailedAt: null, @@ -184,9 +177,10 @@ describe('customAutomationsJob', () => { id: 'slack-installation-channel-1', slackInstallation: { isActive: true, teamId: 'T123' }, } as never); - vi.mocked(db.query.slackInstallations.findFirst).mockResolvedValue( - undefined, - ); + vi.mocked(db.query.slackInstallations.findFirst).mockResolvedValue({ + botAccessToken: 'xoxb-test', + teamId: 'T123', + } as never); vi.mocked(enqueueTask).mockResolvedValue({ taskId: 'task_abc', } as never); @@ -748,10 +742,11 @@ describe('customAutomationsJob', () => { }); }); - it('launches a StandardTask for due automations', async () => { + it('starts a scheduled legacy sandbox automation as a Fast Session', async () => { const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); + expect(result.completed).toBe(true); + expect(result.launchedTaskId).toBeNull(); expect(tryClaimCustomAutomationLaunch).toHaveBeenCalledWith( automation.id, automation.lastRunAt, @@ -762,32 +757,19 @@ describe('customAutomationsJob', () => { scheduleHourLocal: 3, }), ); - expect(enqueueTask).toHaveBeenCalledWith( + expect(enqueueTask).not.toHaveBeenCalled(); + expect(fastMocks.getSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + ); + expect(fastMocks.deliverParentEvent).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.objectContaining({ - type: TaskPayloadKind.StandardTask, - payload: expect.objectContaining({ - environmentId: automation.environmentId, - description: expect.stringContaining(automation.prompt), - repo: '', - customAutomationId: automation.id, - channel: 'C123', - slackChannel: 'C123', - }), + event: expect.objectContaining({ + type: 'automation_triggered', + automationId: automation.id, + prompt: automation.prompt, + trigger: 'schedule', + taskEnvironmentId: automation.environmentId, }), - initiator: { - kind: 'automation', - key: 'custom_automation', - actor: { - externalId: automation.id, - displayName: automation.name, - }, - }, - title: automation.name, - workflow: 'standard', - surface: 'system', - trigger: 'schedule', - channels: { slackChannelId: 'C123' }, }), ); expect(recordCustomAutomationRunOutcome).toHaveBeenCalledWith( @@ -795,13 +777,12 @@ describe('customAutomationsJob', () => { expect.objectContaining({ id: automation.id, status: 'succeeded', - lastLaunchedTaskId: 'task_abc', launchClaimedAt: expect.any(Date), }), ); }); - it('launches all-repositories automations without a named environment', async () => { + it('preserves all-repositories scope on the Fast automation event', async () => { vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ { ...automation, @@ -812,100 +793,44 @@ describe('customAutomationsJob', () => { const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); + expect(result.completed).toBe(true); expect(db.query.environments.findFirst).not.toHaveBeenCalled(); - expect(enqueueTask).toHaveBeenCalledWith( + expect(enqueueTask).not.toHaveBeenCalled(); + expect(fastMocks.deliverParentEvent).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.objectContaining({ - payload: expect.objectContaining({ repo: ALL_REPOSITORIES }), + event: expect.objectContaining({ + taskEnvironmentId: ALL_REPOSITORIES, }), }), ); - const enqueued = vi.mocked(enqueueTask).mock.calls[0]?.[0] as { - task: { payload: Record & { description: string } }; - }; - expect(enqueued.task.payload).not.toHaveProperty('environmentId'); - expect(enqueued.task.payload.description).toContain( - 'must include the concrete `targetRepositoryFullName`', - ); }); - it('passes a model override through to the launch', async () => { + it('passes a valid model override to delegated tasks through Fast', async () => { vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ { ...automation, model: 'anthropic/claude-sonnet-5' } as never, ]); await customAutomationsJob(); - expect(enqueueTask).toHaveBeenCalledWith( + expect(fastMocks.deliverParentEvent).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.objectContaining({ - harness: 'opencode-server', - payload: expect.objectContaining({ - harnessModelOverrides: { - 'opencode-server': 'anthropic/claude-sonnet-5', - }, - }), + event: expect.objectContaining({ + defaultTaskModel: 'anthropic/claude-sonnet-5', }), }), ); }); - it('launches on the deployment default when a persisted model is invalid', async () => { + it('uses the delegated-task default when a persisted model is invalid', async () => { vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ { ...automation, model: 'not-a-model' } as never, ]); const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); - const enqueued = vi.mocked(enqueueTask).mock.calls[0]?.[0] as { - task: { harness?: string; payload: Record }; - }; - expect(enqueued.task.harness).toBeUndefined(); - expect(enqueued.task.payload.harnessModelOverrides).toBeUndefined(); - }); - - it('makes the configured channel available for interruption-worthy reports', async () => { - await customAutomationsJob(); - - const enqueued = vi.mocked(enqueueTask).mock.calls[0]?.[0] as { - task: { payload: { description: string } }; - }; - expect(enqueued.task.payload.description).toContain( - 'C123', - ); - expect(enqueued.task.payload.description).toContain('send_chat_reply'); - expect(enqueued.task.payload.description).toContain( - 'do not post progress updates', - ); - expect(enqueued.task.payload.description).toContain( - 'Default to finishing silently', - ); - expect(enqueued.task.payload.description).toContain( - 'a concrete actionable or important finding', - ); - expect(enqueued.task.payload.description).toContain( - 'Routine success, healthy status, no-change results', - ); - expect(enqueued.task.payload.description).toContain( - 'do not mention this automation', - ); - expect(enqueued.task.payload.description).toContain( - '', - ); - expect(enqueued.task.payload.description).toContain( - 'On any conflict, follow the request', - ); - expect(enqueued.task.payload.description).toContain( - 'normally no more than about 250 words', - ); - expect(enqueued.task.payload.description).toContain( - 'send the detail in follow-up replies in the same thread', - ); - expect(enqueued.task.payload.description.indexOf(automation.prompt)).toBe( - 0, - ); + expect(result.completed).toBe(true); + const event = fastMocks.deliverParentEvent.mock.calls[0]?.[0]?.event; + expect(event).not.toHaveProperty('defaultTaskModel'); }); it('resolves a Slack DM target for the automation owner', async () => { @@ -923,22 +848,17 @@ describe('customAutomationsJob', () => { const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); + expect(result.completed).toBe(true); expect(findUserDirectMessageDestination).toHaveBeenCalledWith( 'slack', 'user-1', ); - expect(enqueueTask).toHaveBeenCalledWith( + expect(fastMocks.getSession).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.objectContaining({ - payload: expect.objectContaining({ - channel: 'D123', - slackChannel: 'D123', - teamId: 'T123', - slackTeamId: 'T123', - }), + conversation: expect.objectContaining({ + surface: 'slack', + replyTarget: { channelId: 'D123', threadId: '100.001' }, }), - channels: { slackChannelId: 'D123' }, }), ); }); @@ -970,6 +890,7 @@ describe('customAutomationsJob', () => { 'teams_user', { channelId: 'teams-dm-1', + teamId: 'tenant-1', serviceUrl: 'https://smba.example.com/amer/', }, ], @@ -998,83 +919,33 @@ describe('customAutomationsJob', () => { const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); + expect(result.completed).toBe(true); expect(findUserDirectMessageDestination).toHaveBeenCalledWith( provider, 'user-1', ); - expect(buildDestinationTaskPayloadFields).toHaveBeenCalledWith( + expect(fastMocks.getSession).toHaveBeenCalledWith( expect.objectContaining({ - provider, - ...resolvedDestination, + conversation: expect.objectContaining({ surface: provider }), }), ); }, ); - it('adds presentation defaults without channel anchoring when no report channel is configured', async () => { + it('uses a stored Fast conversation when no report destination is configured', async () => { vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ { ...automation, target: {} } as never, ]); const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); - const enqueued = vi.mocked(enqueueTask).mock.calls[0]?.[0] as { - task: { payload: Record }; - channels?: unknown; - }; - expect(enqueued.task.payload.description).toEqual( - expect.stringContaining(automation.prompt), - ); - expect(enqueued.task.payload.description).toEqual( - expect.stringContaining(''), - ); - expect(enqueued.task.payload.description).toEqual( - expect.stringContaining('On any conflict, follow the request'), - ); - expect(enqueued.task.payload.description).not.toEqual( - expect.stringContaining('send_chat_reply'), - ); - expect( - String(enqueued.task.payload.description).indexOf(automation.prompt), - ).toBe(0); - expect(enqueued.task.payload.customAutomationId).toBeUndefined(); - expect(enqueued.task.payload.channel).toBeUndefined(); - expect(enqueued.channels).toBeUndefined(); - expect(buildDestinationTaskPayloadFields).not.toHaveBeenCalled(); - }); - - it("falls back to the enabling admin's DM when no report channel is configured", async () => { - vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ - { - ...automation, - target: {}, - createdByUserId: 'user-1', - } as never, - ]); - - const result = await customAutomationsJob(); - - expect(result.launchedTaskId).toBe('task_abc'); - expect(findUserDirectMessageDestination).toHaveBeenCalledWith( - 'slack', - 'user-1', - ); - expect(enqueueTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - payload: expect.objectContaining({ - customAutomationId: automation.id, - channel: 'D123', - slackChannel: 'D123', - teamId: 'T123', - slackTeamId: 'T123', - }), - }), - channels: { slackChannelId: 'D123' }, - }), - ); + expect(result.completed).toBe(true); + expect(enqueueTask).not.toHaveBeenCalled(); + expect(findUserDirectMessageDestination).not.toHaveBeenCalled(); + expect(fastMocks.getSession).toHaveBeenCalledWith({ + userId: 'user-1', + conversation: expect.objectContaining({ surface: 'automation' }), + }); }); it('uses hour-0 boundary for hourly schedules', async () => { @@ -1125,23 +996,19 @@ describe('customAutomationsJob', () => { serviceUrl: 'https://smba.trafficmanager.net/amer/', workspaceId: 'tenant-1', }); - vi.mocked(buildDestinationTaskPayloadFields).mockReturnValue({ - communicationProvider: 'teams', - communicationChannelId: '19:abc@thread.tacv2', - communicationServiceUrl: 'https://smba.trafficmanager.net/amer/', - }); const result = await customAutomationsJob(); - expect(result.launchedTaskId).toBe('task_abc'); + expect(result.completed).toBe(true); expect(findTeamsConversationRoute).toHaveBeenCalledWith( '19:abc@thread.tacv2', ); - expect(enqueueTask).toHaveBeenCalledWith( + expect(fastMocks.getSession).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.objectContaining({ - payload: expect.objectContaining({ - communicationServiceUrl: 'https://smba.trafficmanager.net/amer/', + conversation: expect.objectContaining({ + surface: 'teams', + replyTarget: expect.objectContaining({ + serviceUrl: 'https://smba.trafficmanager.net/amer/', }), }), }), @@ -1178,37 +1045,37 @@ describe('customAutomationsJob', () => { describe('runCustomAutomationNow', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getCustomAutomationById).mockResolvedValue(automation as never); + vi.mocked(getCustomAutomationById).mockResolvedValue({ + ...automation, + target: {}, + } as never); vi.mocked(getCustomAutomationFrequency).mockReturnValue('daily'); vi.mocked(tryClaimCustomAutomationLaunch).mockResolvedValue(new Date()); vi.mocked(db.query.environments.findFirst).mockResolvedValue({ id: automation.environmentId, } as never); - vi.mocked(enqueueTask).mockResolvedValue({ - taskId: 'task_manual', - } as never); + fastMocks.getSession.mockResolvedValue({ + id: '33333333-3333-4333-8333-333333333333', + compatibilityMessages: [], + }); + fastMocks.deliverParentEvent.mockResolvedValue('delivered'); }); - it('launches with a manual trigger', async () => { + it('starts a Fast Session with a manual trigger and configured scope', async () => { const result = await runCustomAutomationNow(automation.id); - expect(result).toEqual({ - outcome: 'launched', - taskId: 'task_manual', - }); + expect(result).toEqual({ outcome: 'completed' }); expect(tryClaimCustomAutomationLaunch).toHaveBeenCalledWith( automation.id, automation.lastRunAt, ); - expect(enqueueTask).toHaveBeenCalledWith( + expect(enqueueTask).not.toHaveBeenCalled(); + expect(fastMocks.deliverParentEvent).toHaveBeenCalledWith( expect.objectContaining({ - trigger: 'manual', - task: expect.objectContaining({ - payload: expect.objectContaining({ - description: expect.stringContaining( - '', - ), - }), + event: expect.objectContaining({ + type: 'automation_triggered', + trigger: 'manual', + taskEnvironmentId: automation.environmentId, }), }), ); @@ -1226,10 +1093,10 @@ describe('runCustomAutomationNow', () => { expect(enqueueTask).not.toHaveBeenCalled(); }); - it('releases the launch claim when enqueue fails', async () => { + it('releases the launch claim when Fast delivery fails', async () => { const claimAt = new Date('2026-07-21T00:00:00.000Z'); vi.mocked(tryClaimCustomAutomationLaunch).mockResolvedValue(claimAt); - vi.mocked(enqueueTask).mockRejectedValue(new Error('queue down')); + fastMocks.deliverParentEvent.mockRejectedValue(new Error('inference down')); const result = await runCustomAutomationNow(automation.id); diff --git a/packages/sdk/src/server/automations/custom-automations.ts b/packages/sdk/src/server/automations/custom-automations.ts index 35dafdbc1..6de2f602b 100644 --- a/packages/sdk/src/server/automations/custom-automations.ts +++ b/packages/sdk/src/server/automations/custom-automations.ts @@ -1,7 +1,4 @@ -import { - enqueueTask, - getOrCreateFastAgentSession, -} from '@roomote/cloud-agents/server'; +import { getOrCreateFastAgentSession } from '@roomote/cloud-agents/server'; import { db, and, @@ -27,15 +24,12 @@ import { isBackgroundAutomationUserTargetKind, isCommunicationAutomationTarget, resolveEvalHarnessSelection, - TaskPayloadKind, type AutomationTarget, type CommunicationProvider, type FastAgentConversation, } from '@roomote/types'; import { - buildDestinationPromptContext, - buildDestinationTaskPayloadFields, findTeamsConversationRoute, listConnectedCommunicationProviders, type ResolvedAutomationDestination, @@ -163,98 +157,6 @@ async function resolveDestination( }; } -function buildDefaultReportPresentationGuidance( - hasDestination: boolean, -): string { - const channelGuidance = hasDestination - ? '\n- The first `send_chat_reply` is the report root and must stand alone. If important supporting detail would make it too long, keep the root concise and send the detail in follow-up replies in the same thread with clear headings. Keep essential conclusions and required actions in the root.' - : ''; - - return ` -These are defaults, not requirements that override the automation request above. Before applying them, check the request for explicit guidance about format, structure, length, tone, audience, or where details should appear. On any conflict, follow the request. Apply these defaults only where the request is silent. - -- Lead with the result or most important takeaway in 1-2 sentences. -- Keep the primary report concise, normally no more than about 250 words. -- When the report has multiple topics, use 2-4 short bold Markdown headings with bullets underneath them. -- Keep bullets short and put one finding, decision, or action in each bullet. -- Prioritize decision-useful findings. Omit routine methodology, exhaustive test transcripts, and repeated conclusions unless the request asks for them or they materially support the result. -- If the request explicitly requires a clean or no-action report, say so briefly and include only the most useful supporting evidence or caveats. -- Use inline links with descriptive labels instead of raw URLs when possible.${channelGuidance} -`; -} - -/** - * Adds default reporting guidance to every custom automation prompt and, - * when configured, makes its destination conversation available for - * interruption-worthy results. - * - * A custom automation may intentionally omit a report destination. When it - * does, prefer the admin who created/enabled it as a private fallback so an - * enabled automation does not disappear from the communication surface. - */ -async function resolveOwnerFallbackDestination( - ownerUserId: string | null, -): Promise { - if (!ownerUserId) { - return null; - } - - const connectedProviders = await listConnectedCommunicationProviders(); - for (const provider of connectedProviders) { - try { - const destination = await findUserDirectMessageDestination( - provider, - ownerUserId, - ); - if (destination) { - return { - provider, - ...destination, - source: 'automation_target', - }; - } - } catch (error) { - console.warn( - `${LOG_PREFIX} Failed to resolve owner DM on ${provider}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return null; -} - -function buildCustomAutomationDescription( - prompt: string, - destination: ResolvedAutomationDestination | null, - options: { allRepositories: boolean }, -): string { - const presentationGuidance = buildDefaultReportPresentationGuidance( - destination !== null, - ); - - if (!destination) { - return `${prompt} - -${presentationGuidance}`; - } - - const promptContext = buildDestinationPromptContext(destination); - const orgWideSuggestionInstruction = options.allRepositories - ? ' This run spans all active repositories. Every launchable suggestion must include the concrete `targetRepositoryFullName` that owns the work so Roomote can start it in the matching environment.' - : ''; - - return `${prompt} - -${presentationGuidance} - - - background-automation - <${promptContext.channelTag}>${destination.channelId} - - -The ${promptContext.surfaceLabel} conversation above is available for reports through \`send_chat_reply\`; do not use \`${promptContext.postToolName}\` and do not post anywhere else. Default to finishing silently. Interrupt the conversation only when there is something a human should see now: a concrete actionable or important finding, a meaningful completed result, a durable blocker, or required user input. Routine success, healthy status, no-change results, and findings that are neither actionable nor important should not produce a message unless the automation request explicitly asks for them. Stay silent while work is in flight: send no opening acknowledgement and do not post progress updates. If you do report, your first message creates this run's thread in that conversation, so make it one self-contained message that stands alone for readers who have not seen this task; later messages and user replies continue that same thread. Write the report as the result itself, like a teammate sharing what they found or did: do not mention this automation, the schedule, the task, or that anything requested the work; the message footer already attributes the automation. Lead with the outcome, not with framing like "Automation requested ..." or "Outcome: ...".${orgWideSuggestionInstruction}`; -} - function isFastDeliveryTarget(target: AutomationTarget): boolean { return isCommunicationAutomationTarget(target); } @@ -436,6 +338,7 @@ async function runFastCustomAutomation(params: { destination: ResolvedAutomationDestination | null; launchClaimedAt: Date; trigger: 'schedule' | 'manual'; + defaultTaskModel?: string; }): Promise { if (!params.automation.createdByUserId) { throw new Error('Fast automation run-as user is not configured.'); @@ -470,9 +373,14 @@ async function runFastCustomAutomation(params: { automationName: params.automation.name, prompt: params.automation.prompt, trigger: params.trigger, - ...(params.automation.model - ? { defaultTaskModel: params.automation.model } + ...(params.defaultTaskModel + ? { defaultTaskModel: params.defaultTaskModel } : {}), + ...(params.automation.allRepositories + ? { taskEnvironmentId: ALL_REPOSITORIES } + : params.automation.environmentId + ? { taskEnvironmentId: params.automation.environmentId } + : {}), ...(rootMessageId ? { rootMessageId } : {}), }; await deliverFastAgentParentEvent({ @@ -558,7 +466,7 @@ async function launchCustomAutomationRow( ): Promise { const result = emptyJobResult(); const frequency = getCustomAutomationFrequency(automation); - const fastExecution = automation.executionMode === 'fast'; + const hasUnconfiguredTaskScope = automation.executionMode === 'fast'; if (automation.scheduleMode !== 'cron' && frequency === 'off') { result.skippedReason = 'Automation is disabled.'; @@ -612,7 +520,6 @@ async function launchCustomAutomationRow( } if ( - fastExecution && automation.launchClaimedAt && Date.now() - automation.launchClaimedAt.getTime() >= CUSTOM_AUTOMATION_LAUNCH_STALE_CLAIM_MS @@ -631,7 +538,7 @@ async function launchCustomAutomationRow( } if ( - !fastExecution && + !hasUnconfiguredTaskScope && !automation.allRepositories && !automation.environmentId ) { @@ -646,14 +553,18 @@ async function launchCustomAutomationRow( } const environment = - fastExecution || automation.allRepositories + hasUnconfiguredTaskScope || automation.allRepositories ? null : await db.query.environments.findFirst({ columns: { id: true }, where: eq(environments.id, automation.environmentId!), }); - if (!fastExecution && !automation.allRepositories && !environment) { + if ( + !hasUnconfiguredTaskScope && + !automation.allRepositories && + !environment + ) { result.skippedReason = 'Environment no longer exists.'; result.errors.push('Environment no longer exists.'); await recordCustomAutomationRunOutcome(db, { @@ -664,12 +575,11 @@ async function launchCustomAutomationRow( return result; } - // A report destination is optional. Prefer a private DM to the admin who - // created/enabled the automation so an enabled run still has a chat-facing - // result; if that admin has no linked DM, preserve the task-UI fallback. + // A report destination is optional. Runs without one use the stored + // automation conversation so Fast still retains their result. let destination: ResolvedAutomationDestination | null = null; if (isConfiguredAutomationTarget(automation.target)) { - if (fastExecution && !isFastDeliveryTarget(automation.target)) { + if (!isFastDeliveryTarget(automation.target)) { const message = `${PROVIDER_LABELS[automation.target.provider as CommunicationProvider]} report destinations of this type are not supported in Fast mode.`; result.skippedReason = message; result.errors.push(message); @@ -712,14 +622,10 @@ async function launchCustomAutomationRow( }); return result; } - } else if (!fastExecution) { - destination = await resolveOwnerFallbackDestination( - automation.createdByUserId, - ); } // The short claim fence prevents concurrent launchers from double-launching - // without blocking a due run behind a previous task that still appears active. + // without blocking a due run behind a previous run that still appears active. const launchClaimedAt = await tryClaimCustomAutomationLaunch( automation.id, automation.lastRunAt, @@ -743,99 +649,27 @@ async function launchCustomAutomationRow( const modelOverride = modelSelection?.ok ? modelSelection : null; try { - if (fastExecution) { - await db - .update(customAutomations) - .set({ lastLaunchedTaskId: null }) - .where( - and( - eq(customAutomations.id, automation.id), - eq(customAutomations.launchClaimedAt, launchClaimedAt), - ), - ); - await runFastCustomAutomation({ - automation, - destination, - launchClaimedAt, - trigger: opts.manualTrigger ? 'manual' : 'schedule', - }); - await recordCustomAutomationRunOutcome(db, { - id: automation.id, - status: 'succeeded', - launchClaimedAt, - }); - result.completed = true; - return result; - } - - const launchResult = await enqueueTask({ - task: { - type: TaskPayloadKind.StandardTask, - ...(modelOverride?.harness ? { harness: modelOverride.harness } : {}), - payload: { - repo: automation.allRepositories ? ALL_REPOSITORIES : '', - ...(automation.environmentId - ? { environmentId: automation.environmentId } - : {}), - description: buildCustomAutomationDescription( - automation.prompt, - destination, - { - allRepositories: automation.allRepositories, - }, - ), - ...(destination - ? buildDestinationTaskPayloadFields(destination) - : {}), - // customAutomationId authorizes the Slack late-bound thread flow: - // the run's first send_chat_reply posts a root message in the - // destination channel and binds it as the task thread, so later - // updates continue the thread and user replies route back into the - // task. The channel/slackChannel payload fields give the sandbox - // its Slack reply context (ROOMOTE_SLACK_CHANNEL). - ...(destination ? { customAutomationId: automation.id } : {}), - ...(destination?.provider === 'slack' - ? { - channel: destination.channelId, - slackChannel: destination.channelId, - ...(destination.teamId - ? { - teamId: destination.teamId, - slackTeamId: destination.teamId, - } - : {}), - } - : {}), - ...(modelOverride?.harnessModelOverrides - ? { harnessModelOverrides: modelOverride.harnessModelOverrides } - : {}), - }, - }, - title: automation.name, - initiator: { - kind: 'automation', - key: 'custom_automation', - actor: { - externalId: automation.id, - displayName: automation.name, - }, - }, - workflow: 'standard', - surface: 'system', + await db + .update(customAutomations) + .set({ lastLaunchedTaskId: null }) + .where( + and( + eq(customAutomations.id, automation.id), + eq(customAutomations.launchClaimedAt, launchClaimedAt), + ), + ); + await runFastCustomAutomation({ + automation, + destination, + launchClaimedAt, trigger: opts.manualTrigger ? 'manual' : 'schedule', - ...(destination?.provider === 'slack' - ? { channels: { slackChannelId: destination.channelId } } - : {}), + ...(modelOverride ? { defaultTaskModel: automation.model! } : {}), }); - await recordCustomAutomationRunOutcome(db, { id: automation.id, status: 'succeeded', - lastLaunchedTaskId: launchResult.taskId, launchClaimedAt, }); - - result.launchedTaskId = launchResult.taskId; result.completed = true; return result; } catch (error) { diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 56e9a7a28..de012b449 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -3,6 +3,7 @@ const mocks = vi.hoisted(() => ({ releaseTurnLock: vi.fn(), answerQuestion: vi.fn(), createLauncher: vi.fn(), + createTaskLauncher: vi.fn(), launchTask: vi.fn(), findSession: vi.fn(), findInstallation: vi.fn(), @@ -56,18 +57,18 @@ vi.mock('@roomote/cloud-agents/server', () => ({ answerFastAgentQuestion: mocks.answerQuestion, resolveApiBaseUrl: () => 'https://roomote.example.com', fastAgentConversationRepository: { findById: mocks.findSession }, - createFastAgentTaskLauncher: - ({ - buildTask, - }: { - buildTask: (input: { - prompt: string; - environmentId: string | null; - model?: string | null; - parentSessionId: string; - }) => unknown | Promise; - }) => - async (input: { + createFastAgentTaskLauncher: (params: { + initiator: unknown; + buildTask: (input: { + prompt: string; + environmentId: string | null; + model?: string | null; + parentSessionId: string; + }) => unknown | Promise; + }) => { + mocks.createTaskLauncher(params); + const { buildTask } = params; + return async (input: { prompt: string; environmentId: string | null; model?: string | null; @@ -82,7 +83,8 @@ vi.mock('@roomote/cloud-agents/server', () => ({ await input.postKickoff({ taskId: 'child-task-1', taskUrl }); await mocks.enqueueTask({ task }); return { success: true, taskId: 'child-task-1', taskUrl }; - }, + }; + }, })); vi.mock('@roomote/db/server', () => ({ @@ -651,7 +653,7 @@ describe('deliverFastAgentParentEvent', () => { await adapter.resolveMcpServerConfigs(); return adapter.launchTask({ prompt: 'Inspect the repository.', - environmentId: null, + environmentId: 'agent-selected-environment', parentSessionId: automationParent.sessionId, postKickoff: vi.fn(), }); @@ -668,6 +670,7 @@ describe('deliverFastAgentParentEvent', () => { prompt: 'Find actionable regressions.', trigger: 'schedule', defaultTaskModel: 'openai/gpt-5.6-luna', + taskEnvironmentId: 'configured-automation-environment', }, }); @@ -676,11 +679,15 @@ describe('deliverFastAgentParentEvent', () => { apiBaseUrl: 'https://roomote.example.com', includeRoomoteMemberTools: true, }); + expect(mocks.createTaskLauncher).toHaveBeenCalledWith( + expect.objectContaining({ initiator: { kind: 'user', userId: 'u1' } }), + ); expect(mocks.enqueueTask).toHaveBeenCalledWith({ task: expect.objectContaining({ payload: expect.objectContaining({ fastAgentSessionId: automationParent.sessionId, fastAgentParent: automationParent, + environmentId: 'configured-automation-environment', harnessModelOverrides: { 'opencode-server': 'openai/gpt-5.6-luna', }, diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index a0cb12acf..661a3a074 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -124,6 +124,8 @@ export type FastAgentParentEvent = prompt: string; trigger: 'schedule' | 'manual'; defaultTaskModel?: string; + /** Enforced scope for sandbox tasks delegated by this automation. */ + taskEnvironmentId?: string; rootMessageId?: string; } | { @@ -344,11 +346,6 @@ function createFastAgentAutomationTaskLauncher(params: { conversation: Extract; event: FastAgentParentEvent; }): LaunchFastAgentTask { - const automationName = - params.event.type === 'automation_triggered' - ? params.event.automationName - : 'Custom automation'; - return createFastAgentTaskLauncher({ userId: params.userId, surface: 'system', @@ -358,12 +355,8 @@ function createFastAgentAutomationTaskLauncher(params: { : 'schedule', taskUrlCampaign: 'fast-automation-delegation', initiator: { - kind: 'automation', - key: 'custom_automation', - actor: { - externalId: params.conversation.workspaceId, - displayName: automationName, - }, + userId: params.userId, + kind: 'user', }, afterKickoff: async (taskRun) => { await db @@ -1316,13 +1309,23 @@ export async function deliverFastAgentParentEvent(params: { params.event.type === 'automation_triggered' ? params.event.defaultTaskModel : undefined; - const launchTask = defaultTaskModel - ? (input: Parameters[0]) => - parentTurn.adapter.launchTask({ - ...input, - model: input.model ?? defaultTaskModel, - }) - : parentTurn.adapter.launchTask; + const taskEnvironmentId = + params.event.type === 'automation_triggered' + ? params.event.taskEnvironmentId + : undefined; + const launchTask = + defaultTaskModel || taskEnvironmentId + ? (input: Parameters[0]) => + parentTurn.adapter.launchTask({ + ...input, + ...(defaultTaskModel + ? { model: input.model ?? defaultTaskModel } + : {}), + ...(taskEnvironmentId + ? { environmentId: taskEnvironmentId } + : {}), + }) + : parentTurn.adapter.launchTask; // The same base URL must reach both the config resolver and the broker: // the broker only injects its auth header on deployment-proxy URLs whose // origin matches its own apiBaseUrl, so a mismatched pair silently drops diff --git a/packages/types/src/manage-custom-automations-tool.test.ts b/packages/types/src/manage-custom-automations-tool.test.ts index 80ca11422..3cde765ec 100644 --- a/packages/types/src/manage-custom-automations-tool.test.ts +++ b/packages/types/src/manage-custom-automations-tool.test.ts @@ -23,11 +23,11 @@ describe('manage custom automations tool contract', () => { 'Admin-only management of deployment custom automations.', ); expect(MANAGE_CUSTOM_AUTOMATIONS_TOOL.description).toContain( - 'run the automation in Fast mode', + 'Every run starts as a Fast Session', ); expect( MANAGE_CUSTOM_AUTOMATIONS_TOOL.inputSchema.environmentId.description, - ).toContain('Fast mode without an initial sandbox task'); + ).toContain('Execution scope for tasks delegated by the Fast Session'); expect( MANAGE_CUSTOM_AUTOMATIONS_TOOL.inputSchema.schedule.description, ).toContain('off, every_hour, every_6_hours, daily, weekly'); diff --git a/packages/types/src/manage-custom-automations-tool.ts b/packages/types/src/manage-custom-automations-tool.ts index c3347d628..5c6bf74cc 100644 --- a/packages/types/src/manage-custom-automations-tool.ts +++ b/packages/types/src/manage-custom-automations-tool.ts @@ -43,7 +43,7 @@ export const manageCustomAutomationsFieldSchemas = { environmentId: z .string() .describe( - `Environment UUID, "${ALL_REPOSITORIES}", or "${FAST_EXECUTION}" for Fast mode without an initial sandbox task.`, + `Execution scope for tasks delegated by the Fast Session: an environment UUID, "${ALL_REPOSITORIES}" for every active repository, or "${FAST_EXECUTION}" to leave the task environment unconfigured.`, ) .optional(), targetProvider: z @@ -174,7 +174,7 @@ export function buildManageCustomAutomationsRequest( export const MANAGE_CUSTOM_AUTOMATIONS_TOOL = { name: 'manage_custom_automations', title: 'Manage Custom Automations', - description: `Admin-only management of deployment custom automations. List existing automations or enabled task models, resolve a cron or natural-language schedule, create or update an automation, delete an automation by exact ID, or run an enabled automation now. Pass environmentId "${FAST_EXECUTION}" to run the automation in Fast mode without starting a sandbox; Fast may still delegate a task when repository or workspace execution is required. Use list_models before setting a model override; create and update accept only exact model IDs returned by that action. Model IDs encode the inference route: for example, openrouter/... targets OpenRouter, while openai/... uses the deployment OpenAI route, including a connected ChatGPT subscription when configured. When the user asks an automation to DM them, set their preferred connected targetProvider and targetMode to direct_message; no targetChannelId is needed. Natural-language schedules are converted to validated five-field cron in the deployment scheduling timezone. Keep cadence only in the schedule field; do not repeat it in the stored prompt. When a user asks an automation to offer help, suggest tasks, make follow-ups actionable or launchable, or turn findings or action items into tasks, encode that intent in product language by instructing the automation to post concrete actions as launchable suggested tasks alongside its report. Do not expose runtime tool names or parameter syntax in the stored prompt. A request only to summarize or list action items is not suggested-task intent. Only promise launchable suggested tasks when the automation has both a configured chat report destination and a repository or environment for executable work; otherwise keep actions as report text and explain the missing capability. After successfully creating an automation in response to a conversational request, ask the user whether they want to run it now to test it.`, + description: `Admin-only management of deployment custom automations. List existing automations or enabled task models, resolve a cron or natural-language schedule, create or update an automation, delete an automation by exact ID, or run an enabled automation now. Every run starts as a Fast Session and may delegate a sandbox task only when repository or workspace execution is required. The configured environmentId scopes delegated tasks; pass "${FAST_EXECUTION}" to leave their environment unconfigured. Use list_models before setting a model override; create and update accept only exact model IDs returned by that action. Model IDs encode the inference route: for example, openrouter/... targets OpenRouter, while openai/... uses the deployment OpenAI route, including a connected ChatGPT subscription when configured. When the user asks an automation to DM them, set their preferred connected targetProvider and targetMode to direct_message; no targetChannelId is needed. Natural-language schedules are converted to validated five-field cron in the deployment scheduling timezone. Keep cadence only in the schedule field; do not repeat it in the stored prompt. When a user asks an automation to offer help, suggest tasks, make follow-ups actionable or launchable, or turn findings or action items into tasks, encode that intent in product language by instructing the automation to post concrete actions as launchable suggested tasks alongside its report. Do not expose runtime tool names or parameter syntax in the stored prompt. A request only to summarize or list action items is not suggested-task intent. Only promise launchable suggested tasks when the automation has both a configured chat report destination and a repository or environment for executable work; otherwise keep actions as report text and explain the missing capability. After successfully creating an automation in response to a conversational request, ask the user whether they want to run it now to test it.`, inputSchema: manageCustomAutomationsFieldSchemas, annotations: { readOnlyHint: false, From 7ebfd91ca38662dfea120715319bf7ee171f13f1 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 11:40:36 +0000 Subject: [PATCH 2/7] fix: keep ownerless automations on sandbox path --- .../__tests__/custom-automations.test.ts | 60 ++++++++- .../server/automations/custom-automations.ts | 124 +++++++++++++++++- 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts b/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts index 0bf973ea0..4cd3485e7 100644 --- a/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts +++ b/packages/sdk/src/server/automations/__tests__/custom-automations.test.ts @@ -83,6 +83,12 @@ vi.mock('@roomote/db/server', () => ({ })); vi.mock('../destination', () => ({ + buildDestinationPromptContext: vi.fn(() => ({ + channelTag: 'slack_channel_id', + postToolName: 'post_to_channel', + surfaceLabel: 'Slack', + })), + buildDestinationTaskPayloadFields: vi.fn(() => ({})), findTeamsConversationRoute: vi.fn(), listConnectedCommunicationProviders: vi.fn(async () => ['slack', 'teams']), })); @@ -117,7 +123,7 @@ import { releaseCustomAutomationLaunchClaim, tryClaimCustomAutomationLaunch, } from '@roomote/db/server'; -import { ALL_REPOSITORIES } from '@roomote/types'; +import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types'; import { findUserDirectMessageDestination } from '../../lib/user-direct-message'; import { @@ -782,6 +788,38 @@ describe('customAutomationsJob', () => { ); }); + it('keeps an ownerless legacy sandbox automation on the direct task path', async () => { + vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ + { ...automation, createdByUserId: null } as never, + ]); + + const result = await customAutomationsJob(); + + expect(result.launchedTaskId).toBe('task_abc'); + expect(fastMocks.getSession).not.toHaveBeenCalled(); + expect(fastMocks.deliverParentEvent).not.toHaveBeenCalled(); + expect(enqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: 'schedule', + initiator: { + kind: 'automation', + key: 'custom_automation', + actor: { + externalId: automation.id, + displayName: automation.name, + }, + }, + task: expect.objectContaining({ + type: TaskPayloadKind.StandardTask, + payload: expect.objectContaining({ + environmentId: automation.environmentId, + description: expect.stringContaining(automation.prompt), + }), + }), + }), + ); + }); + it('preserves all-repositories scope on the Fast automation event', async () => { vi.mocked(listEnabledCustomAutomations).mockResolvedValue([ { @@ -1081,6 +1119,26 @@ describe('runCustomAutomationNow', () => { ); }); + it('keeps an ownerless manual run on the legacy sandbox path', async () => { + vi.mocked(getCustomAutomationById).mockResolvedValue({ + ...automation, + createdByUserId: null, + target: {}, + } as never); + vi.mocked(enqueueTask).mockResolvedValue({ + taskId: 'task_manual', + } as never); + + const result = await runCustomAutomationNow(automation.id); + + expect(result).toEqual({ outcome: 'launched', taskId: 'task_manual' }); + expect(fastMocks.getSession).not.toHaveBeenCalled(); + expect(fastMocks.deliverParentEvent).not.toHaveBeenCalled(); + expect(enqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ trigger: 'manual' }), + ); + }); + it('skips manual run when a concurrent launch holds the claim', async () => { vi.mocked(tryClaimCustomAutomationLaunch).mockResolvedValue(null); diff --git a/packages/sdk/src/server/automations/custom-automations.ts b/packages/sdk/src/server/automations/custom-automations.ts index 6de2f602b..e02556d2d 100644 --- a/packages/sdk/src/server/automations/custom-automations.ts +++ b/packages/sdk/src/server/automations/custom-automations.ts @@ -1,4 +1,7 @@ -import { getOrCreateFastAgentSession } from '@roomote/cloud-agents/server'; +import { + enqueueTask, + getOrCreateFastAgentSession, +} from '@roomote/cloud-agents/server'; import { db, and, @@ -24,12 +27,15 @@ import { isBackgroundAutomationUserTargetKind, isCommunicationAutomationTarget, resolveEvalHarnessSelection, + TaskPayloadKind, type AutomationTarget, type CommunicationProvider, type FastAgentConversation, } from '@roomote/types'; import { + buildDestinationPromptContext, + buildDestinationTaskPayloadFields, findTeamsConversationRoute, listConnectedCommunicationProviders, type ResolvedAutomationDestination, @@ -157,6 +163,49 @@ async function resolveDestination( }; } +function buildLegacySandboxDescription( + prompt: string, + destination: ResolvedAutomationDestination | null, + allRepositories: boolean, +): string { + const channelGuidance = destination + ? '\n- The first `send_chat_reply` is the report root and must stand alone. If important supporting detail would make it too long, keep the root concise and send the detail in follow-up replies in the same thread with clear headings. Keep essential conclusions and required actions in the root.' + : ''; + const presentationGuidance = ` +These are defaults, not requirements that override the automation request above. Before applying them, check the request for explicit guidance about format, structure, length, tone, audience, or where details should appear. On any conflict, follow the request. Apply these defaults only where the request is silent. + +- Lead with the result or most important takeaway in 1-2 sentences. +- Keep the primary report concise, normally no more than about 250 words. +- When the report has multiple topics, use 2-4 short bold Markdown headings with bullets underneath them. +- Keep bullets short and put one finding, decision, or action in each bullet. +- Prioritize decision-useful findings. Omit routine methodology, exhaustive test transcripts, and repeated conclusions unless the request asks for them or they materially support the result. +- If the request explicitly requires a clean or no-action report, say so briefly and include only the most useful supporting evidence or caveats. +- Use inline links with descriptive labels instead of raw URLs when possible.${channelGuidance} +`; + + if (!destination) { + return `${prompt} + +${presentationGuidance}`; + } + + const promptContext = buildDestinationPromptContext(destination); + const orgWideSuggestionInstruction = allRepositories + ? ' This run spans all active repositories. Every launchable suggestion must include the concrete `targetRepositoryFullName` that owns the work so Roomote can start it in the matching environment.' + : ''; + + return `${prompt} + +${presentationGuidance} + + + background-automation + <${promptContext.channelTag}>${destination.channelId} + + +The ${promptContext.surfaceLabel} conversation above is available for reports through \`send_chat_reply\`; do not use \`${promptContext.postToolName}\` and do not post anywhere else. Default to finishing silently. Interrupt the conversation only when there is something a human should see now: a concrete actionable or important finding, a meaningful completed result, a durable blocker, or required user input. Routine success, healthy status, no-change results, and findings that are neither actionable nor important should not produce a message unless the automation request explicitly asks for them. Stay silent while work is in flight: send no opening acknowledgement and do not post progress updates. If you do report, your first message creates this run's thread in that conversation, so make it one self-contained message that stands alone for readers who have not seen this task; later messages and user replies continue that same thread. Write the report as the result itself, like a teammate sharing what they found or did: do not mention this automation, the schedule, the task, or that anything requested the work; the message footer already attributes the automation. Lead with the outcome, not with framing like "Automation requested ..." or "Outcome: ...".${orgWideSuggestionInstruction}`; +} + function isFastDeliveryTarget(target: AutomationTarget): boolean { return isCommunicationAutomationTarget(target); } @@ -467,6 +516,8 @@ async function launchCustomAutomationRow( const result = emptyJobResult(); const frequency = getCustomAutomationFrequency(automation); const hasUnconfiguredTaskScope = automation.executionMode === 'fast'; + const useLegacySandboxFallback = + !automation.createdByUserId && automation.executionMode === 'sandbox_task'; if (automation.scheduleMode !== 'cron' && frequency === 'off') { result.skippedReason = 'Automation is disabled.'; @@ -520,6 +571,7 @@ async function launchCustomAutomationRow( } if ( + !useLegacySandboxFallback && automation.launchClaimedAt && Date.now() - automation.launchClaimedAt.getTime() >= CUSTOM_AUTOMATION_LAUNCH_STALE_CLAIM_MS @@ -575,11 +627,11 @@ async function launchCustomAutomationRow( return result; } - // A report destination is optional. Runs without one use the stored - // automation conversation so Fast still retains their result. + // A report destination is optional. Fast runs without one retain their + // result in the stored automation conversation. let destination: ResolvedAutomationDestination | null = null; if (isConfiguredAutomationTarget(automation.target)) { - if (!isFastDeliveryTarget(automation.target)) { + if (!useLegacySandboxFallback && !isFastDeliveryTarget(automation.target)) { const message = `${PROVIDER_LABELS[automation.target.provider as CommunicationProvider]} report destinations of this type are not supported in Fast mode.`; result.skippedReason = message; result.errors.push(message); @@ -649,6 +701,70 @@ async function launchCustomAutomationRow( const modelOverride = modelSelection?.ok ? modelSelection : null; try { + if (useLegacySandboxFallback) { + const launchResult = await enqueueTask({ + task: { + type: TaskPayloadKind.StandardTask, + ...(modelOverride?.harness ? { harness: modelOverride.harness } : {}), + payload: { + repo: automation.allRepositories ? ALL_REPOSITORIES : '', + ...(automation.environmentId + ? { environmentId: automation.environmentId } + : {}), + description: buildLegacySandboxDescription( + automation.prompt, + destination, + automation.allRepositories, + ), + ...(destination + ? buildDestinationTaskPayloadFields(destination) + : {}), + ...(destination ? { customAutomationId: automation.id } : {}), + ...(destination?.provider === 'slack' + ? { + channel: destination.channelId, + slackChannel: destination.channelId, + ...(destination.teamId + ? { + teamId: destination.teamId, + slackTeamId: destination.teamId, + } + : {}), + } + : {}), + ...(modelOverride?.harnessModelOverrides + ? { harnessModelOverrides: modelOverride.harnessModelOverrides } + : {}), + }, + }, + title: automation.name, + initiator: { + kind: 'automation', + key: 'custom_automation', + actor: { + externalId: automation.id, + displayName: automation.name, + }, + }, + workflow: 'standard', + surface: 'system', + trigger: opts.manualTrigger ? 'manual' : 'schedule', + ...(destination?.provider === 'slack' + ? { channels: { slackChannelId: destination.channelId } } + : {}), + }); + + await recordCustomAutomationRunOutcome(db, { + id: automation.id, + status: 'succeeded', + lastLaunchedTaskId: launchResult.taskId, + launchClaimedAt, + }); + result.launchedTaskId = launchResult.taskId; + result.completed = true; + return result; + } + await db .update(customAutomations) .set({ lastLaunchedTaskId: null }) From 67ad7c535f5cad1c7036c87512a5aaf6e813683d Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 28 Aug 2026 12:14:58 +0000 Subject: [PATCH 3/7] feat: classify all automation task launchers --- apps/docs/automations.mdx | 33 ++- ...AutomationsSettings.render.client.test.tsx | 53 +++-- .../automations/CustomAutomationsSection.tsx | 45 ++-- .../automations/custom-automations.ts | 12 + .../server/automations/custom-automations.ts | 7 +- .../automation-task-launch-policy.test.ts | 71 ++++++ .../background-automation-registry.test.ts | 38 +++ .../src/automation-task-launch-policy.ts | 224 ++++++++++++++++++ .../src/background-automation-registry.ts | 15 ++ packages/types/src/index.ts | 1 + 10 files changed, 441 insertions(+), 58 deletions(-) create mode 100644 packages/types/src/__tests__/automation-task-launch-policy.test.ts create mode 100644 packages/types/src/automation-task-launch-policy.ts diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 233661c11..e1f8f2914 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -85,6 +85,13 @@ conflict resolution, and remove it when a human should handle the conflict instead. Roomote only tries this on labeled PRs that are still active, and it skips PRs older than the age cap you set. +Built-in repository scans, audits, issue and CI investigations, PR reviews, +conflict resolution, setup scans, and snapshot maintenance continue to start +tasks directly. Those automations either have no human run-as identity, produce +structured results consumed by another Roomote workflow, or require a sandbox +by definition. Deterministic alert and stats automations that do not need an +agent post their result directly without launching a task. + ## Custom automations Create arbitrary scheduled agent runs with: @@ -92,7 +99,8 @@ Create arbitrary scheduled agent runs with: - a clear **name** - the **prompt** Roomote should run - a **cadence** (`every hour`, `every 6 hours`, `daily`, or `weekly`) -- one required **execution target**: **Fast**, a named environment, or **All repositories** +- one required **delegated task scope**: no default environment, a named + environment, or **All repositories** - an optional **model** override for the runs; the default follows the deployment task model - an optional **report destination**: a direct message to the automation owner, @@ -101,9 +109,9 @@ Create arbitrary scheduled agent runs with: Each automation card summarizes its cadence, workspace target, report destination, creator, and most recent run. -Use **View previous runs** on an automation card to open the task list -filtered to that automation's runs. For a custom automation, the history is -scoped to that specific automation rather than all custom automations. +Legacy ownerless automations that still run directly in a sandbox show **View +previous runs** on their automation card. Owned automation results stay with +their Fast sessions and any tasks delegated from those sessions. When creating an automation through Roomote chat, ask for **suggested tasks** or **launchable follow-ups** if qualifying findings should become tasks that @@ -121,10 +129,13 @@ five-field cron expression or a natural-language schedule such as “weekdays at clarification rather than guessing when the recurrence itself is ambiguous. Custom schedules do not support seconds or cron macros. -On each due tick, Roomote either launches a normal task in the selected -environment (or across all active repositories), or runs the prompt directly in -**Fast** without starting a sandbox. A Fast run can still delegate a normal task -when repository or workspace execution is required. +On each due tick, an owned custom automation starts in **Fast** without a +sandbox. Fast uses integrations directly when that is enough and delegates a +normal task only when repository or workspace execution is required. The +selected task scope controls that delegated task; it does not force every run to +start a sandbox. Legacy automation records whose owner was deleted retain their +previous direct sandbox behavior because there is no user identity that can +authorize a Fast session safely. Fast runs deliver to every custom-automation report destination: Slack, Discord, Microsoft Teams, or Telegram, as either a channel/chat or a direct @@ -144,9 +155,9 @@ or important findings, meaningful completed results, blockers, and questions that need input. Routine success, healthy status, and no-change results stay silent unless the automation prompt explicitly asks for a report in those cases. The first message starts a thread, later updates continue that same -thread, and you can reply there to talk to the task. There is no progress -chatter in between. Without a destination, the run happens silently and its -results appear only in the task view. +thread, and you can reply there to continue the Fast session. There is no +progress chatter in between. Without a destination, the run happens silently +and its result remains in the stored Fast session. Unless the prompt asks for a different presentation, custom automation reports lead with the result, stay concise, and use short Markdown headings and bullets diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 5965fa397..8d93dc0de 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -22,6 +22,7 @@ const state = vi.hoisted(() => ({ cronExpression: string | null; model: null; executionMode?: 'sandbox_task' | 'fast'; + launchMode?: 'fast_session' | 'legacy_sandbox_task' | 'unavailable'; environmentId: string; target: { provider?: 'slack' | 'discord' | 'teams' | 'telegram'; @@ -280,7 +281,11 @@ const mutations = vi.hoisted(() => ({ ) => void; } | null, latestCustomTriggerOptions: null as { - onSuccess?: (result: { outcome: 'launched'; taskId: string }) => void; + onSuccess?: ( + result: + | { outcome: 'launched'; taskId: string } + | { outcome: 'completed' }, + ) => void; } | null, })); @@ -1082,6 +1087,7 @@ describe('AutomationsSettings', () => { scheduleMode: 'weekly', cronExpression: null, model: null, + launchMode: 'fast_session', environmentId: 'env-1', target: { provider: 'slack', externalRef: 'C123MANAGER' }, lastRunAt: null, @@ -1108,13 +1114,10 @@ describe('AutomationsSettings', () => { screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), ).toBeEnabled(); expect( - screen.getByRole('link', { + screen.queryByRole('link', { name: 'View previous runs for Weekly flaky-test scan', }), - ).toHaveAttribute( - 'href', - '/tasks?userId=automation%3Acustom_automation%3Aautomation-1', - ); + ).not.toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), ); @@ -1123,15 +1126,11 @@ describe('AutomationsSettings', () => { }); act(() => { mutations.latestCustomTriggerOptions?.onSuccess?.({ - outcome: 'launched', - taskId: 'task-custom-1', + outcome: 'completed', }); }); expect(toast.success).toHaveBeenCalledWith( - 'Running Weekly flaky-test scan now', - expect.objectContaining({ - action: expect.objectContaining({ label: 'View task' }), - }), + 'Weekly flaky-test scan ran successfully.', ); state.customAutomations.push({ @@ -1161,10 +1160,10 @@ describe('AutomationsSettings', () => { }), ).toBeInTheDocument(); expect( - screen.getByRole('link', { + screen.queryByRole('link', { name: 'View previous runs for Weekly flaky-test scan', }), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Delete Weekly flaky-test scan' }), ).toBeInTheDocument(); @@ -1199,6 +1198,7 @@ describe('AutomationsSettings', () => { scheduleMode: 'daily', cronExpression: null, model: null, + launchMode: 'fast_session', environmentId: '__all_repositories__', target: { provider: 'slack', externalRef: 'C123MANAGER' }, lastRunAt: null, @@ -1218,7 +1218,9 @@ describe('AutomationsSettings', () => { await screen.findByText('Daily, in All repositories →'), ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'New' })); - fireEvent.click(screen.getByRole('combobox', { name: 'Environment' })); + fireEvent.click( + screen.getByRole('combobox', { name: 'Delegated task environment' }), + ); expect( screen.getByRole('option', { name: 'All repositories' }), ).toBeInTheDocument(); @@ -1235,6 +1237,7 @@ describe('AutomationsSettings', () => { cronExpression: null, model: null, executionMode: 'fast', + launchMode: 'fast_session', environmentId: '__fast__', target: {}, lastRunAt: null, @@ -1251,7 +1254,11 @@ describe('AutomationsSettings', () => { render(); - expect(await screen.findByText('Daily, in Fast →')).toBeInTheDocument(); + expect( + await screen.findByText( + 'Daily, in Fast with no default task environment →', + ), + ).toBeInTheDocument(); expect( screen.getByText('No actionable regressions found.'), ).toBeInTheDocument(); @@ -1266,12 +1273,14 @@ describe('AutomationsSettings', () => { expect(screen.getByText('Delegated task model')).toBeInTheDocument(); expect( screen.getByText( - 'This run is stored as a Fast conversation without posting to chat.', + 'Each run starts as a stored Fast session. A sandbox task uses the selected environment only when workspace execution is required.', ), ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('combobox', { name: 'Environment' })); + fireEvent.click( + screen.getByRole('combobox', { name: 'Delegated task environment' }), + ); expect( - screen.getByRole('option', { name: 'Fast (no sandbox)' }), + screen.getByRole('option', { name: 'No default task environment' }), ).toBeInTheDocument(); }); @@ -1357,7 +1366,7 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Each Fast run posts here, and replies continue the Fast session.', + 'Each run starts as Fast and posts here; replies continue the Fast session.', ), ).toBeInTheDocument(); }); @@ -1408,7 +1417,7 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Each Fast run posts here, and replies continue the Fast session.', + 'Each run starts as Fast and posts here; replies continue the Fast session.', ), ).toBeInTheDocument(); }); @@ -1451,7 +1460,7 @@ describe('AutomationsSettings', () => { expect( screen.getByText( - 'Each Fast run posts here, and replies continue the Fast session.', + 'Each run starts as Fast and posts here; replies continue the Fast session.', ), ).toBeInTheDocument(); }); diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index ade8f27e6..43af9b7bd 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -326,7 +326,7 @@ export function CustomAutomationsSection() { const environmentOptions = useMemo( () => [ - { id: FAST_EXECUTION, name: 'Fast (no sandbox)' }, + { id: FAST_EXECUTION, name: 'No default task environment' }, { id: ALL_REPOSITORIES, name: 'All repositories' }, ...(environmentsQuery.data ?? []).map((environment) => ({ id: environment.id, @@ -727,7 +727,9 @@ export function CustomAutomationsSection() {
- +