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..73166fe473c2 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,36 @@ 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); + }); + + 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', () => { it('renders the hypotheses carried on the projection', async () => { MockApiClient.addMockResponse({ @@ -261,18 +291,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..7c662f5bce27 100644 --- a/static/app/views/investigations/hypotheses/investigationHypotheses.tsx +++ b/static/app/views/investigations/hypotheses/investigationHypotheses.tsx @@ -37,19 +37,40 @@ const POLL_INTERVAL_MS = 2000; */ const COMMAND_SETTLE_MS = 30_000; +/** Statuses the agent will never move out of on its own. */ +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); + /** - * Whether a workflow has stopped moving on its own. + * 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. + * + * 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. * - * `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. + * 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 isInvestigationRunSettled( - status: InvestigationOrchestrationStatus | undefined +export function shouldPollInvestigationRun( + status: InvestigationOrchestrationStatus | undefined, + hasSeerRun = true ): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; + if (status === undefined) { + return true; + } + if (TERMINAL_STATUSES.has(status)) { + return false; + } + return status === 'awaiting_input' ? !hasSeerRun : true; } type InvestigationHypothesesProps = { @@ -89,7 +110,8 @@ export function InvestigationHypotheses({ ...investigationOrchestrationQueryOptions(organization.slug, investigationId), enabled, refetchInterval: query => { - if (!isInvestigationRunSettled(query.state.data?.json.status)) { + const run = query.state.data?.json; + if (shouldPollInvestigationRun(run?.status, run?.runId !== null)) { 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'},