From df202fe5a7fa7bf3fe2390508f8e8408dcd0e908 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 14 Sep 2026 10:32:25 -0400 Subject: [PATCH 1/2] feat(investigations): Create agentic runs from both entry points Neither entry point produced an investigation with hypotheses. The server decides from the request body -- a `source` with no `templateKey` builds an agentic run, anything else builds a plain notebook -- and both buttons sent a shape that landed elsewhere. "Investigate" on a metric issue now sends the metric snapshot alone, dropping the template key. The candidates endpoint already matches agentic and template lineage keys alike, so a breach that was investigated before still resolves to "View" rather than offering a duplicate. "New investigation" sends a manual source. That run opens `awaiting_input` and stays there until someone supplies a prompt, which nothing in the UI does yet, so the investigation behaves as before -- an empty notebook -- with an idle run attached. That idle run is why polling changed. The predicate asked whether a run had reached a terminal status, and `awaiting_input` has not; left alone, every newly created investigation would have polled the detail and orchestration endpoints every two seconds forever. It now asks whether the agent is advancing, which `awaiting_input` is not: it is blocked on a person, and supplying input writes the new projection into the cache and starts it again. Note that this does not gate the behaviour behind anything beyond the existing organizations:investigations flag -- every investigation created in a flagged org becomes agentic. Claude-Session: https://claude.ai/code/session_012CtaiBZtJdz8uMRtUgvbmv --- static/app/views/investigations/api.ts | 21 ++++++++---- .../app/views/investigations/detail/index.tsx | 4 +-- .../investigationHypotheses.spec.tsx | 33 +++++++++--------- .../hypotheses/investigationHypotheses.tsx | 34 ++++++++++++++----- .../app/views/investigations/index.spec.tsx | 5 ++- .../metricDetectorTriggeredSection.spec.tsx | 4 +-- 6 files changed, 65 insertions(+), 36 deletions(-) diff --git a/static/app/views/investigations/api.ts b/static/app/views/investigations/api.ts index 98525d8e46f5..d50b2f49c161 100644 --- a/static/app/views/investigations/api.ts +++ b/static/app/views/investigations/api.ts @@ -268,6 +268,14 @@ function useInvestigationMutation( }); } +/** + * Start an empty investigation. + * + * A `source` with no `templateKey` is what makes the server build an agentic + * run rather than a bare notebook, so this is the field that decides whether + * the investigation ever has hypotheses. A manual source carries no prompt yet, + * so the run opens `awaiting_input` and waits for one. + */ export function useCreateInvestigationMutation( organizationSlug: string, options?: MutationOptions @@ -280,7 +288,7 @@ export function useCreateInvestigationMutation( path: {organizationIdOrSlug: organizationSlug}, }), method: 'POST', - data: {title: 'Untitled investigation'}, + data: {title: 'Untitled investigation', source: {type: 'manual'}}, }), options ); @@ -298,11 +306,12 @@ export function useLaunchInvestigationMutation( path: {organizationIdOrSlug: organizationSlug}, }), method: 'POST', - data: { - templateKey: 'breached_metric', - templateVersion: 1, - source, - }, + // No `templateKey`: the metric snapshot is enough for the server to + // build an agentic run, which is what gives this investigation + // hypotheses instead of a fixed sequence of notebook cells. The + // candidates endpoint matches agentic and template lineage keys alike, + // so an already-investigated breach still resolves to "View". + data: {source}, }), options, {invalidateCandidates: true} diff --git a/static/app/views/investigations/detail/index.tsx b/static/app/views/investigations/detail/index.tsx index dd2a9170de8d..166d74a2816f 100644 --- a/static/app/views/investigations/detail/index.tsx +++ b/static/app/views/investigations/detail/index.tsx @@ -42,7 +42,7 @@ import { } from 'sentry/views/investigations/detail/cell'; import { InvestigationHypotheses, - isInvestigationRunSettled, + shouldPollInvestigationRun, } from 'sentry/views/investigations/hypotheses/investigationHypotheses'; import {updateInvestigationCache} from 'sentry/views/investigations/investigationCache'; import {InvestigationSummaryCard} from 'sentry/views/investigations/investigationSummaryCard'; @@ -94,7 +94,7 @@ export function InvestigationBootstrapPage({investigationId}: {investigationId: // row, so a stale copy would leave the row hidden or showing a run that // has since finished. const orchestrationActive = - data?.orchestration && !isInvestigationRunSettled(data.orchestration.status); + data?.orchestration && shouldPollInvestigationRun(data.orchestration.status); return orchestrationActive || shouldPollInvestigationBlocks(data?.blocks ?? []) || isTitleGenerationActive(data?.titleGeneration?.status) diff --git a/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx b/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx index ba80c33259b4..1f2e3ae932a8 100644 --- a/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx +++ b/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx @@ -7,7 +7,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import {InvestigationOrchestrationFixture} from 'sentry/views/investigations/fixtures'; import { InvestigationHypotheses, - isInvestigationRunSettled, + shouldPollInvestigationRun, } from 'sentry/views/investigations/hypotheses/investigationHypotheses'; import type {InvestigationOrchestration} from 'sentry/views/investigations/types'; @@ -25,6 +25,22 @@ function renderHypotheses() { }); } +describe('shouldPollInvestigationRun', () => { + it.each([ + ['pending', true], + ['processing', true], + [undefined, true], + // Blocked on a person, not on the agent. Every investigation created + // without a prompt starts here, so polling would never stop. + ['awaiting_input', false], + ['completed', false], + ['failed', false], + ['cancelled', false], + ] as const)('%s polls: %s', (status, expected) => { + expect(shouldPollInvestigationRun(status)).toBe(expected); + }); +}); + describe('InvestigationHypotheses', () => { it('renders the hypotheses carried on the projection', async () => { MockApiClient.addMockResponse({ @@ -261,18 +277,3 @@ describe('InvestigationHypotheses', () => { expect(screen.getAllByText('Formed')).toHaveLength(3); }); }); - -describe('isInvestigationRunSettled', () => { - it.each([ - ['completed', true], - ['failed', true], - ['cancelled', true], - // Not terminal: the run resumes as soon as input arrives, possibly from - // another surface, so the projection has to keep being read. - ['awaiting_input', false], - ['processing', false], - ['pending', false], - ] as const)('reads %s as %s', (status, expected) => { - expect(isInvestigationRunSettled(status)).toBe(expected); - }); -}); diff --git a/static/app/views/investigations/hypotheses/investigationHypotheses.tsx b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx index a05d60c32d01..ca053ca9602d 100644 --- a/static/app/views/investigations/hypotheses/investigationHypotheses.tsx +++ b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx @@ -38,18 +38,34 @@ const POLL_INTERVAL_MS = 2000; const COMMAND_SETTLE_MS = 30_000; /** - * Whether a workflow has stopped moving on its own. + * Statuses where the agent is not going to move on its own. The first three + * have stopped for good; `awaiting_input` has stopped recoverably, blocked on a + * person. + */ +const STOPPED_STATUSES = new Set([ + 'completed', + 'failed', + 'cancelled', + 'awaiting_input', +]); + +/** + * Whether a run is still advancing, and so worth polling. + * + * `awaiting_input` counts as stopped even though it can resume: an + * investigation created without a prompt starts there and stays there until + * someone supplies one, so polling it would be a permanent two-second request + * loop on a run nobody is driving. Supplying input from this client writes the + * new projection straight into the cache, which starts it again. * - * `awaiting_input` is deliberately not terminal: the run resumes as soon as - * input arrives, which may happen from another surface, so polling has to - * continue. Exported because the detail view decides from the summary served - * alongside the investigation, and this component from the full projection — - * the same three statuses either way. + * Exported because the detail view decides from the summary served alongside + * the investigation and this component from the full projection — the same + * statuses either way. */ -export function isInvestigationRunSettled( +export function shouldPollInvestigationRun( status: InvestigationOrchestrationStatus | undefined ): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; + return status === undefined || !STOPPED_STATUSES.has(status); } type InvestigationHypothesesProps = { @@ -89,7 +105,7 @@ export function InvestigationHypotheses({ ...investigationOrchestrationQueryOptions(organization.slug, investigationId), enabled, refetchInterval: query => { - if (!isInvestigationRunSettled(query.state.data?.json.status)) { + if (shouldPollInvestigationRun(query.state.data?.json.status)) { return POLL_INTERVAL_MS; } const waitingOnCommand = diff --git a/static/app/views/investigations/index.spec.tsx b/static/app/views/investigations/index.spec.tsx index 3e01d7f0c84a..1acce5c09538 100644 --- a/static/app/views/investigations/index.spec.tsx +++ b/static/app/views/investigations/index.spec.tsx @@ -231,7 +231,10 @@ describe('Explore Investigations', () => { await waitFor(() => expect(createRequest).toHaveBeenCalledWith( listUrl, - expect.objectContaining({data: {title: 'Untitled investigation'}}) + expect.objectContaining({ + // A source with no templateKey is what makes this agentic. + data: {title: 'Untitled investigation', source: {type: 'manual'}}, + }) ) ); expect(await screen.findByText('Untitled investigation')).toBeInTheDocument(); diff --git a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx index b1f4f94edcd5..8c645f85bf31 100644 --- a/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx +++ b/static/app/views/issueDetails/sidebar/metricDetectorTriggeredSection.spec.tsx @@ -293,9 +293,9 @@ describe('MetricDetectorTriggeredSection', () => { expect(launchMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ + // No templateKey: the server builds an agentic run from the metric + // snapshot rather than a fixed notebook. data: { - templateKey: 'breached_metric', - templateVersion: 1, source: { type: 'metric_open_period', ref: {groupId: defaultGroup.id, openPeriodId: '101'}, From 248163cd858166433a37f1b8d366459bc235c9ce Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 15 Sep 2026 11:18:46 -0400 Subject: [PATCH 2/2] fix(investigations): Keep polling while a run is still being created A review-bot finding on the polling predicate this commit introduced. Sentry parks a brand-new run at awaiting_input before it has finished creating it in Seer: the create is dispatched after the transaction commits, and until it lands the run carries no Seer id. Treating that status as stopped meant the queries went quiet during exactly the window where the create can still fail the run or rewrite the projection, leaving the UI on awaiting input until a reload. The predicate now takes whether the Seer run exists, so a placeholder awaiting_input keeps being read while a genuine one -- blocked on a person -- still does not. A create that fails leaves no Seer id behind but flips the run to failed, which is terminal, so the missing id cannot restart polling on a stopped run. The detail view reads a summary that carries no run id, so it keeps the blocked-on-a-person reading. Claude-Session: https://claude.ai/code/session_014zh69vex76pjNTnarqVcXL --- .../investigationHypotheses.spec.tsx | 14 +++++ .../hypotheses/investigationHypotheses.tsx | 52 +++++++++++-------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx b/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx index 1f2e3ae932a8..73166fe473c2 100644 --- a/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx +++ b/static/app/views/investigations/hypotheses/investigationHypotheses.spec.tsx @@ -39,6 +39,20 @@ describe('shouldPollInvestigationRun', () => { ] as const)('%s polls: %s', (status, expected) => { expect(shouldPollInvestigationRun(status)).toBe(expected); }); + + it.each([ + // A run parked at `awaiting_input` with no Seer id yet has not been created + // in Seer: the dispatch runs after the commit, and it can still rewrite the + // projection or fail the run, so the placeholder status must keep polling. + ['awaiting_input', false, true], + // Once the run exists, the same status really does mean blocked on a person. + ['awaiting_input', true, false], + // A create that failed leaves no Seer id behind, but the run has stopped for + // good — the missing id must not restart polling. + ['failed', false, false], + ] as const)('%s with hasSeerRun %s polls: %s', (status, hasSeerRun, expected) => { + expect(shouldPollInvestigationRun(status, hasSeerRun)).toBe(expected); + }); }); describe('InvestigationHypotheses', () => { diff --git a/static/app/views/investigations/hypotheses/investigationHypotheses.tsx b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx index ca053ca9602d..7c662f5bce27 100644 --- a/static/app/views/investigations/hypotheses/investigationHypotheses.tsx +++ b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx @@ -37,35 +37,40 @@ const POLL_INTERVAL_MS = 2000; */ const COMMAND_SETTLE_MS = 30_000; -/** - * Statuses where the agent is not going to move on its own. The first three - * have stopped for good; `awaiting_input` has stopped recoverably, blocked on a - * person. - */ -const STOPPED_STATUSES = new Set([ - 'completed', - 'failed', - 'cancelled', - 'awaiting_input', -]); +/** Statuses the agent will never move out of on its own. */ +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); /** - * Whether a run is still advancing, and so worth polling. + * Whether the projection is still worth re-reading. + * + * `awaiting_input` is the subtle one. A run blocked on a person does not move + * until someone supplies a prompt, and an investigation created without one + * starts there and stays there, so polling it would be a permanent two-second + * request loop on a run nobody is driving. Supplying input from this client + * writes the new projection straight into the cache, which starts it again. * - * `awaiting_input` counts as stopped even though it can resume: an - * investigation created without a prompt starts there and stays there until - * someone supplies one, so polling it would be a permanent two-second request - * loop on a run nobody is driving. Supplying input from this client writes the - * new projection straight into the cache, which starts it again. + * But Sentry parks a brand-new run at `awaiting_input` *before* it has finished + * creating it in Seer: the create is dispatched after the transaction commits, + * and until it lands the run carries no Seer id. In that window the status is a + * placeholder rather than a decision to wait for a person, and the create can + * still fail the run or rewrite the projection underneath it — so it has to + * keep being read. `hasSeerRun` is how a caller says which of the two it is. * - * Exported because the detail view decides from the summary served alongside - * the investigation and this component from the full projection — the same - * statuses either way. + * Callers that cannot tell leave it alone and get the blocked-on-a-person + * reading: the detail view decides from the summary served alongside the + * investigation, and that summary carries no run id. */ export function shouldPollInvestigationRun( - status: InvestigationOrchestrationStatus | undefined + status: InvestigationOrchestrationStatus | undefined, + hasSeerRun = true ): boolean { - return status === undefined || !STOPPED_STATUSES.has(status); + if (status === undefined) { + return true; + } + if (TERMINAL_STATUSES.has(status)) { + return false; + } + return status === 'awaiting_input' ? !hasSeerRun : true; } type InvestigationHypothesesProps = { @@ -105,7 +110,8 @@ export function InvestigationHypotheses({ ...investigationOrchestrationQueryOptions(organization.slug, investigationId), enabled, refetchInterval: query => { - if (shouldPollInvestigationRun(query.state.data?.json.status)) { + const run = query.state.data?.json; + if (shouldPollInvestigationRun(run?.status, run?.runId !== null)) { return POLL_INTERVAL_MS; } const waitingOnCommand =