diff --git a/dashboard/src/v2/pages/stats/__tests__/use-system-view-data.test.tsx b/dashboard/src/v2/pages/stats/__tests__/use-system-view-data.test.tsx index 8099d50a22..8c523c7501 100644 --- a/dashboard/src/v2/pages/stats/__tests__/use-system-view-data.test.tsx +++ b/dashboard/src/v2/pages/stats/__tests__/use-system-view-data.test.tsx @@ -2,17 +2,61 @@ * @vitest-environment jsdom */ import { act, renderHook, waitFor } from "@testing-library/preact"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DashboardRealtimeServerMessage } from "../../../../types.js"; import type { ExecutionInvocationRecord } from "../../../types.js"; +import { subscribeToDashboardRealtime } from "../../../../lib/realtime/dashboard-realtime-client.js"; import { fetchProjectInvocations } from "../../../lib/invocation-api.js"; import { useSystemViewData } from "../hooks/use-system-view-data.js"; +vi.mock("../../../../lib/realtime/dashboard-realtime-client.js", () => ({ + subscribeToDashboardRealtime: vi.fn(), +})); + vi.mock("../../../lib/invocation-api.js", () => ({ fetchProjectInvocations: vi.fn(), })); +const mockedSubscribeToDashboardRealtime = vi.mocked(subscribeToDashboardRealtime); const mockedFetchProjectInvocations = vi.mocked(fetchProjectInvocations); +const getRealtimeListener = (): ((message: DashboardRealtimeServerMessage) => void) => { + const subscriptionCall = mockedSubscribeToDashboardRealtime.mock.calls.at(-1); + if (!subscriptionCall) { + throw new Error("Expected a dashboard realtime subscription"); + } + return subscriptionCall[1]; +}; + +const createExecutionEvent = (projectId = "project-1"): DashboardRealtimeServerMessage => ({ + type: "event", + event: { + sequence: 1, + emittedAt: "2026-06-01T10:06:00.000Z", + scopeType: "project", + scopeId: projectId, + scope: `project:${projectId}`, + eventType: "project.execution.updated", + entityType: "project", + entityId: projectId, + projectId, + sprintId: null, + threadId: null, + taskId: null, + dispatchId: null, + sprintRunId: null, + taskRunId: null, + connectionId: null, + correlationId: null, + payload: null, + }, +}); + +const snapshotRequiredMessage: DashboardRealtimeServerMessage = { + type: "snapshot_required", + reason: "replay_gap", +}; + const createInvocation = (overrides: Partial): ExecutionInvocationRecord => ({ id: "inv-1", projectId: "project-1", @@ -55,6 +99,12 @@ const createInvocation = (overrides: Partial): Execut describe("useSystemViewData", () => { beforeEach(() => { mockedFetchProjectInvocations.mockReset(); + mockedSubscribeToDashboardRealtime.mockReset(); + mockedSubscribeToDashboardRealtime.mockImplementation(() => vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); }); it("returns the documented view model shape", async () => { @@ -169,6 +219,67 @@ describe("useSystemViewData", () => { expect(result.current.summaryMetrics.completedCount).toBe(135); }); + it.each([ + ["project.execution.updated", createExecutionEvent()], + ["snapshot_required", snapshotRequiredMessage], + ])("refetches the current ledger query for %s", async (_eventType, message) => { + (mockedFetchProjectInvocations as any) + .mockResolvedValueOnce([createInvocation({ id: "inv-before" })]) + .mockResolvedValueOnce([createInvocation({ id: "inv-after" })]); + + const { result } = renderHook(() => useSystemViewData("project-1")); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(mockedSubscribeToDashboardRealtime).toHaveBeenCalledWith( + ["project:project-1"], + expect.any(Function), + ); + + vi.useFakeTimers(); + act(() => { + getRealtimeListener()(message); + vi.advanceTimersByTime(150); + }); + vi.useRealTimers(); + + await waitFor(() => { + expect(mockedFetchProjectInvocations).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + }); + expect(result.current.invocations[0]?.id).toBe("inv-after"); + }); + + it("coalesces bursts of execution and snapshot invalidations into one refetch", async () => { + (mockedFetchProjectInvocations as any).mockResolvedValue([createInvocation({ id: "inv-1" })]); + const { result } = renderHook(() => useSystemViewData("project-1")); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + vi.useFakeTimers(); + act(() => { + const listener = getRealtimeListener(); + listener(createExecutionEvent()); + listener(snapshotRequiredMessage); + listener(createExecutionEvent()); + vi.advanceTimersByTime(149); + }); + expect(mockedFetchProjectInvocations).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(1); + }); + vi.useRealTimers(); + + await waitFor(() => { + expect(mockedFetchProjectInvocations).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + }); + }); + it("filters invocations by failed status", async () => { (mockedFetchProjectInvocations as any).mockResolvedValue([ createInvocation({ id: "inv-1", status: "completed", type: "analysis", provider: "gemini" }), @@ -228,55 +339,147 @@ describe("useSystemViewData", () => { expect(result.current.errorsByCategory.cancelled).toBe(1); }); - it("suppresses stale responses using AbortController", async () => { - let resolveFirstRequest: any; - let resolveSecondRequest: any; - - const promise1 = new Promise((resolve) => { + it("aborts a superseded request and rejects its stale response when abort is ignored", async () => { + let resolveFirstRequest!: (value: ExecutionInvocationRecord[]) => void; + let resolveSecondRequest!: (value: ExecutionInvocationRecord[]) => void; + const firstRequest = new Promise((resolve) => { resolveFirstRequest = resolve; }); - const promise2 = new Promise((resolve) => { + const secondRequest = new Promise((resolve) => { resolveSecondRequest = resolve; }); (mockedFetchProjectInvocations as any) - .mockReturnValueOnce(promise1) - .mockReturnValueOnce(promise2); + .mockReturnValueOnce(firstRequest) + .mockReturnValueOnce(secondRequest); const { result } = renderHook(() => useSystemViewData("project-1")); - // First request is pending. Trigger a search change to cause a second request. + await waitFor(() => { + expect(mockedFetchProjectInvocations).toHaveBeenCalledTimes(1); + expect(mockedSubscribeToDashboardRealtime).toHaveBeenCalledTimes(1); + }); + const firstSignal = mockedFetchProjectInvocations.mock.calls[0]?.[2]?.signal; + + vi.useFakeTimers(); act(() => { - result.current.setSearch("new search"); + getRealtimeListener()(createExecutionEvent()); + vi.advanceTimersByTime(150); }); + vi.useRealTimers(); - // The second request is now pending, and the first request's AbortController should have aborted it. - // However, since we mock fetchProjectInvocations, we just resolve the first one and verify it's ignored. + await waitFor(() => { + expect(mockedFetchProjectInvocations).toHaveBeenCalledTimes(2); + }); + expect(firstSignal?.aborted).toBe(true); - const abortError = new Error("aborted"); - abortError.name = "AbortError"; + await act(async () => { + resolveSecondRequest([createInvocation({ id: "inv-newest" })]); + }); + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.invocations[0]?.id).toBe("inv-newest"); + }); await act(async () => { - // Simulate fetch rejecting due to abort - try { - resolveFirstRequest(Promise.reject(abortError)); - } catch (e) {} - - resolveSecondRequest({ - items: [ - createInvocation({ id: "inv-second", status: "completed", type: "analysis", provider: "gemini" }) - ], - totalCount: 1, + resolveFirstRequest([createInvocation({ id: "inv-stale" })]); + }); + expect(result.current.invocations[0]?.id).toBe("inv-newest"); + expect(result.current.error).toBeNull(); + }); + + it("preserves rows, filters, sorting, search, and the current page during realtime refresh", async () => { + const currentPageResponse = { + items: [createInvocation({ id: "inv-current-page" })], + totalCount: 500, + } as any; + mockedFetchProjectInvocations.mockResolvedValue(currentPageResponse); + + const { result } = renderHook(() => useSystemViewData("project-1")); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.setSearch("telemetry"); + result.current.setFilters({ + status: ["failed"], + purpose: ["analysis"], + provider: ["codex"], + errorCategories: ["timeout", "rateLimit"], }); + result.current.setSort({ key: "totalTokens", dir: "asc" }); + }); + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.page).toBe(0); }); + act(() => { + result.current.setPage(2); + }); await waitFor(() => { expect(result.current.loading).toBe(false); + expect(mockedFetchProjectInvocations).toHaveBeenLastCalledWith( + "project-1", + expect.objectContaining({ offset: 200 }), + expect.any(Object), + ); }); - expect(result.current.invocations).toHaveLength(1); - expect(result.current.invocations[0].id).toBe("inv-second"); - expect(result.current.error).toBeNull(); // AbortError shouldn't set error + let resolveRefresh!: (value: typeof currentPageResponse) => void; + mockedFetchProjectInvocations.mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + const callCountBeforeRefresh = mockedFetchProjectInvocations.mock.calls.length; + + vi.useFakeTimers(); + act(() => { + getRealtimeListener()(createExecutionEvent()); + vi.advanceTimersByTime(150); + }); + vi.useRealTimers(); + + await waitFor(() => { + expect(mockedFetchProjectInvocations).toHaveBeenCalledTimes(callCountBeforeRefresh + 1); + expect(result.current.loading).toBe(true); + }); + expect(result.current.invocations[0]?.id).toBe("inv-current-page"); + expect(result.current.page).toBe(2); + expect(result.current.search).toBe("telemetry"); + expect(result.current.filters).toEqual({ + status: ["failed"], + purpose: ["analysis"], + provider: ["codex"], + errorCategories: ["timeout", "rateLimit"], + }); + expect(result.current.sort).toEqual({ key: "totalTokens", dir: "asc" }); + expect(mockedFetchProjectInvocations).toHaveBeenLastCalledWith( + "project-1", + { + limit: 100, + offset: 200, + search: "telemetry", + sortKey: "totalTokens", + sortDir: "asc", + status: "failed", + purpose: "analysis", + provider: "codex", + errorCategories: ["timeout", "rateLimit"], + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + + await act(async () => { + resolveRefresh({ + ...currentPageResponse, + items: [createInvocation({ id: "inv-refreshed-page" })], + }); + }); + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.invocations[0]?.id).toBe("inv-refreshed-page"); + }); }); it("resets page to 0 on filter, search, or sort change", async () => { diff --git a/dashboard/src/v2/pages/stats/hooks/use-system-view-data.ts b/dashboard/src/v2/pages/stats/hooks/use-system-view-data.ts index 5874ff05ef..ad962698f6 100644 --- a/dashboard/src/v2/pages/stats/hooks/use-system-view-data.ts +++ b/dashboard/src/v2/pages/stats/hooks/use-system-view-data.ts @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import type { DashboardRealtimeServerMessage } from "../../../../types.js"; +import { subscribeToDashboardRealtime } from "../../../../lib/realtime/dashboard-realtime-client.js"; import { fetchProjectInvocations } from "../../../lib/invocation-api.js"; import type { ExecutionInvocationRecord, ExecutionInvocationStatus, ProjectInvocationsQuery, ProjectInvocationsQueryResult } from "../../../types.js"; @@ -67,6 +69,8 @@ const EMPTY_FILTERS: SystemFilters = { errorCategories: [], }; +const REALTIME_REFRESH_DEBOUNCE_MS = 150; + const normalizeText = (value: string | null | undefined): string => (value || "").trim().toLowerCase(); const getNumericValue = (value: number | null | undefined): number => (typeof value === "number" && Number.isFinite(value) ? value : 0); @@ -411,6 +415,8 @@ export function useSystemViewData(projectId: string) { const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const [page, setPage] = useState(0); + const requestSequenceRef = useRef(0); + const realtimeRefreshTimerRef = useRef | null>(null); const pageSize = 100; useEffect(() => { @@ -419,6 +425,7 @@ export function useSystemViewData(projectId: string) { useEffect(() => { if (!projectId) { + requestSequenceRef.current += 1; setLegacyAllInvocations(null); setServerResult(null); setLoading(false); @@ -430,6 +437,7 @@ export function useSystemViewData(projectId: string) { setError(null); const controller = new AbortController(); + const requestSequence = ++requestSequenceRef.current; const query: ProjectInvocationsQuery = { limit: pageSize, @@ -445,6 +453,9 @@ export function useSystemViewData(projectId: string) { void fetchProjectInvocations(projectId, query, { signal: controller.signal }) .then((response: ExecutionInvocationRecord[] | ProjectInvocationsQueryResult) => { + if (controller.signal.aborted || requestSequence !== requestSequenceRef.current) { + return; + } if (Array.isArray(response)) { setLegacyAllInvocations(response); setServerResult(null); @@ -455,7 +466,11 @@ export function useSystemViewData(projectId: string) { setLoading(false); }) .catch((fetchError: unknown) => { - if (fetchError instanceof Error && fetchError.name === "AbortError") { + if ( + controller.signal.aborted + || requestSequence !== requestSequenceRef.current + || (fetchError instanceof Error && fetchError.name === "AbortError") + ) { return; } setError(fetchError instanceof Error ? fetchError.message : String(fetchError)); @@ -518,6 +533,42 @@ export function useSystemViewData(projectId: string) { setRefreshKey((current) => current + 1); }, []); + useEffect(() => { + if (!projectId) { + return; + } + + const scheduleRealtimeRefresh = (): void => { + if (realtimeRefreshTimerRef.current !== null) { + globalThis.clearTimeout(realtimeRefreshTimerRef.current); + } + realtimeRefreshTimerRef.current = globalThis.setTimeout(() => { + realtimeRefreshTimerRef.current = null; + refetch(); + }, REALTIME_REFRESH_DEBOUNCE_MS); + }; + + const unsubscribe = subscribeToDashboardRealtime( + [`project:${projectId}`], + (message: DashboardRealtimeServerMessage) => { + if ( + message.type === "snapshot_required" + || (message.type === "event" && message.event.eventType === "project.execution.updated") + ) { + scheduleRealtimeRefresh(); + } + }, + ); + + return () => { + unsubscribe(); + if (realtimeRefreshTimerRef.current !== null) { + globalThis.clearTimeout(realtimeRefreshTimerRef.current); + realtimeRefreshTimerRef.current = null; + } + }; + }, [projectId, refetch]); + const hasMore = serverResult !== null ? (page * pageSize + serverResult.items.length < serverResult.totalCount) : false; const totalCount = serverResult !== null ? serverResult.totalCount : (legacyAllInvocations?.length || 0); diff --git a/docs-web/architecture/execution-invocation-tracking.md b/docs-web/architecture/execution-invocation-tracking.md index d4e6240692..86da7ea162 100644 --- a/docs-web/architecture/execution-invocation-tracking.md +++ b/docs-web/architecture/execution-invocation-tracking.md @@ -2,6 +2,19 @@ Code UX records provider work in `execution_invocations` and `execution_invocation_messages` so the dashboard can show prompt history, live agent transcripts, tool activity, token usage, and terminal status for each provider-backed run. +## CLI task-coding lifecycle + +CLI task coding uses two durable records with different responsibilities: + +- The execution invocation covers the complete Code UX workflow, including cancellable workspace/provider preparation, provider execution, Git finalization, and pull-request finalization. It becomes visible after cancellation registration and before preparation begins, so an early running row means “workflow in progress,” not “provider usage started.” +- The provider invocation is created and linked only when Code UX atomically claims provider capacity. Its `started_at`, duration, concurrency occupancy, and token/tool telemetry begin at claim/run time. Preparation never creates a provider usage placeholder. + +The workflow remains truthful on every exit path. Preparation failures close the execution invocation without provider usage. Pre-claim cancellation aborts preparation or capacity waiting and records no provider duration or tokens; post-claim cancellation closes the linked provider usage independently. Runtime shutdown preserves eligible workspace state for startup reconciliation instead of inventing a provider result. + +A resumed preserved workspace receives a new workflow execution record. When recovery proves provider work already completed in that workspace, Code UX skips a second provider call and resumes Git/PR finalization without duplicating usage. Otherwise the resumed attempt claims a new provider row. Each actual provider claim has its own usage record; the execution link is absent before claim, never represents preparation, and is not shared as unrelated execution accounting. Distinct retried claims keep distinct provider usage history. + +Provider completion does not finish the workflow row by itself. The CLI workflow owns the execution invocation's terminal state because Git and PR work can continue after provider capacity and usage accounting have ended. + Provider transcript parsing is intentionally provider-specific at the edge and normalized before persistence. The shared boundary is `ParsedConversationTurn` from `src/infrastructure/providers/cli/provider-logs/provider-conversation-types.ts`, and persistence maps those turns through `src/services/provider-conversation-message-mapper.ts`. That mapper keeps the existing message-role contract: readable reasoning becomes an assistant message marked with `metadata.kind = "reasoning"`, injected context becomes a system message, and tool calls/results become tool messages with capped payloads. Provider/model identity, call ids, status, timestamps, and per-turn token evidence remain in message metadata when available. @@ -52,6 +65,12 @@ Final post-process usage collection remains authoritative. Live telemetry is bes Jules remains outside this local CLI parser and watcher path. Its remote session synchronizer records its transcript separately and derives estimated usage from accumulated input/output characters; Code UX does not describe those estimates as provider-native token telemetry. +## Dashboard and recovery behavior + +Chat's Invocations rail is server-authoritative. It reads the paginated `GET /api/projects/:projectId/execution/invocations` projection; `project.execution.updated` and `snapshot_required` trigger REST refetches for the list and selected transcript instead of creating browser-only invocation rows. + +Startup recovery reconciles stale workflow and provider rows from durable task-run, sprint-run, dispatch, process, and Docker-container evidence. Preparation-only rows can fail without provider linkage, terminal provider rows are reconciled without extending their usage window, and a recovered completed provider attempt may continue from its preserved workspace without a duplicate provider run. + ## Focused verification Use focused tests for parser, telemetry, and persistence changes: diff --git a/docs-web/content/docs/architecture-execution-invocation-tracking.mdx b/docs-web/content/docs/architecture-execution-invocation-tracking.mdx index d4e6240692..86da7ea162 100644 --- a/docs-web/content/docs/architecture-execution-invocation-tracking.mdx +++ b/docs-web/content/docs/architecture-execution-invocation-tracking.mdx @@ -2,6 +2,19 @@ Code UX records provider work in `execution_invocations` and `execution_invocation_messages` so the dashboard can show prompt history, live agent transcripts, tool activity, token usage, and terminal status for each provider-backed run. +## CLI task-coding lifecycle + +CLI task coding uses two durable records with different responsibilities: + +- The execution invocation covers the complete Code UX workflow, including cancellable workspace/provider preparation, provider execution, Git finalization, and pull-request finalization. It becomes visible after cancellation registration and before preparation begins, so an early running row means “workflow in progress,” not “provider usage started.” +- The provider invocation is created and linked only when Code UX atomically claims provider capacity. Its `started_at`, duration, concurrency occupancy, and token/tool telemetry begin at claim/run time. Preparation never creates a provider usage placeholder. + +The workflow remains truthful on every exit path. Preparation failures close the execution invocation without provider usage. Pre-claim cancellation aborts preparation or capacity waiting and records no provider duration or tokens; post-claim cancellation closes the linked provider usage independently. Runtime shutdown preserves eligible workspace state for startup reconciliation instead of inventing a provider result. + +A resumed preserved workspace receives a new workflow execution record. When recovery proves provider work already completed in that workspace, Code UX skips a second provider call and resumes Git/PR finalization without duplicating usage. Otherwise the resumed attempt claims a new provider row. Each actual provider claim has its own usage record; the execution link is absent before claim, never represents preparation, and is not shared as unrelated execution accounting. Distinct retried claims keep distinct provider usage history. + +Provider completion does not finish the workflow row by itself. The CLI workflow owns the execution invocation's terminal state because Git and PR work can continue after provider capacity and usage accounting have ended. + Provider transcript parsing is intentionally provider-specific at the edge and normalized before persistence. The shared boundary is `ParsedConversationTurn` from `src/infrastructure/providers/cli/provider-logs/provider-conversation-types.ts`, and persistence maps those turns through `src/services/provider-conversation-message-mapper.ts`. That mapper keeps the existing message-role contract: readable reasoning becomes an assistant message marked with `metadata.kind = "reasoning"`, injected context becomes a system message, and tool calls/results become tool messages with capped payloads. Provider/model identity, call ids, status, timestamps, and per-turn token evidence remain in message metadata when available. @@ -52,6 +65,12 @@ Final post-process usage collection remains authoritative. Live telemetry is bes Jules remains outside this local CLI parser and watcher path. Its remote session synchronizer records its transcript separately and derives estimated usage from accumulated input/output characters; Code UX does not describe those estimates as provider-native token telemetry. +## Dashboard and recovery behavior + +Chat's Invocations rail is server-authoritative. It reads the paginated `GET /api/projects/:projectId/execution/invocations` projection; `project.execution.updated` and `snapshot_required` trigger REST refetches for the list and selected transcript instead of creating browser-only invocation rows. + +Startup recovery reconciles stale workflow and provider rows from durable task-run, sprint-run, dispatch, process, and Docker-container evidence. Preparation-only rows can fail without provider linkage, terminal provider rows are reconciled without extending their usage window, and a recovered completed provider attempt may continue from its preserved workspace without a duplicate provider run. + ## Focused verification Use focused tests for parser, telemetry, and persistence changes: diff --git a/docs-web/content/docs/architecture-sprint-rollbacks.mdx b/docs-web/content/docs/architecture-sprint-rollbacks.mdx deleted file mode 100644 index 383f362a14..0000000000 --- a/docs-web/content/docs/architecture-sprint-rollbacks.mdx +++ /dev/null @@ -1,24 +0,0 @@ -# Sprint Rollbacks - -Code UX models a rollback as a new sprint, not as destructive history editing. The original sprint remains auditable, while the rollback receives its own branch, tasks, execution history, and visual identity. Remote projects deliver the branch through a pull request; local projects merge it locally without creating one. - -## Choosing the execution path - -Before creation, Code UX checks the completed source sprint, Git mode, later sprint activity, and the source merge at the tip of the default branch. - -- **Automatic rollback** is offered only for a proven isolated latest merge with no later sprint work. Code UX reverts that merge in a detached worktree and enforces a hard no-dispatch boundary for its settled audit task. Remote mode pushes the dedicated rollback branch and completes it through a green pull request. Local mode keeps the branch local and merges it into the configured default branch without a pull request. -- **Agent-assisted rollback** is used when later work may depend on the source, merge history is ambiguous, a deterministic revert conflicts, or you enter custom instructions. - -Entering instructions always selects the agent path. This is how you request a partial rollback such as “remove only feature XY but keep the migration.” The agent is told to inspect dependencies, preserve compatible work, and update tests. It pushes only in remote mode; local mode commits to the rollback branch without remote access. - -## Delivery by Git mode - -Remote rollback sprints force live PR tracking even when ordinary sprint PR monitoring is disabled. Automatic rollbacks use green-check auto-merge; agent-assisted rollbacks retain the configured merge policy, with `OFF` promoted to `CREATE_PR`. A remote rollback sprint completes only after the Git host reports the PR merged. - -Local rollback sprints do not fetch, push, or create a PR. Code UX uses its standard isolated local finalization worktree to merge the rollback branch into the configured default branch, preserving a dirty visible checkout and surfacing merge conflicts through attention handling. The sprint completes only after the local merge succeeds. - -## Dashboard identity - -Rollback gallery cells and ledger rows use an orange treatment, a rollback badge, and distinct action copy. Their normal run status, task progress, CI state, review state, and human-attention indicators remain visible. - -See [Sprints](/docs/user-dashboard-sprints) for the operator workflow. diff --git a/docs-web/content/docs/architecture-virtual-workers.mdx b/docs-web/content/docs/architecture-virtual-workers.mdx index f793f6a693..856812b892 100644 --- a/docs-web/content/docs/architecture-virtual-workers.mdx +++ b/docs-web/content/docs/architecture-virtual-workers.mdx @@ -124,7 +124,7 @@ Each dispatch operates on its own Git worktree under `/.worktrees/` for the explicit sprint feature branch or the effective runtime git default branch, never the host repo's current checkout. If that remote tracking ref or fallback cannot be prepared, planning fails instead of falling back to a stale local branch. In `LOCAL` git mode, sprint branch allocation and preflight use local heads only and never fetch, inspect, fast-forward from, or push to `origin`, even if the repository has a remote configured. Restart and Continue reuse the preserved snapshot workspace so cancelled or interrupted provider sessions can still resume. +Docker-backed planning uses a read-only snapshot workspace instead of a mutable task worktree. In `REMOTE` git mode, fresh planning invocations refresh `origin` and check out only `origin/` for the explicit sprint feature branch or the effective runtime git default branch, never the host repo's current checkout. If that remote tracking ref or fallback cannot be prepared, planning fails instead of falling back to a stale local branch. Restart and Continue reuse the preserved snapshot workspace so cancelled or interrupted provider sessions can still resume. Provider CLI workspace preparation is centralized through `InvocationWorkspacePreparer`. Its shared provider-invocation option builder constructs snapshot checkout, git policy, and fresh/continue lifecycle values for Docker provider calls, while its continuation resolver locates preserved workspaces and their current branches. Fresh Docker invocations in `REMOTE` git mode use explicit remote refs only: planning, project setup, dashboard/chat replies, worker inbox replies, node-flow provider prompts, QA review snapshots, task coding, QA follow-up, CI autofix, and merge-conflict repair all materialize from `origin/` refs rather than local branches or the host repo's current checkout. Dashboard/chat replies resolve dashboard settings with the project scope before building this policy, so local Git projects keep `LOCAL` snapshot behavior and do not require `origin/`. Continuation/restart flows may reuse a preserved workspace for provider-session continuity; if a preserved workspace is missing and a new workspace must be materialized, the same remote-only branch policy applies. diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index 4878ebd1bf..1ead876cae 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -52,8 +52,6 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | `GET` | `/api/projects/:projectId/sprints` | List. | | `POST` | `/api/projects/:projectId/sprints` | Create. | | `PATCH` | `/api/sprints/:sprintId` | Update. | -| `POST` | `/api/sprints/:sprintId/complete` | Runtime-aware manual completion; force-cancels an active sprint run before persisting `completed`. | -| `POST` | `/api/sprints/:sprintId/qa-pass` | Record a manual sprint-level QA pass and resolve its matching sprint QA handoff. | | `DELETE` | `/api/sprints/:sprintId` | Delete. | | `POST` | `/api/projects/:projectId/sprints/import` | Import from a markdown bundle. | | `GET` | `/api/projects/:projectId/sprints/:sprintId/export` | Export as a markdown bundle. | diff --git a/docs-web/content/docs/developer-websocket-realtime.mdx b/docs-web/content/docs/developer-websocket-realtime.mdx index f7cff76077..ab2b5f09d2 100644 --- a/docs-web/content/docs/developer-websocket-realtime.mdx +++ b/docs-web/content/docs/developer-websocket-realtime.mdx @@ -70,6 +70,16 @@ The official client (`dashboard/src/lib/realtime/dashboard-realtime-client.ts`) The official client dispatches at most one `snapshot_required` notification every 3 seconds. Resource controllers also coalesce their silent REST refetches so one recovery handshake does not produce a refresh storm. +## Dashboard resource invalidation + +Realtime events signal freshness; the database-backed REST projections remain authoritative: + +- Chat loads its invocation rail from paginated `GET /api/projects/:projectId/execution/invocations`. On `project.execution.updated`, it refetches that server-owned list and the selected transcript instead of inserting invocation rows from the event payload. On `snapshot_required`, it also refreshes the active project's thread, connection, and selected-detail resources. +- Stats treats `project.execution.updated` and `snapshot_required` as invalidations for both `GET /api/projects/:projectId/stats` and its independent paginated System invocation query. Cached analytics stay visible during the silent/debounced refetch. +- Live continues to consume the heavier `project.live.updated` snapshot. Steady-state publication of that snapshot has a five-second minimum interval; urgent lifecycle refreshes may bypass it. The heavy Live throttle does not govern the lighter Chat or Stats invalidation paths. + +Clients should preserve these read-model boundaries: do not infer provider usage from an early workflow execution row, and do not construct invocation records locally when the server projection can be refetched. + ## Fallback to polling If the WebSocket connection cannot be established, consumers continue using their resource-specific REST snapshots. Common examples are `GET /api/live?projectId=:id`, `GET /api/projects/:id/execution`, `GET /api/git-status`, and the project conversation list/message endpoints. The WebSocket transports invalidations and deltas; REST remains the source for initial and recovery snapshots. diff --git a/docs-web/content/docs/registry.ts b/docs-web/content/docs/registry.ts index ddc672638a..c346b5dcaf 100644 --- a/docs-web/content/docs/registry.ts +++ b/docs-web/content/docs/registry.ts @@ -95,7 +95,6 @@ export type DocsSlug = | 'architecture-system-overview' | 'architecture-mcp-server' | 'architecture-sprint-engine' - | 'architecture-sprint-rollbacks' | 'architecture-virtual-workers' | 'architecture-ci-integration' | 'architecture-dashboard-architecture' @@ -774,13 +773,6 @@ export const docsRegistry: Record = { title: "Sprint engine", description: "The sprint engine is the heart of Code UX. It schedules, dispatches, monitors, gates, and finalises every unit of work.", }, - 'architecture-sprint-rollbacks': { - id: 'architecture-sprint-rollbacks', - path: '/docs/architecture-sprint-rollbacks', - section: 'Architecture', - title: "Sprint rollbacks", - description: "How Code UX assesses, executes, tracks, and PR-gates automatic and agent-assisted sprint rollbacks.", - }, 'architecture-virtual-workers': { id: 'architecture-virtual-workers', path: '/docs/architecture-virtual-workers', @@ -1044,7 +1036,6 @@ export const orderedDocs: DocsRegistryEntry[] = [ docsRegistry['architecture-system-overview'], docsRegistry['architecture-mcp-server'], docsRegistry['architecture-sprint-engine'], - docsRegistry['architecture-sprint-rollbacks'], docsRegistry['architecture-virtual-workers'], docsRegistry['architecture-ci-integration'], docsRegistry['architecture-dashboard-architecture'], diff --git a/docs-web/content/docs/settings-quality-assurance.mdx b/docs-web/content/docs/settings-quality-assurance.mdx index 8b8b1cd591..19b62e5231 100644 --- a/docs-web/content/docs/settings-quality-assurance.mdx +++ b/docs-web/content/docs/settings-quality-assurance.mdx @@ -55,7 +55,6 @@ If the saved setting does not appear to take effect: - A fix continuation created by the review that reaches the configured cap gets one final verification review. A CLI continuation with no patch and no commits ahead is treated as `follow_up_no_progress` and applies the exhaustion policy immediately; repeated continuations cannot extend the budget indefinitely. - Recovered failed, cancelled, or errored QA attempts retry only within the bounded infrastructure grace. All terminal attempts count toward the hard ceiling, so repeated container loss eventually opens the configured handoff. - Sprint QA review limits count review cycles, not the number of findings in each earlier cycle. The final configured cycle is verification-only: if it does not pass, Code UX opens one sprint-scoped human handoff and does not create another automatic follow-up batch. Completed follow-up work cannot bypass that exhausted-budget handoff merely because it changed the task snapshot. -- After a person reviews a blocked sprint result, **Mark QA Pass** in the Sprints page action menu creates a durable manual passing verdict and resolves only the sprint-level QA handoff. The control is disabled while an automated sprint review is running; it does not approve task-level QA failures or unrelated attention. ## Related Documentation diff --git a/docs-web/content/docs/user-dashboard-chat.mdx b/docs-web/content/docs/user-dashboard-chat.mdx index 90c16e8003..350f53b076 100644 --- a/docs-web/content/docs/user-dashboard-chat.mdx +++ b/docs-web/content/docs/user-dashboard-chat.mdx @@ -155,6 +155,8 @@ The **Invocations** tab is a structured log of server-created execution invocati - **Timing** — start, end, duration. - **Linked task / sprint** — when an invocation arose from sprint orchestration. +The rail remains server-authoritative during startup and live execution. `project.execution.updated` and realtime snapshot recovery refetch the paginated invocation list, so an early task-coding preparation row appears only after persistence and always carries its real server id into detail, cancellation, or restart actions. A refresh keeps the current selection and transcript when that invocation is still present; the Live page continues to use its separate execution snapshot flow. + Use this for debugging provider runs and MCP client integrations, for example to inspect agent transcripts or see exactly what arguments your LLM is passing to tools like `manage_memory` or `manage_settings`. Invocation transcripts use the same live sprint status and sprint/task reference cards as thread messages when their references resolve to active-project records. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Completed sprint-planning invocations append a final assistant summary with `metadata.widget_metadata.type = "planning_request"`, `status = "completed"`, and `metadata.executionPlan` for that invocation's linked sprint, including the sprint id, created task ids, and planned task titles. The plan shown in the transcript is replayed from persisted invocation message metadata, not from the currently selected sprint or the latest planning run for the project, so historical planning transcripts remain sprint-specific and stable. diff --git a/docs-web/content/docs/user-dashboard-node-flows.mdx b/docs-web/content/docs/user-dashboard-node-flows.mdx index c0b845edeb..809bb5783e 100644 --- a/docs-web/content/docs/user-dashboard-node-flows.mdx +++ b/docs-web/content/docs/user-dashboard-node-flows.mdx @@ -6,7 +6,7 @@ The **Nodes** page (`/nodes`) is the project-scoped backend authoring, publicati The flow library contains backend drafts and publications owned by the active project. Saves include the loaded draft revision, so a concurrent edit produces a visible conflict and never overwrites newer work. -The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import into the selected project. The bridge maps legacy `trigger`, `agent`, and `task` kinds to registered `input`, `set_fields`, and `provider_prompt` definitions and remaps their ports before creating an **Imported Nodes Canvas** draft. A failed import remains retryable and does not block the normal library load; only success removes the old value and records the marker. +The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import into the selected project. The bridge maps `trigger`, `agent`, and `task` to registered `input`, `set_fields`, and `provider_prompt` definitions, retains `condition` and `output`, and remaps their ports before creating an **Imported Nodes Canvas** draft. A failed import remains retryable and does not block the normal library load; only success removes the old value and records the marker. ## Registry-Driven Editing And Credentials diff --git a/docs-web/content/docs/user-dashboard-nodes-canvas.mdx b/docs-web/content/docs/user-dashboard-nodes-canvas.mdx index b783c609de..8aad7c5f0f 100644 --- a/docs-web/content/docs/user-dashboard-nodes-canvas.mdx +++ b/docs-web/content/docs/user-dashboard-nodes-canvas.mdx @@ -6,7 +6,7 @@ The **Nodes** page (`/nodes`) is a project-scoped Graph v2 workspace backed by t New flows and saved edits are persisted in the selected project's backend flow library. Each save includes the `draftRevision` that was loaded. If another editor has advanced that revision, Code UX reports a conflict instead of overwriting the newer draft; reload the flow and reapply the intended edit. -The former browser canvas is handled only as a one-time compatibility import. If `codeux:nodes-canvas:v1` exists when a project first loads, Code UX translates legacy kinds and ports to registered Graph v2 definitions and creates an **Imported Nodes Canvas** draft. A successful import records a project-specific marker and removes the old value. A failed import remains retryable and does not block existing backend flows. +The former browser canvas is handled only as a one-time compatibility import. If `codeux:nodes-canvas:v1` exists when a project first loads, Code UX translates legacy kinds and ports to registered Graph v2 definitions and creates an **Imported Nodes Canvas** draft while preserving non-secret labels, positions, and configuration. A successful import records a project-specific marker and removes the old value. A failed import remains retryable and does not block existing backend flows. Canvas dragging is previewed locally and commits one position update on pointer release. The route uses a static, context-free background while the canvas is mounted to avoid WebGL compositor pressure. diff --git a/docs-web/content/docs/user-dashboard-nodes.mdx b/docs-web/content/docs/user-dashboard-nodes.mdx index 1211e9c8f0..89f5593298 100644 --- a/docs-web/content/docs/user-dashboard-nodes.mdx +++ b/docs-web/content/docs/user-dashboard-nodes.mdx @@ -6,7 +6,7 @@ The **Nodes** page (`/nodes`) is the project-scoped backend workspace for author Creating or saving a draft persists it in the selected project's canonical node-flow repository. Saves include the loaded draft revision, so a concurrent edit produces a visible conflict instead of overwriting newer work. Changing projects clears the current workspace before loading the next project's records. -The former browser graph at `codeux:nodes-canvas:v1` is only a one-time migration source. Code UX translates legacy `trigger`, `agent`, and `task` nodes to governed `input`, `set_fields`, and `provider_prompt` definitions, retains `condition` and `output`, and remaps ports before creating an **Imported Nodes Canvas** draft. The old value is removed only after success. A failed import remains retryable and appears as a warning without blocking existing backend flows. +The former browser graph at `codeux:nodes-canvas:v1` is only a one-time migration source. Code UX translates legacy `trigger`, `agent`, and `task` nodes to governed `input`, `set_fields`, and `provider_prompt` definitions, retains `condition` and `output`, remaps ports, and preserves non-secret labels, layout, and configuration before creating an **Imported Nodes Canvas** draft. It removes the old value only after success. A failed import remains retryable and appears as a warning without blocking existing backend flows. ## Registry-Driven Editing And Execution @@ -14,9 +14,9 @@ The versioned node-definition registry drives palette entries, executable state, The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. -Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy kinds are translated by the import bridge rather than executed directly. Other unknown or unregistered types, mockup entries, and definitions marked non-executable remain planned or unavailable. +Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy kinds are translated by the one-time import bridge rather than executed directly. Other unknown or unregistered types, mockup entries, and definitions marked non-executable remain planned or unavailable. -Dragging previews positions locally and commits once on pointer release. The Nodes route uses a static background while the canvas is mounted to avoid WebGL compositor pressure. +Dragging previews positions locally and commits once on pointer release. The Nodes route uses a static background while the canvas is mounted to avoid combining continuous WebGL rendering with the large interactive surface. ## Review, Publication, And Debugging diff --git a/docs-web/content/docs/user-dashboard-sprints.mdx b/docs-web/content/docs/user-dashboard-sprints.mdx index d751eeda5a..fb039baf78 100644 --- a/docs-web/content/docs/user-dashboard-sprints.mdx +++ b/docs-web/content/docs/user-dashboard-sprints.mdx @@ -110,26 +110,6 @@ You can run any sprint multiple times. Each run has its own ID and its own row i Pausing / cancelling are also exposed as MCP actions via the `manage_sprints` tool (actions `pause`, `cancel`, `force_cancel`). -## Manual completion and QA pass - -The sprint card and ledger action menus include two separate manual controls: - -- **Mark Completed** safely ends any active sprint run before saving the sprint as completed. Active dispatches, provider invocations, runtime rows, and leases are cleaned up first, so a still-running watch loop cannot change the sprint back to `running`. -- **Mark QA Pass** records a durable sprint-level QA verdict with outcome `pass` and reviewer `Manual QA`. It also resolves a matching sprint-level QA handoff, without dismissing task QA or unrelated attention. The action is unavailable while sprint QA is actively reviewing and is hidden after the latest sprint QA verdict passes. - -These are operator overrides. Use **Mark Completed** when no more runtime work should continue, and use **Mark QA Pass** only after a person has reviewed the integrated sprint result and accepts it despite the automated QA state. - -## Rolling back a completed sprint - -Open the action menu of a completed standard sprint and choose **Rollback Sprint**. Code UX first inspects the applicable local or remote merge history and later sprint work, then explains the selected path: - -- **Automatic** means the sprint is the latest proven isolated merge and no later sprint work exists. Code UX creates the revert in a detached worktree without a coding invocation. -- **Agent-assisted** means later work, ambiguous history, or a revert conflict requires an agent to isolate the safe change set. - -Use the optional instruction field for partial rollbacks such as “remove only feature XY.” Any entered instruction starts an agent invocation so dependent behavior can be preserved deliberately. - -The original sprint is never rewritten. Every request creates an orange, visibly labelled rollback sprint with its own task history and branch. Remote projects deliver the rollback through a pull request even if ordinary sprint PR monitoring is disabled. Local projects merge the rollback branch directly into the configured local default branch without creating a PR. See [Sprint Rollbacks](/docs/architecture-sprint-rollbacks) for the safety model. - ## Importing & exporting sprints Sprints support importing issues directly from external providers, as well as being portable as Markdown bundles: diff --git a/docs-web/content/docs/user-dashboard-stats.mdx b/docs-web/content/docs/user-dashboard-stats.mdx index 0623c9f0f6..3e4bdc4bd6 100644 --- a/docs-web/content/docs/user-dashboard-stats.mdx +++ b/docs-web/content/docs/user-dashboard-stats.mdx @@ -77,7 +77,11 @@ Cost data is visualized directly within the Usage Graph and Composition views, f ## Underlying telemetry -The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available. +The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available. Both `project.execution.updated` and `snapshot_required` cause Stats to refetch its authoritative REST data. The aggregate snapshot and System invocation ledger refresh independently, and existing cards/rows remain visible while cached data updates. + +System invocation records come from the paginated `GET /api/projects/:projectId/execution/invocations` projection; realtime messages invalidate that query but do not manufacture invocation rows in the browser. Stats is also independent from Live's heavier `project.live.updated` snapshot, which retains a five-second server throttle. + +A CLI workflow may appear as a running execution invocation while cancellable workspace/provider preparation is still underway. Provider `started_at`, duration, concurrency, tokens, and cost begin only after Code UX claims the provider slot and starts that provider run. A preparation failure or pre-claim cancellation can therefore be visible in System without any provider usage, which is intentional rather than missing telemetry. It is backed by: - `GET /api/stats/header-throughput?projectId=...&window=...` — compact app and optional selected-project token throughput read model; the top dashboard header displays the app-wide value. diff --git a/docs-web/developer/websocket-realtime.md b/docs-web/developer/websocket-realtime.md index f7cff76077..ab2b5f09d2 100644 --- a/docs-web/developer/websocket-realtime.md +++ b/docs-web/developer/websocket-realtime.md @@ -70,6 +70,16 @@ The official client (`dashboard/src/lib/realtime/dashboard-realtime-client.ts`) The official client dispatches at most one `snapshot_required` notification every 3 seconds. Resource controllers also coalesce their silent REST refetches so one recovery handshake does not produce a refresh storm. +## Dashboard resource invalidation + +Realtime events signal freshness; the database-backed REST projections remain authoritative: + +- Chat loads its invocation rail from paginated `GET /api/projects/:projectId/execution/invocations`. On `project.execution.updated`, it refetches that server-owned list and the selected transcript instead of inserting invocation rows from the event payload. On `snapshot_required`, it also refreshes the active project's thread, connection, and selected-detail resources. +- Stats treats `project.execution.updated` and `snapshot_required` as invalidations for both `GET /api/projects/:projectId/stats` and its independent paginated System invocation query. Cached analytics stay visible during the silent/debounced refetch. +- Live continues to consume the heavier `project.live.updated` snapshot. Steady-state publication of that snapshot has a five-second minimum interval; urgent lifecycle refreshes may bypass it. The heavy Live throttle does not govern the lighter Chat or Stats invalidation paths. + +Clients should preserve these read-model boundaries: do not infer provider usage from an early workflow execution row, and do not construct invocation records locally when the server projection can be refetched. + ## Fallback to polling If the WebSocket connection cannot be established, consumers continue using their resource-specific REST snapshots. Common examples are `GET /api/live?projectId=:id`, `GET /api/projects/:id/execution`, `GET /api/git-status`, and the project conversation list/message endpoints. The WebSocket transports invalidations and deltas; REST remains the source for initial and recovery snapshots. diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index 90c16e8003..350f53b076 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -155,6 +155,8 @@ The **Invocations** tab is a structured log of server-created execution invocati - **Timing** — start, end, duration. - **Linked task / sprint** — when an invocation arose from sprint orchestration. +The rail remains server-authoritative during startup and live execution. `project.execution.updated` and realtime snapshot recovery refetch the paginated invocation list, so an early task-coding preparation row appears only after persistence and always carries its real server id into detail, cancellation, or restart actions. A refresh keeps the current selection and transcript when that invocation is still present; the Live page continues to use its separate execution snapshot flow. + Use this for debugging provider runs and MCP client integrations, for example to inspect agent transcripts or see exactly what arguments your LLM is passing to tools like `manage_memory` or `manage_settings`. Invocation transcripts use the same live sprint status and sprint/task reference cards as thread messages when their references resolve to active-project records. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Completed sprint-planning invocations append a final assistant summary with `metadata.widget_metadata.type = "planning_request"`, `status = "completed"`, and `metadata.executionPlan` for that invocation's linked sprint, including the sprint id, created task ids, and planned task titles. The plan shown in the transcript is replayed from persisted invocation message metadata, not from the currently selected sprint or the latest planning run for the project, so historical planning transcripts remain sprint-specific and stable. diff --git a/docs-web/user/dashboard/stats.md b/docs-web/user/dashboard/stats.md index 0623c9f0f6..3e4bdc4bd6 100644 --- a/docs-web/user/dashboard/stats.md +++ b/docs-web/user/dashboard/stats.md @@ -77,7 +77,11 @@ Cost data is visualized directly within the Usage Graph and Composition views, f ## Underlying telemetry -The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available. +The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available. Both `project.execution.updated` and `snapshot_required` cause Stats to refetch its authoritative REST data. The aggregate snapshot and System invocation ledger refresh independently, and existing cards/rows remain visible while cached data updates. + +System invocation records come from the paginated `GET /api/projects/:projectId/execution/invocations` projection; realtime messages invalidate that query but do not manufacture invocation rows in the browser. Stats is also independent from Live's heavier `project.live.updated` snapshot, which retains a five-second server throttle. + +A CLI workflow may appear as a running execution invocation while cancellable workspace/provider preparation is still underway. Provider `started_at`, duration, concurrency, tokens, and cost begin only after Code UX claims the provider slot and starts that provider run. A preparation failure or pre-claim cancellation can therefore be visible in System without any provider usage, which is intentional rather than missing telemetry. It is backed by: - `GET /api/stats/header-throughput?projectId=...&window=...` — compact app and optional selected-project token throughput read model; the top dashboard header displays the app-wide value. diff --git a/docs/architecture/dashboard-realtime-transport-plan.md b/docs/architecture/dashboard-realtime-transport-plan.md index 9b99365472..d8be52aa4a 100644 --- a/docs/architecture/dashboard-realtime-transport-plan.md +++ b/docs/architecture/dashboard-realtime-transport-plan.md @@ -336,6 +336,16 @@ Start/stop/retry/cancel buttons should: This removes current “wait for next poll” lag without inventing fake final states. +### 5. Keep surface refresh contracts explicit + +The implemented dashboard surfaces share the transport but not the same read model: + +- Chat's invocation rail is server-authoritative. It loads the paginated `GET /api/projects/:projectId/execution/invocations` projection and never inserts frontend-only invocation records. `project.execution.updated` refetches the invocation page and selected transcript; `snapshot_required` refreshes the invocation, thread, connection, and selected-detail REST resources for the active project. +- Stats uses `project.execution.updated` and `snapshot_required` as invalidations for both the project stats snapshot and the independent paginated System invocation ledger. The event does not replace either REST response, and cached analytics remain visible during the silent refresh. +- Live retains the heavy `project.live.updated` snapshot for its live-session projection. `DashboardRealtimeService` keeps `PROJECT_LIVE_MIN_INTERVAL_MS = 5_000`, so steady-state heavy Live snapshots publish at most once per five seconds (urgent lifecycle scheduling may explicitly bypass that wait). This throttle must not delay the lighter execution invalidations used by Chat or Stats. + +These boundaries keep the database and REST projections authoritative while allowing each page to choose the smallest truthful refresh path. + ## Backend Rollout Phases ## Phase 1: Realtime foundation diff --git a/docs/architecture/execution-invocation-tracking.md b/docs/architecture/execution-invocation-tracking.md index 9e06135e68..a88adb4615 100644 --- a/docs/architecture/execution-invocation-tracking.md +++ b/docs/architecture/execution-invocation-tracking.md @@ -19,6 +19,23 @@ Failed invocations can be explicitly preserved with `preserved_at`. Preservation For supported local CLI models, tracking prefers provider-reported usage. Jules retains a separate remote-session synchronization path that computes **estimated** tokens by accumulating input and output characters divided by 4 (the characters-per-token heuristic), keeping it accounted for without inventing authoritative native counts. +### CLI task-coding lifecycle + +A CLI task-coding run deliberately uses two records with different clocks and ownership: + +- The long-lived `execution_invocations` row represents the complete Code UX workflow. It is created after cancellation registration and before cancellable workspace/provider preparation, so Chat and Live can truthfully show that preparation is in progress. Its `started_at` is a workflow-observability timestamp, not evidence that a provider is consuming capacity. +- The linked `provider_invocations` row represents only a claimed provider attempt. Code UX creates it atomically when `ProviderConcurrencyService.waitForSlotAndClaim` obtains capacity (or immediately at the equivalent no-limit claim), links it to the existing execution row, and only then starts provider accounting. Provider `started_at`, duration, concurrency occupancy, and token/tool telemetry therefore begin at claim/run time; preparation never manufactures provider activity. +- The workflow owns the execution row's terminal status because Git commit/push and pull-request finalization can continue after the provider has finished. Provider completion releases and closes provider usage, but the execution invocation remains `running` until the whole workflow completes, fails, or is cancelled. + +The lifecycle remains truthful across non-happy paths: + +- A preparation failure closes the execution invocation as `failed` with the preparation error and creates no provider usage row. +- Cancellation can stop preparation or a provider-capacity wait through the registered dispatch. If cancellation wins before the atomic claim, the execution invocation becomes `cancelled` and no provider row or concurrency/token usage is recorded. If the provider already claimed capacity, the linked provider row is cancelled independently while the workflow row retains the cancellation transcript. +- Runtime shutdown preserves the workspace and leaves recovery to startup reconciliation rather than reporting an invented provider result. Recovery resolves stale workflow/provider state from the durable task run, dispatch, provider row, tracked process, and Docker-container evidence. +- A resumed preserved workspace creates a new workflow execution record. If startup recovery proves that provider work already completed in that same workspace, the new workflow skips a second provider call and continues Git/PR finalization; it does not create duplicate provider usage or attribute the earlier provider's tokens to preparation. Otherwise, a new provider claim creates a new usage row for the resumed attempt. + +For each actual provider claim, the execution row's `provider_invocation_id` links to that claim's one usage record. The link is absent before claim, is never a preparation placeholder, and a provider usage row is never shared as the accounting record for unrelated execution invocations. If the provider layer makes a distinct retried claim, that attempt receives its own durable usage row and the execution row is relinked to the current attempt while prior usage history remains intact. + ### `execution_invocation_messages` This table records each granular interaction loop in an invocation, preserving the exact sequence of `system`, `user`, `assistant`, and `tool` messages. It persists markdown content and parsed JSON arguments for tool calls, serving as a replayable log of an agent's reasoning process. @@ -81,7 +98,7 @@ Execution invocations are heavily used by the Chat page to track activity. When chat conversations take place (routed to either connected workers or virtual providers), those discrete operations and interactions generate `execution_invocations` with `type === "chat"`. This provides a clear audit log of the agent's work and prompt history separate from the user-facing `ConversationThreadRecord` and `ConversationMessageRecord` items. User-facing chat threads show up with `scope === "project"`, while agent background logs and execution runs appear with `scope === "connection"`. -The dashboard Chat -> Invocations rail renders only `execution_invocations` returned by the server list endpoint or project realtime refreshes. Invocation rows are created by the backend when the routed operation starts or is persisted. Sending a chat message still updates the thread transcript from the returned conversation message immediately, but the invocation rail waits for the persisted backend invocation row instead of inserting a frontend-only optimistic invocation placeholder. +The dashboard Chat -> Invocations rail renders only `execution_invocations` returned by the paginated `GET /api/projects/:projectId/execution/invocations` server endpoint. Invocation rows are created by the backend when the routed operation starts or is persisted. Sending a chat message still updates the thread transcript from the returned conversation message immediately, but the invocation rail waits for the persisted backend invocation row instead of inserting a frontend-only optimistic invocation placeholder. `project.execution.updated` and `snapshot_required` are invalidation signals: Chat refetches the authoritative invocation page and selected transcript instead of accepting or manufacturing invocation rows from realtime payloads in the browser. The Chat -> Invocations detail view exposes same-session recovery actions for failed or cancelled planning invocations. **Restart** preserves the original terminal transcript, creates a new invocation row, and resends the full planning prompt while passing the terminal provider row's native session id as `continueSessionId` (Claude Code uses `--resume `). **Continue** uses the same native-session resume path and asks the provider to finish the previous planning attempt, but the continuation prompt also embeds the original planning instructions so a provider fallback to a fresh session still has the full schema, sprint goal, and task-generation context. Docker-backed planning runs use a stable project/sprint snapshot workspace and preserve its paired provider runtime volume while the run is failed, cancelled, or incomplete. Restart and Continue reuse that workspace so provider-local session files remain available; fresh planning invocations in `REMOTE` git mode still refresh `origin` and build a new snapshot from `origin/`, using the explicit sprint feature branch when present or the effective runtime git default branch otherwise. Successful planning cleans up that workspace and paired runtime volume. The replacement invocation has its own provider usage trail; the terminal row remains immutable evidence of the quota/error/cancellation history. If Claude Code reports "No conversation found" during resume, Code UX retries once with a fresh Claude session and persists that fresh native session id rather than the rejected id. @@ -113,6 +130,8 @@ When an invocation or its messages are created/updated, the server emits a proje The Live dashboard consumes invocation records through the same project execution snapshot used for runtime events. `getProjectExecutionSnapshot(projectId, { selectedSprintId })` merges three slices into `recentInvocations`: the latest project-wide records, all invocation records for expanded active/paused/queued sprint runs, and all invocation records for the selected sprint. This keeps the Live invocation feed available for stopped or paused sprints even when other sprints have newer activity, while still letting active multi-sprint sessions stream through the existing `project.live.updated` websocket flow. The REST project execution endpoint returns the full bounded feeds, while the realtime `project.execution.updated` channel remains feed-less for payload size. The page-level feed is intentionally summary-level and scoped to the selected sprint when one is selected: status, provider/model/execution mode, task/sprint context, message and prompt/transcript character counts, timing, tokens, and latest error. Task cards filter the same records by task id/task key plus current dispatch and task-run ids to show local invocation activity on the card. Full invocation messages remain loaded on demand from the Chat invocation view. +Stats does not depend on the heavier Live snapshot for freshness. Its aggregate snapshot and independent System invocation ledger treat `project.execution.updated` and `snapshot_required` as invalidations and refetch their REST sources. The System ledger continues to use the paginated project invocation endpoint, while `project.live.updated` retains its five-second minimum publish interval for the heavier Live snapshot. + SQLite startup schema and migrations both maintain scalar indexes for these live snapshot slices: project/sprint/run invocation recency, provider fallback sprint/run recency for legacy rows, active invocation status recency, task dispatch recency, runtime event recency, and attention item status/update ordering. These indexes intentionally avoid prompt, transcript, markdown, JSON, and other large text fields so dashboard polling stays read-efficient without making provider writes heavy. The Chat invocation pane force-refreshes the selected running transcript whenever the invocation summary changes its `message_count`, `last_message_at`, `updated_at`, or `status` fields. That keeps live reasoning, tool, and assistant updates visible without navigating away and back, while the message equality check also considers content, tool-call payloads, and metadata so in-place transcript edits do not get collapsed into stale cache entries. @@ -132,11 +151,13 @@ scalability without compromising filter integrity. CLI-backed provider invocations now persist their workflow execution mode alongside the session id used to launch the worker. -On Code UX restart, runtime recovery reconciles any still-`running` CLI provider invocations before the dashboard rehydrates: +On Code UX restart, runtime recovery reconciles any still-`running` CLI workflow and provider invocations before the dashboard rehydrates: - tracked background CLI sessions recovered from `session-tracking.db` are marked failed because the original owning process exited - session recovery covers every local CLI provider (`gemini`, `codex`, `claude-code`, `qwen-code`, `opencode`, and `antigravity`) so dashboard session state does not stay `RUNNING` for a provider whose owning process is gone - Docker-backed invocations are checked against active Docker containers using the `code-ux.session-id` label; if no active container remains, the provider invocation and linked execution invocation are failed and annotated with a recovery message - stale task-coding execution audit rows also close their linked `session-tracking.db` session when startup recovery reconciles the provider invocation, so the live dashboard does not keep showing a recovered container run as still running +- preparation-only task-coding rows without provider linkage are reconciled from their task run, sprint run, and dispatch state; only rows that remain stale without active evidence are failed, so startup does not invent provider usage for preparation +- a preserved workspace with a durably completed provider attempt can resume at Git/PR finalization without claiming capacity or recording the same provider work twice This prevents stale `qa_review` or worker invocations from remaining indefinitely `running` after the underlying container or host process has already exited. @@ -146,7 +167,7 @@ Execution invocations cascade when their parent \`project_id\`, \`sprint_id\`, o Node-flow run rows reference execution invocations with `ON DELETE SET NULL`. Deleting an invocation should not delete the node-flow run history, and deleting a node flow cascades its versions, attachments, run rows, and node-run rows through the node-flow table relationships. -Additionally, every execution invocation explicitly links to a `provider_invocations` usage row. The execution transcripts stored in `execution_invocation_messages` serve as the replayable prompt history corresponding to the exact token and time consumption recorded in the usage row, allowing the dashboard Stats page to drill down into the exact sequence that generated specific costs. +Provider-backed execution invocations link to `provider_invocations` only after a provider claim exists. The execution transcript covers the broader workflow, while the linked usage row covers the exact provider capacity, token, and time consumption for that claim. Preparation-only failures and cancellations legitimately have no provider link and contribute no provider duration, concurrency, or token usage. `ExecutionRepository` remains the public persistence facade for both sides of this relationship. The table-specific write ownership is split behind that facade: `execution-invocation-writes.ts` owns invocation and transcript mutations, while `provider-invocation-usage-writes.ts` owns provider usage creation, slot-claim creation, provider session association, runtime row association, and usage updates. Both modules preserve the facade's validation behavior, timestamps, returned DTO shapes, and project realtime refresh semantics. diff --git a/docs/dashboard/design-system-chat.md b/docs/dashboard/design-system-chat.md index 50de036fe3..c8f22e8623 100644 --- a/docs/dashboard/design-system-chat.md +++ b/docs/dashboard/design-system-chat.md @@ -133,7 +133,8 @@ rg "agentEffect|codeux:agent|Project Manager|reduced motion|welcome-back|work to ``` ## Data Flow and Polling -- **Active Invocation Polling**: When active invocations exist (running or optimistic), the dashboard actively polls for updates. This polling relies on a stable derived key (`activeInvocationKey`) representing the set of active IDs to prevent unnecessary interval resets when non-ID metadata updates. Stale refreshes are prevented by verifying that both the active project and the selected invocation remain identical to when the polling cycle started. +- **Active Invocation Polling**: When persisted running invocations exist, the dashboard actively polls for updates. This polling relies on a stable derived key (`activeInvocationKey`) representing the set of server-provided running IDs to prevent unnecessary interval resets when non-ID metadata updates. Stale refreshes are prevented by verifying that both the active project and the selected invocation remain identical to when the polling cycle started. +- **Server-authoritative invocation refresh**: `project.execution.updated` refreshes the lightweight paginated invocation list, and `snapshot_required` refreshes the same REST-backed resource set. An early persisted `cli_task_coding` preparation row therefore appears with its database id and can use the existing detail and cancellation actions; Chat never inserts a placeholder row or substitutes a synthetic action id. List refreshes preserve a still-present selected invocation and its cached transcript while the selected transcript is force-refetched. - **Invocation Pagination**: The chat page calls the paginated invocation query with `limit=40` for the initial rail load. After more pages are loaded, live refreshes request the currently loaded window size so scroll-expanded history remains present while active invocation metadata updates. - **Live Planning Widgets**: Thread and invocation bubbles receive the current project tasks, task loading state, `ExecutionDashboardSnapshot`, and execution loading state from existing hooks. Task refreshes follow project task realtime updates, and execution changes arrive through `project.execution.updated`; no extra polling endpoint is used for chat widgets. The widget delegates task phase derivation to the shared live task helpers so queued, running, completed, failed, blocked, quota, and provider-cap states match the Live page. - **Live Entity Widgets**: `dashboard/src/v2/lib/chat-live-entities.ts` is the pure resolver contract for sprint/task references in chat and invocation messages. `useChatPageData` loads selected-project sprints with `useSprints` and all selected-project tasks with `useProjectTasks`; sprint summaries silently refetch after both `project.structure.updated` and `project.execution.updated`, while tasks retain their structure refresh path. This reuses the lightweight project scope without a separate chat poller or heavy live-snapshot subscription. Sprint completion cards preserve one decimal when needed and use the same clamped value for text, bar width, and progressbar ARIA. `ChatPage` memoizes resolver output for the visible thread messages and merged invocation transcript messages, passing the selected invocation as context for invocation transcripts. The resolver accepts only already-loaded project sprints/tasks plus message metadata, invocation context, and markdown; it resolves explicit record ids before keys before dashboard links, returns widgets only for real project records, and refuses ambiguous bare task keys unless a matching sprint context disambiguates them. Dashboard links may be relative `/sprints` or `/tasks` URLs, or absolute URLs on the current dashboard origin; absolute links from external origins stay plain transcript text. diff --git a/docs/dashboard/design-system-stats.md b/docs/dashboard/design-system-stats.md index 1c33893d66..5622dbb8c7 100644 --- a/docs/dashboard/design-system-stats.md +++ b/docs/dashboard/design-system-stats.md @@ -15,6 +15,9 @@ Stats presentation must stay within the implemented snapshot and invocation cont - Recent snapshot windows are bucket aligned and half-open while including the latest current bucket: `1h` keeps twelve 5-minute buckets through the current 5-minute bucket end, `24h` keeps twenty-four hourly buckets through the current partial hour, and daily/custom windows include their selected end day or bucket. - Usage totals include invocation count, active and wall time, input/cached/output/reasoning/total tokens, cost fields, optional tool-call count, and usage-source counters for `reported`, `estimated`, `unavailable`, and `unsupported`. - System mode uses `useSystemViewData(projectId)` and invocation APIs for records, server-projected filters, sort state, pagination, summaries, and transcript expansion. +- `project.execution.updated` and `snapshot_required` invalidate the aggregate Stats snapshot and the independent System invocation query. Both paths refetch their REST projections; realtime payloads do not become invocation records in the browser. +- System records remain sourced from paginated `GET /api/projects/:projectId/execution/invocations` responses. Cached cards and rows remain visible during the debounced/background refresh. +- Stats refreshes independently from Live's heavier `project.live.updated` snapshot, which retains a five-second server throttle. Do not route Stats freshness through the Live snapshot or imply that preparation-only workflow visibility is provider usage. Do not document or render speculative metrics. Missing telemetry is a first-class state and must remain visibly different from a meaningful zero. @@ -160,6 +163,8 @@ Cost values come from snapshot cost fields and should only be presented when con Cost displays use two fractional digits for scanability, rounding values such as `$55.4093` to `$55.41`. +CLI task-coding analytics distinguish workflow visibility from provider usage. A running execution invocation may appear while Code UX is preparing a cancellable workspace or waiting to claim provider capacity. Provider `started_at`, duration, concurrency, token, and cost telemetry begin only after the provider claim/run starts; preparation failure or pre-claim cancellation therefore leaves an auditable execution row without fabricated provider usage. + ## Primitives And Styling Use page-scoped Stats primitives instead of one-off analytics chrome. The Stats surface vocabulary is a warm void hierarchy: diff --git a/src/services/cli-workflow-service.ts b/src/services/cli-workflow-service.ts index e84f7b5fb5..a9f98b9090 100644 --- a/src/services/cli-workflow-service.ts +++ b/src/services/cli-workflow-service.ts @@ -17,7 +17,7 @@ import type { ProjectManagementRepository } from "../repositories/project-manage import { SessionTrackingRepository } from "../repositories/session-tracking-repository.js"; import { runCommandStrict, type CommandResult } from "./cli-process-runner.js"; import { isReadFileNotFoundToolError, buildReadFileRetryPrompt } from "./cli-workflow-text-utils.js"; -import type { ProviderSettingsOverride } from "./provider-settings-override.js"; +import { buildProviderSettingsOverride, type ProviderSettingsOverride } from "./provider-settings-override.js"; import { buildProviderPrompt, @@ -57,6 +57,8 @@ import type { AgentPresetRepository } from "../repositories/agent-preset-reposit import type { McpConnectionInfo } from "../contracts/mcp-connection-types.js"; import type { AgentPresetRecord } from "../contracts/agent-preset-types.js"; import { parseTaskExecutionOutcomeFromProviderOutput, type TaskExecutionOutcome } from "../domain/sprint/task-execution-outcome.js"; +import { resolveProviderForInvocation } from "./provider-routing.js"; +import { resolveEffectiveModel } from "./provider-execution-service.js"; interface CliWorkflowServiceDependencies { sessionTracking: SessionTrackingRepository; @@ -280,6 +282,33 @@ export class CliWorkflowService { const taskRun = args.taskRunId && this.deps.executionRepository ? this.deps.executionRepository.getTaskRun(args.taskRunId) : null; + const canPersistExecutionInvocation = Boolean( + taskRun + && this.deps.executionRepository + && typeof this.deps.executionRepository.createExecutionInvocation === "function", + ); + const invocationModel = canPersistExecutionInvocation + ? (() => { + const resolvedProvider = resolveProviderForInvocation(settings, { + invocation: "task_coding", + task: args.task, + }); + const resolvedProviderSettings = resolvedProvider.providers[args.provider]; + const providerSettings = args.providerSettingsOverride + || buildProviderSettingsOverride(resolvedProviderSettings.model, resolvedProviderSettings); + return resolveEffectiveModel({ + provider: args.provider, + model: providerSettings.model, + providerMountAuth: providerSettings.providerMountAuth, + customModel: providerSettings.customModel, + qwenAuthMode: providerSettings.qwenAuthMode, + qwenModelId: providerSettings.qwenModelId, + openCodeAuthMode: providerSettings.openCodeAuthMode, + openCodeProviderId: providerSettings.openCodeProviderId, + openCodeModelId: providerSettings.openCodeModelId, + }); + })() + : null; const ctx: PipelineContext = { ...args, @@ -348,6 +377,33 @@ export class CliWorkflowService { let preserveWorkspaceForShutdown = false; try { + if (taskRun && invocationModel && this.deps.executionRepository) { + const invocation = this.deps.executionRepository.createExecutionInvocation({ + projectId: taskRun.projectId, + sprintId: taskRun.sprintId, + taskId: taskRun.taskId, + sprintRunId: taskRun.sprintRunId, + dispatchId: taskRun.dispatchId || args.dispatchId || null, + taskRunId: taskRun.id, + type: "cli_task_coding", + status: "running", + provider: args.provider, + model: invocationModel, + invocationSource: "internal", + agentPresetId: workerAgent?.id, + }); + ctx.executionInvocationId = invocation.id; + this.deps.executionRepository.appendExecutionInvocationMessage(invocation.id, { + role: "system", + contentMarkdown: `Preparing the task workspace and ${args.provider} configuration.`, + metadata: { + kind: "preparation_started", + provider: args.provider, + model: invocationModel, + }, + }); + } + this.appendExecutionEvent(args, "cli_workspace_bound", { provider: args.provider, repoPath: args.repoPath, @@ -430,12 +486,13 @@ export class CliWorkflowService { dispatchStatus: "blocked", errorMessage: blocker, workerBranch: null, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_blocked", { provider: args.provider, category, errorMessage: blocker, }, `cli:workflow:blocked:agent:${args.sessionId}`); + this.finalizeExecutionInvocation(ctx.executionInvocationId, "failed", finishedAt, blocker); return; } this.appendExecutionEvent(args, "cli_git_no_changes", { @@ -450,11 +507,12 @@ export class CliWorkflowService { // branch, otherwise the orchestrator treats it as merge evidence and // falsely advances/merges the task. workerBranch: null, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_completed", { provider: args.provider, outcome: "no_changes", }, "cli:workflow:completed:no-changes"); + this.finalizeExecutionInvocation(ctx.executionInvocationId, "completed", finishedAt); return; } @@ -476,7 +534,7 @@ export class CliWorkflowService { finishedAt, workerBranch: args.workerBranch, dispatchStatus: "completed", - }); + }, ctx.executionInvocationId); const { prUrl } = await executePrFinalizeStage(ctx, { completionTimestamp: finishedAt }); this.updateExecutionState(args, { @@ -485,7 +543,7 @@ export class CliWorkflowService { prUrl, workerBranch: args.workerBranch, dispatchStatus: "completed", - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_pr_finalized", { provider: args.provider, prUrl: prUrl || null, @@ -496,6 +554,7 @@ export class CliWorkflowService { outcome: "pushed", prUrl: prUrl || null, }, `cli:workflow:completed:${prUrl || "none"}`); + this.finalizeExecutionInvocation(ctx.executionInvocationId, "completed", finishedAt); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -531,7 +590,7 @@ export class CliWorkflowService { finishedAt, dispatchStatus: "cancelled", errorMessage: "Workflow cancelled by dashboard control.", - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_cancel_requested", { provider: args.provider, sessionId: args.sessionId, @@ -551,7 +610,7 @@ export class CliWorkflowService { finishedAt, dispatchStatus: workflowSettings.retryOnRateLimit ? "quota" : "failed", errorMessage: message, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_rate_limited", { provider: args.provider, errorMessage: message, @@ -576,7 +635,7 @@ export class CliWorkflowService { finishedAt, dispatchStatus: "quota", errorMessage: message, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_quota", { provider: args.provider, errorMessage: message, @@ -601,7 +660,7 @@ export class CliWorkflowService { finishedAt, dispatchStatus: "failed", errorMessage: message, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_failed", { provider: args.provider, errorMessage: message, @@ -624,7 +683,7 @@ export class CliWorkflowService { finishedAt, dispatchStatus: "blocked", errorMessage: message, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_blocked", { provider: args.provider, category: isNonRecoverableGitWorkflowError(message) ? "git_configuration" : "execution_environment", @@ -646,7 +705,7 @@ export class CliWorkflowService { finishedAt, dispatchStatus: "failed", errorMessage: message, - }); + }, ctx.executionInvocationId); this.appendExecutionEvent(args, "cli_workflow_failed", { provider: args.provider, errorMessage: message, @@ -657,6 +716,13 @@ export class CliWorkflowService { message, }); } + const invocationStatus = abortController.signal.aborted ? "cancelled" : "failed"; + this.finalizeExecutionInvocation( + ctx.executionInvocationId, + invocationStatus, + finishedAt, + invocationStatus === "cancelled" ? "Workflow cancelled by dashboard control." : message, + ); } finally { try { const cleanupResult = preserveWorkspaceForShutdown @@ -800,12 +866,23 @@ export class CliWorkflowService { dispatchStatus: NonNullable; errorMessage?: string; }, + executionInvocationId?: string, ): void { const taskRun = this.resolveTaskRun(args); if (!taskRun || !this.deps.executionRepository) { return; } + // Cancellation is persisted before the active provider/workflow has + // necessarily observed its abort signal. Ignore a late pipeline update so + // the cancelled task and dispatch cannot drift back to another state. + if ( + executionInvocationId + && this.deps.executionRepository.getExecutionInvocation(executionInvocationId)?.status === "cancelled" + ) { + return; + } + if (this.isSprintRunCancelled(taskRun.sprintRunId)) { this.markTaskRunCancelledBySprintStop(taskRun, input.finishedAt, input.errorMessage ?? "Sprint run was cancelled."); return; @@ -839,6 +916,42 @@ export class CliWorkflowService { } } + private finalizeExecutionInvocation( + invocationId: string | undefined, + status: "completed" | "failed" | "cancelled", + finishedAt: string, + errorMessage?: string, + ): void { + if (!invocationId || !this.deps.executionRepository) { + return; + } + const invocation = this.deps.executionRepository.getExecutionInvocation(invocationId); + if (!invocation || (invocation.status !== "running" && invocation.status !== "paused")) { + return; + } + this.deps.executionRepository.updateExecutionInvocation(invocationId, { + status, + finishedAt, + errorMessage: status === "completed" ? null : errorMessage ?? null, + lastErrorCategory: status === "failed" ? invocation.lastErrorCategory ?? "UNKNOWN" : null, + lastErrorMessage: status === "completed" ? null : errorMessage ?? null, + lastRetryAfterIso: null, + }); + this.deps.executionRepository.appendExecutionInvocationMessage(invocationId, { + role: "system", + contentMarkdown: status === "completed" + ? "CLI workflow completed successfully." + : status === "cancelled" + ? `CLI workflow cancelled${errorMessage ? `: ${errorMessage}` : "."}` + : `CLI workflow failed${errorMessage ? `: ${errorMessage}` : "."}`, + metadata: { + kind: "cli_workflow_finalized", + status, + }, + createdAt: finishedAt, + }); + } + private isSprintRunCancelled(sprintRunId?: string | null): boolean { if (!sprintRunId || !this.deps.executionRepository) { return false; diff --git a/src/services/cli-workflow/pipeline/execute-provider-stage.ts b/src/services/cli-workflow/pipeline/execute-provider-stage.ts index 3ee16e4c42..00444eb6e2 100644 --- a/src/services/cli-workflow/pipeline/execute-provider-stage.ts +++ b/src/services/cli-workflow/pipeline/execute-provider-stage.ts @@ -107,6 +107,8 @@ export async function executeProviderStage(ctx: PipelineContext, providerPrompt: workspaceSessionId: ctx.workspaceSessionId, continueSessionId, openCodeBaselineRawUsageJson, + invocationId: ctx.executionInvocationId, + finalizeExecutionInvocation: ctx.executionInvocationId ? false : undefined, workflowSettings: ctx.workflowSettings, repoPath: ctx.repoPath, gitPolicy: { diff --git a/src/services/cli-workflow/pipeline/pipeline-context.ts b/src/services/cli-workflow/pipeline/pipeline-context.ts index 92cc174dee..014e3bd32f 100644 --- a/src/services/cli-workflow/pipeline/pipeline-context.ts +++ b/src/services/cli-workflow/pipeline/pipeline-context.ts @@ -48,6 +48,8 @@ export interface PipelineContext { worktreePath: string; workspaceSessionId: string; abortSignal?: AbortSignal; + /** Durable execution invocation created before workspace preparation begins. */ + executionInvocationId?: string; workflowSettings: CliWorkflowSettings; settings: DashboardSettings; initialHead: string; diff --git a/src/services/execution-invocation-control-service.ts b/src/services/execution-invocation-control-service.ts index 9539d514d9..72c116b2dc 100644 --- a/src/services/execution-invocation-control-service.ts +++ b/src/services/execution-invocation-control-service.ts @@ -111,39 +111,51 @@ export class ExecutionInvocationControlService { const providerInvocation = invocation.providerInvocationId ? this.deps.executionRepository.getProviderInvocationUsage(invocation.providerInvocationId) : null; - - await this.requestActiveDispatchStop(invocation, CANCEL_MESSAGE); - const stoppedContainerIds = await this.stopDockerContainers(invocation, providerInvocation); const finishedAt = new Date().toISOString(); - if (providerInvocation?.status === "running") { - this.deps.executionRepository.updateProviderInvocationUsage(providerInvocation.id, { - status: "cancelled", - finishedAt, - durationMs: calculateDurationMs(providerInvocation.startedAt, finishedAt) ?? undefined, - }); - } - - this.closeTaskRuntimeForRetry(invocation, finishedAt); - + // Close the durable invocation before asking the active workflow to stop. + // The abort rejection can race this control request, and workflow/provider + // finalizers intentionally refuse to replace an already-terminal row. this.deps.executionRepository.updateExecutionInvocation(invocation.id, { status: "cancelled", finishedAt, errorMessage: CANCEL_MESSAGE, + lastErrorCategory: null, + lastErrorMessage: CANCEL_MESSAGE, + lastRetryAfterIso: null, }); this.deps.executionRepository.appendExecutionInvocationMessage(invocation.id, { role: "system", - contentMarkdown: stoppedContainerIds.length > 0 - ? `${CANCEL_MESSAGE} Stopped Docker container${stoppedContainerIds.length === 1 ? "" : "s"} ${stoppedContainerIds.join(", ")}.` - : CANCEL_MESSAGE, + contentMarkdown: CANCEL_MESSAGE, metadata: { cancellation: "dashboard_invocation_cancel", providerInvocationId: providerInvocation?.id ?? null, - stoppedContainerIds, }, createdAt: finishedAt, }); + await this.requestActiveDispatchStop(invocation, CANCEL_MESSAGE); + const stoppedContainerIds = await this.stopDockerContainers(invocation, providerInvocation).catch((error: unknown) => { + this.deps.logger?.warn("Failed to stop all Docker containers for invocation cancellation", { + invocationId: invocation.id, + error: error instanceof Error ? error.message : String(error), + }); + return []; + }); + + const activeProviderInvocation = providerInvocation + ? this.deps.executionRepository.getProviderInvocationUsage(providerInvocation.id) + : null; + if (activeProviderInvocation?.status === "running") { + this.deps.executionRepository.updateProviderInvocationUsage(activeProviderInvocation.id, { + status: "cancelled", + finishedAt, + durationMs: calculateDurationMs(activeProviderInvocation.startedAt, finishedAt) ?? undefined, + }); + } + + this.closeTaskRuntimeForRetry(invocation, finishedAt); + return { cancelled: true, invocationId, diff --git a/src/services/provider-concurrency-service.ts b/src/services/provider-concurrency-service.ts index e3db0a3676..df5800918d 100644 --- a/src/services/provider-concurrency-service.ts +++ b/src/services/provider-concurrency-service.ts @@ -97,11 +97,18 @@ export class ProviderConcurrencyService { limit: number, input: CreateProviderInvocationUsageInput, signal?: AbortSignal, - maxWaitMs?: number + maxWaitMs?: number, + executionInvocationId?: string, ): Promise { if (limit <= 0) { await this.reconcileStaleProviderInvocations(provider, true); - return this.deps.executionRepository.createProviderInvocationUsage(input); + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason || "AbortSignal triggered")); + } + this.assertExecutionInvocationCanClaim(executionInvocationId); + const invocation = this.deps.executionRepository.createProviderInvocationUsage(input); + this.linkExecutionInvocation(executionInvocationId, invocation.id); + return invocation; } const startMs = Date.now(); @@ -120,8 +127,13 @@ export class ProviderConcurrencyService { await this.reconcileStaleProviderInvocations(provider, isFirstCheck); isFirstCheck = false; + // Keep the status check and synchronous repository claim in the same event-loop turn. + // Dashboard cancellation can therefore stop a waiting execution before it manufactures + // provider usage or consumes capacity. + this.assertExecutionInvocationCanClaim(executionInvocationId); const invocation = this.deps.executionRepository.tryCreateProviderInvocationUsage(input, limit); if (invocation) { + this.linkExecutionInvocation(executionInvocationId, invocation.id); return invocation; } @@ -139,6 +151,25 @@ export class ProviderConcurrencyService { } } + private assertExecutionInvocationCanClaim(executionInvocationId: string | undefined): void { + if (!executionInvocationId) { + return; + } + const invocation = this.deps.executionRepository.getExecutionInvocation(executionInvocationId); + if (invocation?.status === "cancelled") { + throw new Error(`Execution invocation ${executionInvocationId} is ${invocation.status}; provider slot will not be claimed.`); + } + } + + private linkExecutionInvocation(executionInvocationId: string | undefined, providerInvocationId: string): void { + if (!executionInvocationId) { + return; + } + this.deps.executionRepository.updateExecutionInvocation(executionInvocationId, { + providerInvocationId, + }); + } + /** * Attempts to claim a concurrency slot for the given provider atomically without waiting. * Returns the claimed invocation record, or null if the global cap is currently reached. diff --git a/src/services/provider-execution-service.ts b/src/services/provider-execution-service.ts index 2c08247772..c88cd825bd 100644 --- a/src/services/provider-execution-service.ts +++ b/src/services/provider-execution-service.ts @@ -278,6 +278,9 @@ export class ProviderExecutionService { async executeProvider(args: ExecutionProviderRunArgs): Promise { let execInvocationId: string | null = args.invocationId || null; let lastPersistedMessagesSignature: string | null = null; + if (execInvocationId) { + this.assertExecutionInvocationCanRun(execInvocationId); + } const effectiveModel = resolveEffectiveModel(args); const scopedSettings = args.projectId.trim() && this.deps.getDashboardSettings ? this.deps.getDashboardSettings({ @@ -315,7 +318,10 @@ export class ProviderExecutionService { }); const runProviderInner = async (p: string, retrySystemMessage?: string, continueSessionId?: string | null, openCodeBaselineRawUsageJson?: Record | null): Promise => { - const startedAt = new Date().toISOString(); + if (execInvocationId) { + this.assertExecutionInvocationCanRun(execInvocationId); + } + const executionStartedAt = new Date().toISOString(); // Coalesce the per-line streaming activity firehose into batched transactions so concurrent // sprints don't saturate the single thread with one INSERT per output line. Only used when @@ -347,19 +353,19 @@ export class ProviderExecutionService { type: args.type, provider: args.provider, model: effectiveModel, - startedAt, + startedAt: executionStartedAt, invocationSource: args.invocationSource, })?.id || null; } - if (execInvocationId && retrySystemMessage) { + if (execInvocationId && retrySystemMessage && this.isExecutionInvocationStillRunning(execInvocationId)) { this.deps.executionRepository?.appendExecutionInvocationMessage(execInvocationId, { role: "system", contentMarkdown: retrySystemMessage, }); } - if (execInvocationId && args.trackPromptInInvocation !== false) { + if (execInvocationId && args.trackPromptInInvocation !== false && this.isExecutionInvocationStillRunning(execInvocationId)) { this.deps.executionRepository?.appendExecutionInvocationMessage(execInvocationId, { role: "user", contentMarkdown: p, @@ -384,7 +390,6 @@ export class ProviderExecutionService { purpose: args.purpose, model: effectiveModel, executionMode: args.workflowSettings.executionMode, - startedAt, promptChars: p.length, }; @@ -395,20 +400,26 @@ export class ProviderExecutionService { usageInput, args.signal, args.concurrencyWaitTimeoutMs, + execInvocationId ?? undefined, ); } else { // Fallback for cases where ProviderConcurrencyService is not provided, // e.g. in some specialized service tests, though in production it should be present // when an execution repository is present. + if (execInvocationId) { + this.assertExecutionInvocationCanRun(execInvocationId); + } invocation = this.deps.executionRepository?.createProviderInvocationUsage(usageInput); + if (invocation && execInvocationId) { + this.deps.executionRepository?.updateExecutionInvocation(execInvocationId, { + providerInvocationId: invocation.id, + }); + } } - if (invocation && execInvocationId) { - this.deps.executionRepository?.updateExecutionInvocation(execInvocationId, { - providerInvocationId: invocation.id, - }); + if (execInvocationId) { + this.assertExecutionInvocationCanRun(execInvocationId); } - const startedMs = Date.now(); this.deps.logger?.info("Provider invocation started", { logPurpose: "invocation", @@ -483,7 +494,7 @@ export class ProviderExecutionService { usageSignature !== lastPersistedUsageSignature && invocation && this.deps.executionRepository - && this.isProviderInvocationStillRunning(invocation.id) + && this.isProviderWorkStillRunning(invocation.id, execInvocationId) ) { const durationMs = Date.now() - startedMs; this.deps.executionRepository.updateProviderInvocationUsage(invocation.id, { @@ -559,7 +570,7 @@ export class ProviderExecutionService { invocation && this.deps.executionRepository && !preserveForStartupRecovery - && this.isProviderInvocationStillRunning(invocation.id) + && this.isProviderWorkStillRunning(invocation.id, execInvocationId) ) { const finishedAt = new Date().toISOString(); const durationMs = Date.now() - startedMs; @@ -597,10 +608,16 @@ export class ProviderExecutionService { // Persist any buffered streaming activity from the completed run before recording usage. activityCoalescer?.stop(); + if (args.invocationId && execInvocationId && !this.isExecutionInvocationStillRunning(execInvocationId)) { + this.assertExecutionInvocationCanRun(execInvocationId); + } + if (invocation && this.deps.executionRepository) { const finishedAt = new Date().toISOString(); const durationMs = Date.now() - startedMs; - if (this.isProviderInvocationStillRunning(invocation.id) && !isServerShutdownAbort(args.signal)) { + const shouldPersistTerminalUsage = this.isProviderWorkStillRunning(invocation.id, execInvocationId) + && !isServerShutdownAbort(args.signal); + if (shouldPersistTerminalUsage) { this.deps.executionRepository.updateProviderInvocationUsage(invocation.id, { status: (args.signal?.aborted || isRuntimeShutdownInProgress()) ? "cancelled" : (result.ok ? "completed" : "failed"), model: effectiveModel, @@ -625,8 +642,8 @@ export class ProviderExecutionService { }); } - if (args.taskRunId) { - this.deps.executionRepository.appendTaskRunEvent(args.taskRunId, "cli_provider_usage_reported", "system", { + if (args.taskRunId && shouldPersistTerminalUsage) { + this.deps.executionRepository.appendTaskRunEvent(args.taskRunId, "cli_provider_usage_reported", "system", { provider: args.provider, model: effectiveModel, purpose: args.purpose, @@ -758,7 +775,7 @@ export class ProviderExecutionService { && !(retryDecision.kind === "rate_limit" && rateLimitRetryCount >= args.workflowSettings.maxRateLimitRetries) ? retryDecision.retryAtIso : null; - if (execInvocationId) { + if (execInvocationId && this.isExecutionInvocationStillRunning(execInvocationId)) { this.deps.executionRepository?.updateExecutionInvocation(execInvocationId, { lastErrorCategory: classification.category, lastErrorMessage: persistedUserMessage, @@ -796,7 +813,7 @@ export class ProviderExecutionService { }); } - if (execInvocationId) { + if (execInvocationId && this.isExecutionInvocationStillRunning(execInvocationId)) { this.deps.executionRepository?.appendExecutionInvocationMessage(execInvocationId, { role: "system", contentMarkdown: retryMessage, @@ -893,6 +910,22 @@ export class ProviderExecutionService { return !current || current.status === "running"; } + private isProviderWorkStillRunning(providerInvocationId: string, executionInvocationId: string | null): boolean { + return this.isProviderInvocationStillRunning(providerInvocationId) + && (!executionInvocationId || !this.isExecutionInvocationCancelled(executionInvocationId)); + } + + private assertExecutionInvocationCanRun(executionInvocationId: string): void { + const current = this.deps.executionRepository?.getExecutionInvocation?.(executionInvocationId); + if (current?.status === "cancelled") { + throw new Error(`Execution invocation ${executionInvocationId} is ${current.status}; provider execution will not continue.`); + } + } + + private isExecutionInvocationCancelled(executionInvocationId: string): boolean { + return this.deps.executionRepository?.getExecutionInvocation?.(executionInvocationId)?.status === "cancelled"; + } + private isExecutionInvocationStillRunning(executionInvocationId: string): boolean { const current = this.deps.executionRepository?.getExecutionInvocation?.(executionInvocationId); return !current || current.status === "running" || current.status === "paused"; diff --git a/src/services/runtime-recovery/invocation-recovery.ts b/src/services/runtime-recovery/invocation-recovery.ts index d6cb1d1688..91a31541a0 100644 --- a/src/services/runtime-recovery/invocation-recovery.ts +++ b/src/services/runtime-recovery/invocation-recovery.ts @@ -177,6 +177,9 @@ export class InvocationRecoveryService { status: resolution.status, finishedAt: reconciledAt, errorMessage: resolution.status === "failed" ? resolution.message : null, + lastErrorCategory: resolution.status === "failed" ? invocation.lastErrorCategory ?? "UNKNOWN" : null, + lastErrorMessage: resolution.status === "failed" ? resolution.message : null, + lastRetryAfterIso: null, }); this.deps.executionRepository.appendExecutionInvocationMessage(invocation.id, { role: "system", @@ -230,11 +233,25 @@ export class InvocationRecoveryService { const sprintRun = invocation.sprintRunId ? this.deps.executionRepository.getSprintRun(invocation.sprintRunId) : null; if (sprintRun && ["completed", "failed", "cancelled"].includes(sprintRun.status)) { return { - status: "failed", + status: sprintRun.status === "cancelled" ? "cancelled" : "failed", message: `Recovered stale task coding invocation after the linked sprint run was already ${sprintRun.status}.`, }; } + const dispatch = invocation.dispatchId ? this.deps.executionRepository.getTaskDispatch(invocation.dispatchId) : null; + if ( + dispatch + && dispatch.status !== "paused" + && !ACTIVE_DISPATCH_STATUSES.includes(dispatch.status as (typeof ACTIVE_DISPATCH_STATUSES)[number]) + ) { + return { + status: dispatch.status === "completed" + ? "completed" + : dispatch.status === "cancelled" ? "cancelled" : "failed", + message: `Recovered stale task coding invocation after the linked task dispatch was already ${dispatch.status}.`, + }; + } + const referenceAt = Date.parse(invocation.lastMessageAt || invocation.startedAt); const ageMs = Number.isFinite(referenceAt) ? Date.now() - referenceAt : 0; diff --git a/src/services/runtime-startup-recovery-service.ts b/src/services/runtime-startup-recovery-service.ts index 2e73920ce1..b9f1cfc529 100644 --- a/src/services/runtime-startup-recovery-service.ts +++ b/src/services/runtime-startup-recovery-service.ts @@ -134,10 +134,13 @@ export class RuntimeStartupRecoveryService { const reconciledStructuredInvocationIds = await invocationRecovery.reconcileInterruptedStructuredInvocations(activeContainerSessionIds); const rehydratedSprintRunIds = this.rehydrateDurableProviderSprintRuns(); const restartPolicySyncedOrphanedSprintIds = this.syncOrphanedRunningSprintProjections(); - const reconciledTaskCodingInvocationIds = await invocationRecovery.reconcileInterruptedTaskCodingInvocations(activeContainerSessionIds); - const reconciledTaskCodingProviderIds = invocationRecovery.reconcileOrphanedTaskCodingProviderInvocations(); const reconciledTerminalProviderDispatchIds = this.reconcileTerminalProviderBackedDispatches(); const reconciledTerminalDispatchIds = this.reconcileTerminalTaskRunDispatches(); + // Settle task/dispatch truth before the workflow-level audit row. Provider + // completion alone does not mean a CLI workflow completed because Git and + // PR finalization happen after the provider exits. + const reconciledTaskCodingInvocationIds = await invocationRecovery.reconcileInterruptedTaskCodingInvocations(activeContainerSessionIds); + const reconciledTaskCodingProviderIds = invocationRecovery.reconcileOrphanedTaskCodingProviderInvocations(); const reconciledDuplicateDispatchIds = this.reconcileDuplicateActiveTaskDispatches(); const reconciledTaskRunIds = this.reconcileInterruptedTaskRuns(); const reconciledPausedSprintRunIds = this.reconcileStalePausedSprintRuns(); diff --git a/tests/backend/integration/cli-invocation-lifecycle.test.ts b/tests/backend/integration/cli-invocation-lifecycle.test.ts new file mode 100644 index 0000000000..0e8b6a349f --- /dev/null +++ b/tests/backend/integration/cli-invocation-lifecycle.test.ts @@ -0,0 +1,711 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { DashboardSettings } from "../../../src/contracts/app-types.js"; +import type { ExecutionInvocationRecord } from "../../../src/contracts/invocation-types.js"; +import type { + IProviderRunner, + ProviderRunInput, + ProviderRunResult, +} from "../../../src/infrastructure/providers/cli/provider-runner.js"; +import type { ProviderUsageTelemetry } from "../../../src/infrastructure/providers/cli/provider-usage.js"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { ExecutionRepository } from "../../../src/repositories/execution-repository.js"; +import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { SessionTrackingRepository } from "../../../src/repositories/session-tracking-repository.js"; +import { DEFAULT_DASHBOARD_SETTINGS } from "../../../src/repositories/settings-defaults.js"; +import { ActiveDispatchRegistry } from "../../../src/services/active-dispatch-registry.js"; +import { CliWorkflowService } from "../../../src/services/cli-workflow-service.js"; +import { executeCleanupStage } from "../../../src/services/cli-workflow/pipeline/cleanup-stage.js"; +import { executeGitFinalizeStage } from "../../../src/services/cli-workflow/pipeline/git-finalize-stage.js"; +import { executePrFinalizeStage } from "../../../src/services/cli-workflow/pipeline/pr-finalize-stage.js"; +import { executePrepareStage } from "../../../src/services/cli-workflow/pipeline/prepare-stage.js"; + +vi.mock("../../../src/services/cli-workflow/pipeline/cleanup-stage.js"); +vi.mock("../../../src/services/cli-workflow/pipeline/git-finalize-stage.js"); +vi.mock("../../../src/services/cli-workflow/pipeline/pr-finalize-stage.js"); +vi.mock("../../../src/services/cli-workflow/pipeline/prepare-stage.js"); + +const PROVIDER_PROMPT = "Implement the deterministic lifecycle fixture."; +const PROVIDER_TRANSCRIPT = [ + "Lifecycle fixture completed without external provider access.", + "CODE_UX_TASK_OUTCOME: completed", +].join("\n"); +const PROVIDER_MODEL = "mockup-lifecycle-model"; +const PROVIDER_NATIVE_SESSION_ID = "native-lifecycle-session"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +interface WorkflowArgs { + provider: "mockup-cli"; + providerSettingsOverride: { + model: string; + thinkingMode: "MEDIUM"; + apiKey: string; + maxConcurrentTasks: number; + }; + task: { + id: string; + record_id: string; + prompt: string; + title: string; + }; + repoPath: string; + featureBranch: string; + sprintNumber: number; + settingsScope: { projectId: string; sprintId: string }; + sessionId: string; + dispatchId: string; + taskRunId: string; + workerBranch: string; + title: string; + resumeFromFailedSessionId?: string; +} + +interface LifecycleHarness { + storage: AppDbStorage; + sessionTracking: SessionTrackingRepository; + executionRepository: ExecutionRepository; + projectManagementRepository: ProjectManagementRepository; + activeDispatchRegistry: ActiveDispatchRegistry; + service: CliWorkflowService; + runProvider: ReturnType>; + logError: ReturnType; + projectId: string; + sprintId: string; + sprintRunId: string; + taskId: string; + taskKey: string; + taskTitle: string; + dispatchId: string; + taskRunId: string; + repoPath: string; + tempDir: string; +} + +const harnesses: LifecycleHarness[] = []; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function buildTelemetry(prompt = PROVIDER_PROMPT): ProviderUsageTelemetry { + return { + inputTokens: 17, + cachedInputTokens: 3, + outputTokens: 11, + reasoningOutputTokens: 2, + totalTokens: 31, + usageSource: "reported", + rawUsageJson: { fixture: "cli-invocation-lifecycle" }, + transcriptText: PROVIDER_TRANSCRIPT, + nativeSessionId: PROVIDER_NATIVE_SESSION_ID, + conversation: [ + { kind: "user", text: prompt }, + { kind: "assistant", text: PROVIDER_TRANSCRIPT }, + ], + }; +} + +function buildProviderResult(prompt = PROVIDER_PROMPT): ProviderRunResult { + return { + ok: true, + code: 0, + stdout: PROVIDER_TRANSCRIPT, + stderr: "", + nativeSessionId: PROVIDER_NATIVE_SESSION_ID, + usageTelemetry: buildTelemetry(prompt), + }; +} + +function buildSettings(): DashboardSettings { + const mockupCli = DEFAULT_DASHBOARD_SETTINGS.aiProvider.providers["mockup-cli"]; + const taskCodingRoute = DEFAULT_DASHBOARD_SETTINGS.aiProvider.invocationRouting.task_coding; + return { + ...DEFAULT_DASHBOARD_SETTINGS, + git: { + ...DEFAULT_DASHBOARD_SETTINGS.git, + githubMode: "LOCAL", + }, + cliWorkflow: { + ...DEFAULT_DASHBOARD_SETTINGS.cliWorkflow, + executionMode: "HOST", + gitMode: "local", + cleanupWorktreeOnSuccess: false, + cleanupWorktreeOnFailure: false, + }, + aiProvider: { + ...DEFAULT_DASHBOARD_SETTINGS.aiProvider, + provider: "mockup-cli", + strategy: "MANUAL", + providers: { + ...DEFAULT_DASHBOARD_SETTINGS.aiProvider.providers, + "mockup-cli": { + ...mockupCli, + enabled: true, + model: PROVIDER_MODEL, + maxConcurrentTasks: 1, + }, + }, + invocationRouting: { + ...DEFAULT_DASHBOARD_SETTINGS.aiProvider.invocationRouting, + task_coding: { + ...taskCodingRoute, + strategy: "MANUAL", + provider: "mockup-cli", + allowedProviders: ["mockup-cli"], + providers: { + "mockup-cli": { + enabled: true, + model: PROVIDER_MODEL, + weight: 100, + }, + }, + }, + }, + }, + }; +} + +async function createHarness(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-cli-invocation-lifecycle-")); + const repoPath = path.join(tempDir, "repo"); + await fs.mkdir(repoPath, { recursive: true }); + + const storage = new AppDbStorage(path.join(tempDir, "app.db")); + const sessionTracking = new SessionTrackingRepository(path.join(tempDir, "session-tracking.db")); + const executionRepository = new ExecutionRepository(storage); + const projectManagementRepository = new ProjectManagementRepository(storage); + const activeDispatchRegistry = new ActiveDispatchRegistry(); + const project = projectManagementRepository.createProject({ + name: "CLI invocation lifecycle fixture", + sourceType: "local", + sourceRef: repoPath, + defaultBranch: "main", + }); + const sprint = projectManagementRepository.createSprint(project.id, { + name: "Invocation lifecycle sprint", + goal: "Prove the persisted CLI invocation lifecycle.", + featureBranch: "feature/invocation-lifecycle", + }); + const task = projectManagementRepository.createTask(project.id, { + sprintId: sprint.id, + taskKey: "T01", + title: "Exercise the CLI lifecycle", + promptMarkdown: PROVIDER_PROMPT, + status: "in_progress", + }); + const sprintRun = executionRepository.createSprintRun({ + projectId: project.id, + sprintId: sprint.id, + executorMode: "docker_cli", + status: "running", + }); + const dispatch = executionRepository.createTaskDispatch({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + executorType: "docker_cli", + status: "running", + }); + const taskRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + dispatchId: dispatch.id, + provider: "mockup-cli", + mode: "docker_cli", + sessionId: "session-current", + sessionName: "sessions/session-current", + state: "RUNNING", + workerBranch: "task/invocation-lifecycle", + startedAt: new Date().toISOString(), + }); + + const runProvider = vi.fn(async (input: ProviderRunInput) => { + const result = buildProviderResult(input.prompt); + input.onTelemetry?.(result.usageTelemetry); + return result; + }); + const providerRunner: IProviderRunner = { + runProvider, + runProviderForText: vi.fn(async (input: ProviderRunInput) => ({ + ...buildProviderResult(input.prompt), + text: PROVIDER_TRANSCRIPT, + })), + }; + const logError = vi.fn(); + const service = new CliWorkflowService({ + sessionTracking, + executionRepository, + projectManagementRepository, + activeDispatchRegistry, + getDashboardSettings: () => buildSettings(), + agentPresetSyncService: { + resolveTargetedCodingAgent: vi.fn().mockResolvedValue(null), + getOptionalWorkerAgentForRepoPath: vi.fn().mockResolvedValue(null), + }, + getGithubToken: () => undefined, + sprintRunLifecycleService: { + finalizeCancellationIfIdle: vi.fn(), + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: logError, + child: vi.fn(), + }, + }); + (service as unknown as { providerRunner: IProviderRunner }).providerRunner = providerRunner; + + const harness: LifecycleHarness = { + storage, + sessionTracking, + executionRepository, + projectManagementRepository, + activeDispatchRegistry, + service, + runProvider, + logError, + projectId: project.id, + sprintId: sprint.id, + sprintRunId: sprintRun.id, + taskId: task.id, + taskKey: task.taskKey, + taskTitle: task.title, + dispatchId: dispatch.id, + taskRunId: taskRun.id, + repoPath, + tempDir, + }; + harnesses.push(harness); + return harness; +} + +function startWorkflow( + harness: LifecycleHarness, + options: { sessionId?: string; resumeFromFailedSessionId?: string } = {}, +): Promise { + const sessionId = options.sessionId ?? "session-current"; + harness.sessionTracking.createSession({ + id: sessionId, + provider: "mockup-cli", + taskId: harness.taskKey, + title: harness.taskTitle, + prompt: PROVIDER_PROMPT, + state: "RUNNING", + featureBranch: "feature/invocation-lifecycle", + workerBranch: "task/invocation-lifecycle", + repoPath: harness.repoPath, + }); + const args: WorkflowArgs = { + provider: "mockup-cli", + providerSettingsOverride: { + model: PROVIDER_MODEL, + thinkingMode: "MEDIUM", + apiKey: "", + maxConcurrentTasks: 1, + }, + task: { + id: harness.taskKey, + record_id: harness.taskId, + prompt: PROVIDER_PROMPT, + title: harness.taskTitle, + }, + repoPath: harness.repoPath, + featureBranch: "feature/invocation-lifecycle", + sprintNumber: 1, + settingsScope: { projectId: harness.projectId, sprintId: harness.sprintId }, + sessionId, + dispatchId: harness.dispatchId, + taskRunId: harness.taskRunId, + workerBranch: "task/invocation-lifecycle", + title: harness.taskTitle, + resumeFromFailedSessionId: options.resumeFromFailedSessionId, + }; + + return (harness.service as unknown as { + runTaskWorkflow: (input: WorkflowArgs) => Promise; + }).runTaskWorkflow(args); +} + +function queryTaskInvocations(harness: LifecycleHarness): ExecutionInvocationRecord[] { + return harness.executionRepository.queryProjectInvocations({ + projectId: harness.projectId, + limit: 50, + sortKey: "startedAt", + sortDir: "desc", + }).items.filter((invocation) => invocation.taskId === harness.taskId); +} + +async function waitForPreparationStart( + harness: LifecycleHarness, + preparationStarted: Promise, + workflow: Promise, +): Promise { + await Promise.race([ + preparationStarted, + workflow.then(() => { + throw new Error(`Workflow settled before preparation started: ${JSON.stringify(harness.logError.mock.calls)}`); + }), + ]); +} + +function expectTerminalAudit( + harness: LifecycleHarness, + invocationId: string, + status: "completed" | "failed" | "cancelled", + text: string, +): void { + const auditMessages = harness.executionRepository + .listExecutionInvocationMessages(invocationId) + .filter((message) => message.metadata?.kind === "cli_workflow_finalized"); + expect(auditMessages).toHaveLength(1); + expect(auditMessages[0]).toMatchObject({ + role: "system", + contentMarkdown: expect.stringContaining(text), + metadata: expect.objectContaining({ status }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(executeCleanupStage).mockResolvedValue({ cleanedUp: false }); + vi.mocked(executeGitFinalizeStage).mockResolvedValue({ + hasChanges: true, + committedChanges: true, + pushedBranch: "task/invocation-lifecycle", + }); + vi.mocked(executePrFinalizeStage).mockResolvedValue({ + prUrl: "https://example.test/pull/1", + }); +}); + +afterEach(async () => { + for (const harness of harnesses.splice(0)) { + harness.sessionTracking.getDatabase().close(); + harness.storage.close(); + await fs.rm(harness.tempDir, { recursive: true, force: true }); + } +}); + +describe("CLI invocation lifecycle integration", () => { + it("fans in preparation, provider usage, transcript, and workflow finalization into one invocation", async () => { + const harness = await createHarness(); + const preparationStarted = createDeferred(); + const releasePreparation = createDeferred(); + vi.mocked(executePrepareStage).mockImplementation(async () => { + preparationStarted.resolve(); + await releasePreparation.promise; + return { providerPrompt: PROVIDER_PROMPT, resumed: false }; + }); + + const workflow = startWorkflow(harness); + await waitForPreparationStart(harness, preparationStarted.promise, workflow); + + const earlyInvocations = queryTaskInvocations(harness); + expect(earlyInvocations).toHaveLength(1); + const [earlyInvocation] = earlyInvocations; + expect(earlyInvocation).toMatchObject({ + projectId: harness.projectId, + sprintId: harness.sprintId, + taskId: harness.taskId, + sprintRunId: harness.sprintRunId, + dispatchId: harness.dispatchId, + taskRunId: harness.taskRunId, + providerInvocationId: null, + type: "cli_task_coding", + status: "running", + provider: "mockup-cli", + model: PROVIDER_MODEL, + invocationSource: "internal", + }); + expect(earlyInvocation.id).toEqual(expect.any(String)); + expect(earlyInvocation.finishedAt).toBeNull(); + expect(harness.executionRepository.listProviderInvocationsForTask( + harness.projectId, + harness.taskId, + )).toEqual([]); + expect(harness.executionRepository.listExecutionInvocationMessages(earlyInvocation.id)).toEqual([ + expect.objectContaining({ + role: "system", + contentMarkdown: "Preparing the task workspace and mockup-cli configuration.", + metadata: expect.objectContaining({ kind: "preparation_started", model: PROVIDER_MODEL }), + }), + ]); + + releasePreparation.resolve(); + await workflow; + + const terminalInvocations = queryTaskInvocations(harness); + expect(terminalInvocations).toHaveLength(1); + const [terminalInvocation] = terminalInvocations; + expect(terminalInvocation.id).toBe(earlyInvocation.id); + expect(terminalInvocation).toMatchObject({ + status: "completed", + finishedAt: expect.any(String), + errorMessage: null, + lastErrorMessage: null, + providerInvocationId: expect.any(String), + executionMode: "HOST", + inputTokens: 17, + cachedInputTokens: 3, + outputTokens: 11, + totalTokens: 31, + }); + expect(harness.executionRepository.queryProjectInvocations({ + projectId: harness.projectId, + purpose: "task_coding", + limit: 50, + }).items.map((invocation) => invocation.id)).toEqual([terminalInvocation.id]); + + const providerInvocations = harness.executionRepository.listProviderInvocationsForTask( + harness.projectId, + harness.taskId, + ); + expect(providerInvocations).toHaveLength(1); + expect(providerInvocations[0]).toMatchObject({ + id: terminalInvocation.providerInvocationId, + projectId: harness.projectId, + sprintId: harness.sprintId, + taskId: harness.taskId, + sprintRunId: harness.sprintRunId, + dispatchId: harness.dispatchId, + taskRunId: harness.taskRunId, + sessionId: "session-current", + provider: "mockup-cli", + purpose: "task_coding", + status: "completed", + model: PROVIDER_MODEL, + executionMode: "HOST", + nativeSessionId: PROVIDER_NATIVE_SESSION_ID, + finishedAt: expect.any(String), + inputTokens: 17, + cachedInputTokens: 3, + outputTokens: 11, + reasoningOutputTokens: 2, + totalTokens: 31, + }); + expect(harness.runProvider).toHaveBeenCalledOnce(); + + const messages = harness.executionRepository.listExecutionInvocationMessages(terminalInvocation.id); + expect(messages).toHaveLength(4); + expect(messages.map(({ role, contentMarkdown }) => ({ role, contentMarkdown }))).toEqual([ + { + role: "system", + contentMarkdown: "Preparing the task workspace and mockup-cli configuration.", + }, + { role: "user", contentMarkdown: PROVIDER_PROMPT }, + { role: "assistant", contentMarkdown: PROVIDER_TRANSCRIPT }, + { role: "system", contentMarkdown: "CLI workflow completed successfully." }, + ]); + expectTerminalAudit(harness, terminalInvocation.id, "completed", "completed successfully"); + expect(harness.executionRepository.getTaskRun(harness.taskRunId)).toMatchObject({ + state: "COMPLETED", + finishedAt: expect.any(String), + prUrl: "https://example.test/pull/1", + }); + expect(harness.executionRepository.getTaskDispatch(harness.dispatchId)).toMatchObject({ + status: "completed", + finishedAt: expect.any(String), + }); + }); + + it("settles an early preparation failure with one failed audit row and no provider claim", async () => { + const harness = await createHarness(); + const preparationStarted = createDeferred(); + const releaseFailure = createDeferred(); + vi.mocked(executePrepareStage).mockImplementation(async () => { + preparationStarted.resolve(); + await releaseFailure.promise; + throw new Error("Deterministic preparation failure"); + }); + + const workflow = startWorkflow(harness); + await waitForPreparationStart(harness, preparationStarted.promise, workflow); + const [earlyInvocation] = queryTaskInvocations(harness); + expect(earlyInvocation).toMatchObject({ status: "running", providerInvocationId: null }); + + releaseFailure.resolve(); + await workflow; + + const invocations = queryTaskInvocations(harness); + expect(invocations).toHaveLength(1); + expect(invocations[0]).toMatchObject({ + id: earlyInvocation.id, + status: "failed", + finishedAt: expect.any(String), + errorMessage: "Deterministic preparation failure", + lastErrorMessage: "Deterministic preparation failure", + providerInvocationId: null, + }); + expectTerminalAudit(harness, earlyInvocation.id, "failed", "Deterministic preparation failure"); + expect(harness.executionRepository.listProviderInvocationsForTask( + harness.projectId, + harness.taskId, + )).toEqual([]); + expect(harness.runProvider).not.toHaveBeenCalled(); + }); + + it("settles preparation cancellation once and prevents a late provider claim", async () => { + const harness = await createHarness(); + const preparationStarted = createDeferred(); + const releasePreparation = createDeferred(); + vi.mocked(executePrepareStage).mockImplementation(async (ctx) => { + preparationStarted.resolve(); + await releasePreparation.promise; + if (ctx.abortSignal.aborted) { + throw new Error("Command aborted"); + } + return { providerPrompt: PROVIDER_PROMPT, resumed: false }; + }); + + const workflow = startWorkflow(harness); + await waitForPreparationStart(harness, preparationStarted.promise, workflow); + const [earlyInvocation] = queryTaskInvocations(harness); + expect(earlyInvocation).toMatchObject({ status: "running", providerInvocationId: null }); + + await harness.activeDispatchRegistry.requestStop(harness.dispatchId, "dashboard_cancel"); + releasePreparation.resolve(); + await workflow; + + const invocations = queryTaskInvocations(harness); + expect(invocations).toHaveLength(1); + expect(invocations[0]).toMatchObject({ + id: earlyInvocation.id, + status: "cancelled", + finishedAt: expect.any(String), + errorMessage: "Workflow cancelled by dashboard control.", + lastErrorMessage: "Workflow cancelled by dashboard control.", + providerInvocationId: null, + }); + expectTerminalAudit(harness, earlyInvocation.id, "cancelled", "cancelled"); + expect(harness.executionRepository.listProviderInvocationsForTask( + harness.projectId, + harness.taskId, + )).toEqual([]); + expect(harness.runProvider).not.toHaveBeenCalled(); + expect(harness.executionRepository.getTaskDispatch(harness.dispatchId)).toMatchObject({ + status: "cancelled", + finishedAt: expect.any(String), + }); + }); + + it("recovers completed provider work without a second claim or a stale running workflow row", async () => { + const harness = await createHarness(); + const priorDispatch = harness.executionRepository.createTaskDispatch({ + projectId: harness.projectId, + sprintId: harness.sprintId, + taskId: harness.taskId, + sprintRunId: harness.sprintRunId, + executorType: "docker_cli", + status: "failed", + }); + const priorTaskRun = harness.executionRepository.createTaskRun({ + projectId: harness.projectId, + sprintId: harness.sprintId, + taskId: harness.taskId, + sprintRunId: harness.sprintRunId, + dispatchId: priorDispatch.id, + provider: "mockup-cli", + mode: "docker_cli", + sessionId: "session-prior", + sessionName: "sessions/session-prior", + state: "FAILED", + workerBranch: "task/invocation-lifecycle", + startedAt: "2026-07-13T00:00:00.000Z", + finishedAt: "2026-07-13T00:01:00.000Z", + }); + const recoveredProvider = harness.executionRepository.createProviderInvocationUsage({ + projectId: harness.projectId, + sprintId: harness.sprintId, + taskId: harness.taskId, + sprintRunId: harness.sprintRunId, + dispatchId: priorDispatch.id, + taskRunId: priorTaskRun.id, + sessionId: "session-prior", + provider: "mockup-cli", + purpose: "task_coding", + status: "running", + model: PROVIDER_MODEL, + executionMode: "HOST", + startedAt: "2026-07-13T00:00:00.000Z", + }); + harness.executionRepository.updateProviderInvocationUsage(recoveredProvider.id, { + status: "completed", + nativeSessionId: "native-recovered-session", + finishedAt: "2026-07-13T00:00:30.000Z", + totalTokens: 19, + usageSource: "reported", + }); + harness.executionRepository.appendTaskRunEvent( + priorTaskRun.id, + "task_dispatch_reconciled", + "system", + { + reason: "terminal_provider_active_dispatch_mismatch", + providerStatus: "completed", + }, + ); + harness.executionRepository.appendTaskRunEvent( + priorTaskRun.id, + "cli_workspace_bound", + "system", + { workspaceSessionId: "session-prior" }, + ); + vi.mocked(executePrepareStage).mockResolvedValue({ + providerPrompt: PROVIDER_PROMPT, + resumed: true, + }); + + await startWorkflow(harness, { + sessionId: "session-current", + resumeFromFailedSessionId: "session-prior", + }); + + const providerInvocations = harness.executionRepository.listProviderInvocationsForTask( + harness.projectId, + harness.taskId, + ); + expect(providerInvocations).toHaveLength(1); + expect(providerInvocations[0]).toMatchObject({ + id: recoveredProvider.id, + status: "completed", + nativeSessionId: "native-recovered-session", + }); + expect(harness.runProvider).not.toHaveBeenCalled(); + + const invocations = queryTaskInvocations(harness); + expect(invocations).toHaveLength(1); + expect(invocations[0]).toMatchObject({ + type: "cli_task_coding", + status: "completed", + finishedAt: expect.any(String), + providerInvocationId: null, + }); + expect(invocations.some((invocation) => invocation.status === "running")).toBe(false); + expectTerminalAudit(harness, invocations[0]!.id, "completed", "completed successfully"); + expect(harness.executionRepository.listTaskRunEvents(harness.taskRunId)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "cli_provider_completion_recovered", + payload: expect.objectContaining({ recoveredProviderInvocationId: recoveredProvider.id }), + }), + ]), + ); + }); +}); diff --git a/tests/backend/repositories/execution-repository.test.ts b/tests/backend/repositories/execution-repository.test.ts index b50fc440d7..6dd8ba1566 100644 --- a/tests/backend/repositories/execution-repository.test.ts +++ b/tests/backend/repositories/execution-repository.test.ts @@ -1113,6 +1113,111 @@ describe("ExecutionRepository", () => { } }); + it("schedules project-scoped refreshes for coding invocation creation and preparation messages", async () => { + vi.useFakeTimers(); + try { + const notifier = { + scheduleProjectExecutionRefresh: vi.fn(), + }; + const { projectRepository, executionRepository } = await createRepositoriesWithRealtimeNotifier(notifier); + const project = projectRepository.createProject({ + name: "Preparation Invocation Project", + sourceType: "local", + sourceRef: "/workspace/preparation-invocation-project", + }); + const sprint = projectRepository.createSprint(project.id, { + name: "Preparation Sprint", + number: 1, + }); + const task = projectRepository.createTask(project.id, { + sprintId: sprint.id, + taskKey: "T01", + title: "Prepare coding invocation", + promptMarkdown: "Persist the invocation before preparation.", + }); + const sprintRun = executionRepository.createSprintRun({ + projectId: project.id, + sprintId: sprint.id, + status: "running", + executorMode: "docker_cli", + }); + const dispatch = executionRepository.createTaskDispatch({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + executorType: "docker_cli", + status: "running", + }); + const taskRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + dispatchId: dispatch.id, + provider: "codex", + mode: "docker_cli", + state: "RUNNING", + }); + + vi.runOnlyPendingTimers(); + await Promise.resolve(); + notifier.scheduleProjectExecutionRefresh.mockClear(); + + const invocation = executionRepository.createExecutionInvocation({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + dispatchId: dispatch.id, + taskRunId: taskRun.id, + type: "cli_task_coding", + status: "running", + provider: "codex", + model: "gpt-preparation-test", + invocationSource: "internal", + }); + + expect(executionRepository.getExecutionInvocation(invocation.id)).toMatchObject({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + dispatchId: dispatch.id, + taskRunId: taskRun.id, + type: "cli_task_coding", + status: "running", + provider: "codex", + model: "gpt-preparation-test", + invocationSource: "internal", + }); + + vi.runOnlyPendingTimers(); + await Promise.resolve(); + expect(notifier.scheduleProjectExecutionRefresh).toHaveBeenCalledOnce(); + expect(notifier.scheduleProjectExecutionRefresh).toHaveBeenLastCalledWith( + project.id, + expect.objectContaining({ includeOverview: true }), + ); + + notifier.scheduleProjectExecutionRefresh.mockClear(); + executionRepository.appendExecutionInvocationMessage(invocation.id, { + role: "system", + contentMarkdown: "Preparing the task workspace and codex configuration.", + }); + + vi.runOnlyPendingTimers(); + await Promise.resolve(); + expect(notifier.scheduleProjectExecutionRefresh).toHaveBeenCalledOnce(); + expect(notifier.scheduleProjectExecutionRefresh).toHaveBeenLastCalledWith( + project.id, + expect.objectContaining({ includeOverview: false }), + ); + } finally { + vi.useRealTimers(); + } + }); + it("does not drop coalesced refreshes across projects", async () => { vi.useFakeTimers(); try { diff --git a/tests/backend/services/cli-workflow-service.test.ts b/tests/backend/services/cli-workflow-service.test.ts index b99c997314..e72d20ec89 100644 --- a/tests/backend/services/cli-workflow-service.test.ts +++ b/tests/backend/services/cli-workflow-service.test.ts @@ -6,6 +6,7 @@ import { executeGitFinalizeStage } from "../../../src/services/cli-workflow/pipe import { executePrFinalizeStage } from "../../../src/services/cli-workflow/pipeline/pr-finalize-stage.js"; import { executeCleanupStage } from "../../../src/services/cli-workflow/pipeline/cleanup-stage.js"; import { ActiveDispatchRegistry, SERVER_SHUTDOWN_STOP_REASON } from "../../../src/services/active-dispatch-registry.js"; +import { DEFAULT_DASHBOARD_SETTINGS } from "../../../src/repositories/settings-defaults.js"; vi.mock("../../../src/services/cli-workflow/pipeline/prepare-stage.js"); vi.mock("../../../src/services/cli-workflow/pipeline/execute-provider-stage.js"); @@ -108,10 +109,174 @@ describe("CliWorkflowService unpushed commit detection", () => { vi.mocked(executeProviderStage).mockResolvedValue(buildProviderStageResult()); }); + it("persists the scoped coding invocation after cancellation registration and before preparation", async () => { + const callOrder: string[] = []; + let storedInvocation: Record | null = null; + let releasePreparation!: () => void; + let reportPreparationStarted!: () => void; + const preparationGate = new Promise((resolve) => { + releasePreparation = resolve; + }); + const preparationStarted = new Promise((resolve) => { + reportPreparationStarted = resolve; + }); + const taskRun = { + id: "task-run-1", + projectId: "project-1", + sprintId: "sprint-1", + taskId: "task-1", + sprintRunId: "sprint-run-1", + dispatchId: "dispatch-1", + startedAt: "2026-07-13T00:00:00.000Z", + prUrl: null, + workerBranch: null, + }; + const executionRepository = { + getTaskRun: vi.fn().mockReturnValue(taskRun), + getExecutionInvocation: vi.fn().mockImplementation((id: string) => ( + storedInvocation?.id === id ? storedInvocation : null + )), + createExecutionInvocation: vi.fn().mockImplementation((input: Record) => { + callOrder.push("persist_invocation"); + storedInvocation = { ...input, id: "xi-preparation" }; + return storedInvocation; + }), + updateExecutionInvocation: vi.fn().mockImplementation((_id: string, input: Record) => { + Object.assign(storedInvocation!, input); + return storedInvocation; + }), + appendExecutionInvocationMessage: vi.fn().mockImplementation(() => { + callOrder.push("persist_message"); + }), + createProviderInvocationUsage: vi.fn(), + appendTaskRunEvent: vi.fn().mockImplementation((_taskRunId: string, eventType: string) => { + if (eventType === "cli_prepare_started") { + callOrder.push("prepare_event"); + } + }), + updateTaskRun: vi.fn(), + updateTaskDispatch: vi.fn(), + getSprintRun: vi.fn().mockReturnValue({ status: "running" }), + }; + const activeDispatchRegistry = { + register: vi.fn().mockImplementation(() => { + callOrder.push("register_dispatch"); + return vi.fn(); + }), + }; + const workerAgent = { + id: "agent-preset-1", + instructionMarkdown: "Worker guide", + }; + const deps = { + sessionTracking: { + appendActivity: vi.fn(), + updateSession: vi.fn(), + }, + executionRepository, + activeDispatchRegistry, + getDashboardSettings: vi.fn().mockReturnValue(DEFAULT_DASHBOARD_SETTINGS), + agentPresetSyncService: { + resolveTargetedCodingAgent: vi.fn().mockResolvedValue(workerAgent), + getOptionalWorkerAgentForRepoPath: vi.fn().mockResolvedValue(workerAgent), + }, + getGithubToken: vi.fn().mockReturnValue(undefined), + sprintRunLifecycleService: { finalizeCancellationIfIdle: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn() }, + }; + const service = new CliWorkflowService(deps as any); + + vi.mocked(executePrepareStage).mockImplementation(async () => { + callOrder.push("prepare_stage"); + reportPreparationStarted(); + await preparationGate; + return { providerPrompt: "mock prompt" } as any; + }); + vi.mocked(executeProviderStage).mockResolvedValue(buildProviderStageResult( + "No repository changes were required.\nCODE_UX_TASK_OUTCOME: completed", + )); + vi.mocked(executeGitFinalizeStage).mockImplementation(async () => { + expect(storedInvocation).toMatchObject({ status: "running" }); + return { hasChanges: true, committedChanges: true, pushedBranch: "worker-1" }; + }); + vi.mocked(executePrFinalizeStage).mockImplementation(async () => { + expect(storedInvocation).toMatchObject({ status: "running" }); + return { prUrl: "https://example.test/pull/1" }; + }); + vi.mocked(executeCleanupStage).mockResolvedValue({ cleanedUp: false }); + + const workflow = (service as any).runTaskWorkflow({ + provider: "codex", + providerSettingsOverride: { + model: "gpt-preparation-test", + thinkingMode: "medium", + apiKey: "", + maxConcurrentTasks: 2, + }, + task: { id: "T1", record_id: "task-1", prompt: "prompt", title: "title" }, + repoPath: "/repo", + featureBranch: "feature/sprint-1", + sprintNumber: 1, + settingsScope: { projectId: "project-1", sprintId: "sprint-1" }, + sessionId: "session-1", + dispatchId: "dispatch-1", + taskRunId: "task-run-1", + workerBranch: "worker-1", + title: "Title", + }); + + await preparationStarted; + + expect(callOrder).toEqual([ + "register_dispatch", + "persist_invocation", + "persist_message", + "prepare_event", + "prepare_stage", + ]); + expect(executionRepository.getExecutionInvocation("xi-preparation")).toMatchObject({ + projectId: "project-1", + sprintId: "sprint-1", + taskId: "task-1", + sprintRunId: "sprint-run-1", + dispatchId: "dispatch-1", + taskRunId: "task-run-1", + type: "cli_task_coding", + status: "running", + provider: "codex", + model: "gpt-preparation-test", + invocationSource: "internal", + agentPresetId: "agent-preset-1", + }); + expect(executionRepository.createExecutionInvocation).toHaveBeenCalledOnce(); + expect(executionRepository.createProviderInvocationUsage).not.toHaveBeenCalled(); + expect(executionRepository.appendExecutionInvocationMessage).toHaveBeenCalledWith( + "xi-preparation", + expect.objectContaining({ + role: "system", + contentMarkdown: "Preparing the task workspace and codex configuration.", + }), + ); + + releasePreparation(); + await workflow; + + expect(executeProviderStage).toHaveBeenCalledWith( + expect.objectContaining({ executionInvocationId: "xi-preparation" }), + "mock prompt", + ); + expect(storedInvocation).toMatchObject({ status: "completed" }); + }); + it("runs task workflow pipeline and handles error", async () => { + let storedInvocation: Record | null = null; const executionRepository = { getTaskRun: vi.fn().mockReturnValue({ id: "run-1", + projectId: "project-1", + sprintId: "sprint-1", + taskId: "task-1", + sprintRunId: null, dispatchId: "dispatch-1", startedAt: "2026-03-10T00:00:00.000Z", prUrl: null, @@ -122,6 +287,18 @@ describe("CliWorkflowService unpushed commit detection", () => { updateTaskRun: vi.fn(), updateTaskDispatch: vi.fn(), getSprintRun: vi.fn().mockReturnValue(null), + createExecutionInvocation: vi.fn().mockImplementation((input: Record) => { + storedInvocation = { ...input, id: "xi-preparation-failure" }; + return storedInvocation; + }), + getExecutionInvocation: vi.fn().mockImplementation((id: string) => ( + storedInvocation?.id === id ? storedInvocation : null + )), + updateExecutionInvocation: vi.fn().mockImplementation((_id: string, input: Record) => { + Object.assign(storedInvocation!, input); + return storedInvocation; + }), + appendExecutionInvocationMessage: vi.fn(), }; const deps = { sessionTracking: { @@ -130,7 +307,7 @@ describe("CliWorkflowService unpushed commit detection", () => { appendActivity: vi.fn(), updateSession: vi.fn(), }, - getDashboardSettings: vi.fn().mockReturnValue({ cliWorkflow: { containerImage: " " } }), + getDashboardSettings: vi.fn().mockReturnValue(DEFAULT_DASHBOARD_SETTINGS), agentPresetSyncService: { getOptionalWorkerAgentForRepoPath: vi.fn().mockResolvedValue({ instructionMarkdown: "guide" }) }, getGithubToken: vi.fn().mockReturnValue("token"), executionRepository, @@ -179,13 +356,32 @@ describe("CliWorkflowService unpushed commit detection", () => { "dispatch-1", expect.objectContaining({ status: "failed", errorMessage: "Stage failed" }), ); + expect(storedInvocation).toMatchObject({ + status: "failed", + finishedAt: expect.any(String), + errorMessage: "Stage failed", + lastErrorMessage: "Stage failed", + }); + expect(executionRepository.appendExecutionInvocationMessage).toHaveBeenCalledWith( + "xi-preparation-failure", + expect.objectContaining({ + role: "system", + contentMarkdown: "CLI workflow failed: Stage failed", + metadata: expect.objectContaining({ kind: "cli_workflow_finalized", status: "failed" }), + }), + ); expect(deps.logger.error).toHaveBeenCalled(); }); it("preserves running task state and workspace when server shutdown aborts workflow", async () => { + let storedInvocation: Record | null = null; const executionRepository = { getTaskRun: vi.fn().mockReturnValue({ id: "run-1", + projectId: "project-1", + sprintId: "sprint-1", + taskId: "task-1", + sprintRunId: null, dispatchId: "dispatch-1", startedAt: "2026-03-10T00:00:00.000Z", prUrl: null, @@ -196,6 +392,15 @@ describe("CliWorkflowService unpushed commit detection", () => { updateTaskRun: vi.fn(), updateTaskDispatch: vi.fn(), getSprintRun: vi.fn().mockReturnValue(null), + createExecutionInvocation: vi.fn().mockImplementation((input: Record) => { + storedInvocation = { ...input, id: "xi-shutdown" }; + return storedInvocation; + }), + getExecutionInvocation: vi.fn().mockImplementation((id: string) => ( + storedInvocation?.id === id ? storedInvocation : null + )), + updateExecutionInvocation: vi.fn(), + appendExecutionInvocationMessage: vi.fn(), }; const activeDispatchRegistry = new ActiveDispatchRegistry(); const deps = { @@ -206,7 +411,9 @@ describe("CliWorkflowService unpushed commit detection", () => { updateSession: vi.fn(), }, getDashboardSettings: vi.fn().mockReturnValue({ + ...DEFAULT_DASHBOARD_SETTINGS, cliWorkflow: { + ...DEFAULT_DASHBOARD_SETTINGS.cliWorkflow, containerImage: "node:24-bookworm-slim", executionMode: "DOCKER", cleanupWorktreeOnFailure: true, @@ -216,6 +423,7 @@ describe("CliWorkflowService unpushed commit detection", () => { getGithubToken: vi.fn().mockReturnValue("token"), executionRepository, activeDispatchRegistry, + sprintRunLifecycleService: { finalizeCancellationIfIdle: vi.fn() }, logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, }; const service = new CliWorkflowService(deps as any); @@ -242,6 +450,12 @@ describe("CliWorkflowService unpushed commit detection", () => { expect(deps.sessionTracking.updateSession).not.toHaveBeenCalledWith("sess-1", { state: "CANCELLED" }); expect(executionRepository.updateTaskRun).not.toHaveBeenCalled(); expect(executionRepository.updateTaskDispatch).not.toHaveBeenCalled(); + expect(storedInvocation).toMatchObject({ + id: "xi-shutdown", + status: "running", + }); + expect(storedInvocation).not.toHaveProperty("finishedAt", expect.any(String)); + expect(executionRepository.updateExecutionInvocation).not.toHaveBeenCalled(); expect(executeCleanupStage).not.toHaveBeenCalled(); expect(executionRepository.appendTaskRunEvent).toHaveBeenCalledWith( "run-1", @@ -259,6 +473,113 @@ describe("CliWorkflowService unpushed commit detection", () => { ); }); + it("cancels during preparation once and ignores late workflow finalizers", async () => { + let storedInvocation: Record | null = null; + const taskRun = { + id: "run-cancel-preparation", + projectId: "project-1", + sprintId: "sprint-1", + taskId: "task-1", + sprintRunId: null, + dispatchId: "dispatch-cancel-preparation", + startedAt: "2026-07-13T00:00:00.000Z", + prUrl: null, + workerBranch: null, + }; + const executionRepository = { + getTaskRun: vi.fn().mockReturnValue(taskRun), + getLatestTaskRunBySessionId: vi.fn(), + appendTaskRunEvent: vi.fn(), + updateTaskRun: vi.fn(), + updateTaskDispatch: vi.fn(), + getSprintRun: vi.fn().mockReturnValue(null), + createExecutionInvocation: vi.fn().mockImplementation((input: Record) => { + storedInvocation = { ...input, id: "xi-cancel-preparation" }; + return storedInvocation; + }), + getExecutionInvocation: vi.fn().mockImplementation((id: string) => ( + storedInvocation?.id === id ? storedInvocation : null + )), + updateExecutionInvocation: vi.fn().mockImplementation((_id: string, input: Record) => { + Object.assign(storedInvocation!, input); + return storedInvocation; + }), + appendExecutionInvocationMessage: vi.fn(), + }; + const activeDispatchRegistry = new ActiveDispatchRegistry(); + const deps = { + sessionTracking: { + appendActivity: vi.fn(), + updateSession: vi.fn(), + }, + executionRepository, + activeDispatchRegistry, + getDashboardSettings: vi.fn().mockReturnValue(DEFAULT_DASHBOARD_SETTINGS), + agentPresetSyncService: { getOptionalWorkerAgentForRepoPath: vi.fn().mockResolvedValue(null) }, + getGithubToken: vi.fn().mockReturnValue(undefined), + sprintRunLifecycleService: { finalizeCancellationIfIdle: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn() }, + }; + const service = new CliWorkflowService(deps as any); + + vi.mocked(executePrepareStage).mockImplementation(async () => { + await activeDispatchRegistry.requestStop("dispatch-cancel-preparation", "dashboard_cancel"); + throw new Error("Command aborted"); + }); + vi.mocked(executeCleanupStage).mockResolvedValue({ cleanedUp: false }); + + await (service as any).runTaskWorkflow({ + provider: "codex", + task: { id: "T1", record_id: "task-1", prompt: "prompt", title: "title" }, + repoPath: "/repo", + featureBranch: "main", + sprintNumber: 1, + sessionId: "session-cancel-preparation", + dispatchId: "dispatch-cancel-preparation", + taskRunId: "run-cancel-preparation", + workerBranch: "worker-1", + title: "Title", + }); + + expect(storedInvocation).toMatchObject({ + status: "cancelled", + finishedAt: expect.any(String), + errorMessage: "Workflow cancelled by dashboard control.", + lastErrorMessage: "Workflow cancelled by dashboard control.", + }); + expect(executionRepository.updateTaskDispatch).toHaveBeenCalledWith( + "dispatch-cancel-preparation", + expect.objectContaining({ status: "cancelled" }), + ); + const finalAuditCalls = executionRepository.appendExecutionInvocationMessage.mock.calls.filter(([, input]) => ( + input.metadata?.kind === "cli_workflow_finalized" + )); + expect(finalAuditCalls).toHaveLength(1); + + const invocationUpdateCount = executionRepository.updateExecutionInvocation.mock.calls.length; + const taskRunUpdateCount = executionRepository.updateTaskRun.mock.calls.length; + (service as any).finalizeExecutionInvocation( + "xi-cancel-preparation", + "completed", + "2026-07-13T00:05:00.000Z", + ); + (service as any).updateExecutionState({ + taskRunId: taskRun.id, + sessionId: "session-cancel-preparation", + workerBranch: "worker-1", + }, { + state: "COMPLETED", + finishedAt: "2026-07-13T00:05:00.000Z", + dispatchStatus: "completed", + }, "xi-cancel-preparation"); + + expect(executionRepository.updateExecutionInvocation).toHaveBeenCalledTimes(invocationUpdateCount); + expect(executionRepository.updateTaskRun).toHaveBeenCalledTimes(taskRunUpdateCount); + expect(executionRepository.appendExecutionInvocationMessage.mock.calls.filter(([, input]) => ( + input.metadata?.kind === "cli_workflow_finalized" + ))).toHaveLength(1); + }); + it("blocks unrecoverable git credential failures instead of leaving the task retryable", async () => { const executionRepository = { getTaskRun: vi.fn().mockReturnValue({ @@ -457,11 +778,14 @@ describe("CliWorkflowService unpushed commit detection", () => { }); it("resumes Git finalization without invoking the provider twice after a restart crash window", async () => { + let storedInvocation: Record | null = null; const executionRepository = { getTaskRun: vi.fn().mockReturnValue({ id: "current-run", projectId: "project-1", + sprintId: "sprint-1", taskId: "task-1", + sprintRunId: "sprint-run-1", dispatchId: "current-dispatch", startedAt: "2026-07-11T00:25:28.000Z", prUrl: null, @@ -497,7 +821,20 @@ describe("CliWorkflowService unpushed commit detection", () => { appendTaskRunEvent: vi.fn(), updateTaskRun: vi.fn(), updateTaskDispatch: vi.fn(), - getSprintRun: vi.fn().mockReturnValue(null), + getSprintRun: vi.fn().mockReturnValue({ status: "running" }), + createExecutionInvocation: vi.fn().mockImplementation((input: Record) => { + storedInvocation = { ...input, id: "current-recovered-workflow" }; + return storedInvocation; + }), + getExecutionInvocation: vi.fn().mockImplementation((id: string) => ( + storedInvocation?.id === id ? storedInvocation : null + )), + updateExecutionInvocation: vi.fn().mockImplementation((_id: string, input: Record) => { + Object.assign(storedInvocation!, input); + return storedInvocation; + }), + appendExecutionInvocationMessage: vi.fn(), + createProviderInvocationUsage: vi.fn(), }; const deps = { sessionTracking: { @@ -506,7 +843,7 @@ describe("CliWorkflowService unpushed commit detection", () => { appendActivity: vi.fn(), updateSession: vi.fn(), }, - getDashboardSettings: vi.fn().mockReturnValue({ cliWorkflow: { containerImage: " " } }), + getDashboardSettings: vi.fn().mockReturnValue(DEFAULT_DASHBOARD_SETTINGS), agentPresetSyncService: { getOptionalWorkerAgentForRepoPath: vi.fn().mockResolvedValue({ instructionMarkdown: "guide" }) }, getGithubToken: vi.fn().mockReturnValue("token"), executionRepository, @@ -549,7 +886,22 @@ describe("CliWorkflowService unpushed commit detection", () => { "task-1", ); expect(executeProviderStage).not.toHaveBeenCalled(); + expect(executionRepository.createProviderInvocationUsage).not.toHaveBeenCalled(); expect(executeGitFinalizeStage).toHaveBeenCalledOnce(); + expect(storedInvocation).toMatchObject({ + id: "current-recovered-workflow", + status: "completed", + finishedAt: expect.any(String), + errorMessage: null, + }); + expect(executionRepository.updateTaskRun).toHaveBeenLastCalledWith( + "current-run", + expect.objectContaining({ state: "COMPLETED" }), + ); + expect(executionRepository.updateTaskDispatch).toHaveBeenLastCalledWith( + "current-dispatch", + expect.objectContaining({ status: "completed" }), + ); expect(executionRepository.appendTaskRunEvent).toHaveBeenCalledWith( "current-run", "cli_provider_completion_recovered", diff --git a/tests/backend/services/cli-workflow/pipeline/pipeline-stages.test.ts b/tests/backend/services/cli-workflow/pipeline/pipeline-stages.test.ts index 89819306ff..2a09eceefb 100644 --- a/tests/backend/services/cli-workflow/pipeline/pipeline-stages.test.ts +++ b/tests/backend/services/cli-workflow/pipeline/pipeline-stages.test.ts @@ -431,6 +431,44 @@ describe("executePrepareStage", () => { }); describe("executeProviderStage", () => { + it("reuses the preparation invocation and defers its completion past provider execution", async () => { + const ctx = createMockContext(); + const executionInvocation = { + id: "exec-prepared", + status: "running", + providerInvocationId: null as string | null, + }; + ctx.executionInvocationId = executionInvocation.id; + ctx.deps.executionRepository!.getExecutionInvocation = vi.fn().mockReturnValue(executionInvocation as any); + vi.mocked(ctx.deps.executionRepository!.updateExecutionInvocation).mockImplementation((_id, input) => { + Object.assign(executionInvocation, input); + return executionInvocation as any; + }); + vi.mocked(ctx.providerRunner.runProvider).mockResolvedValueOnce({ + ok: true, + stdout: "success", + stderr: "", + usageTelemetry: { transcriptText: "success transcript" } as any, + }); + + await executeProviderStage(ctx, "prompt"); + + expect(ctx.deps.executionRepository!.createExecutionInvocation).not.toHaveBeenCalled(); + expect(ctx.deps.executionRepository!.createProviderInvocationUsage).toHaveBeenCalledOnce(); + expect(executionInvocation).toMatchObject({ + status: "running", + providerInvocationId: "usage-1", + }); + expect(ctx.deps.executionRepository!.updateExecutionInvocation).not.toHaveBeenCalledWith( + "exec-prepared", + expect.objectContaining({ status: "completed" }), + ); + expect(ctx.deps.executionRepository!.appendExecutionInvocationMessage).toHaveBeenCalledWith("exec-prepared", { + role: "user", + contentMarkdown: "prompt", + }); + }); + it("passes the narrow clarification gateway and worker identity to a task-coding provider run", async () => { const ctx = createMockContext(); ctx.agentPresetId = "assigned-worker"; @@ -544,6 +582,7 @@ describe("executeProviderStage", () => { }), undefined, undefined, + "exec-1", ); }); diff --git a/tests/backend/services/execution-invocation-control-service.test.ts b/tests/backend/services/execution-invocation-control-service.test.ts index ab47d56bad..e2c1bfbe55 100644 --- a/tests/backend/services/execution-invocation-control-service.test.ts +++ b/tests/backend/services/execution-invocation-control-service.test.ts @@ -271,7 +271,14 @@ describe("ExecutionInvocationControlService", () => { provider: "codex", startedAt: "2026-07-02T10:00:00.000Z", }); - const requestStop = vi.fn().mockResolvedValue({ accepted: true }); + const requestStop = vi.fn().mockImplementation(async () => { + expect(executionRepository.getExecutionInvocation(invocation.id)).toMatchObject({ + status: "cancelled", + finishedAt: expect.any(String), + lastErrorMessage: "Invocation cancelled from Chat -> Invocations.", + }); + return { accepted: true }; + }); activeDispatchRegistry.register({ dispatchId: dispatch.id, taskRunId: taskRun.id, @@ -307,6 +314,15 @@ describe("ExecutionInvocationControlService", () => { expect(projectRepository.getTask(task.id)?.status).toBe("pending"); expect(executionRepository.listExecutionInvocationMessages(invocation.id).at(-1)?.contentMarkdown) .toContain("Invocation cancelled from Chat -> Invocations."); + + const messageCount = executionRepository.listExecutionInvocationMessages(invocation.id).length; + const lateResult = await service.cancelInvocation(invocation.id); + expect(lateResult).toMatchObject({ + cancelled: false, + message: "Invocation is already cancelled.", + }); + expect(requestStop).toHaveBeenCalledOnce(); + expect(executionRepository.listExecutionInvocationMessages(invocation.id)).toHaveLength(messageCount); }); it("does not stop containers for terminal invocations", async () => { diff --git a/tests/backend/services/provider-concurrency-service.test.ts b/tests/backend/services/provider-concurrency-service.test.ts index d69293eb51..071aee86c6 100644 --- a/tests/backend/services/provider-concurrency-service.test.ts +++ b/tests/backend/services/provider-concurrency-service.test.ts @@ -14,6 +14,7 @@ describe("ProviderConcurrencyService", () => { tryCreateProviderInvocationUsage: vi.fn(), createProviderInvocationUsage: vi.fn(), updateProviderInvocationUsage: vi.fn(), + getExecutionInvocation: vi.fn().mockReturnValue({ id: "exec-1", status: "running", providerInvocationId: null }), listExecutionInvocationsByProviderInvocationId: vi.fn().mockReturnValue([]), updateExecutionInvocation: vi.fn(), appendExecutionInvocationMessage: vi.fn(), @@ -188,6 +189,59 @@ describe("ProviderConcurrencyService", () => { expect(executionRepository.tryCreateProviderInvocationUsage).toHaveBeenCalledWith(input, 5); }); + it("links a claimed provider usage to the active execution invocation", async () => { + const input = { provider: "jules", startedAt: "2026-07-13T12:00:00.000Z" } as any; + executionRepository.tryCreateProviderInvocationUsage.mockReturnValue({ id: "inv-linked" }); + + const result = await service.waitForSlotAndClaim( + "jules", + 5, + input, + undefined, + undefined, + "exec-1", + ); + + expect(result.id).toBe("inv-linked"); + expect(executionRepository.tryCreateProviderInvocationUsage).toHaveBeenCalledWith(input, 5); + expect(executionRepository.updateExecutionInvocation).toHaveBeenCalledOnce(); + expect(executionRepository.updateExecutionInvocation).toHaveBeenCalledWith("exec-1", { + providerInvocationId: "inv-linked", + }); + }); + + it("does not claim provider usage when the execution is cancelled while waiting", async () => { + vi.useFakeTimers(); + try { + const executionInvocation = { id: "exec-1", status: "running", providerInvocationId: null }; + executionRepository.getExecutionInvocation.mockImplementation(() => executionInvocation); + executionRepository.tryCreateProviderInvocationUsage.mockReturnValue(null); + executionRepository.listRunningProviderInvocationUsages.mockReturnValue([{}]); + + const waitPromise = service.waitForSlotAndClaim( + "jules", + 1, + { provider: "jules" } as any, + undefined, + undefined, + "exec-1", + ); + await vi.advanceTimersByTimeAsync(0); + expect(executionRepository.tryCreateProviderInvocationUsage).toHaveBeenCalledTimes(1); + + executionInvocation.status = "cancelled"; + const assertion = expect(waitPromise).rejects.toThrow("provider slot will not be claimed"); + await vi.advanceTimersByTimeAsync(2000); + await assertion; + + expect(executionRepository.tryCreateProviderInvocationUsage).toHaveBeenCalledTimes(1); + expect(executionRepository.createProviderInvocationUsage).not.toHaveBeenCalled(); + expect(executionRepository.updateExecutionInvocation).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("should wait and retry if tryCreate returns null", async () => { vi.useFakeTimers(); try { diff --git a/tests/backend/services/provider-execution-service.test.ts b/tests/backend/services/provider-execution-service.test.ts index bfeda020a0..092cfb41a6 100644 --- a/tests/backend/services/provider-execution-service.test.ts +++ b/tests/backend/services/provider-execution-service.test.ts @@ -171,6 +171,106 @@ describe("ProviderExecutionService", () => { expect.objectContaining({ purpose: "test-purpose", sessionId: "session-1" }), undefined, 30_000, + "exec-inv-1", + ); + }); + + it("reuses a supplied execution invocation and links exactly one claimed provider usage", async () => { + providerRunner.runProvider.mockResolvedValue(mockResult); + + await service.executeProvider({ + ...defaultArgs, + invocationId: "exec-inv-1", + finalizeExecutionInvocation: false, + }); + + expect(executionRepository.createExecutionInvocation).not.toHaveBeenCalled(); + expect(executionRepository.createProviderInvocationUsage).toHaveBeenCalledOnce(); + expect(executionRepository.createProviderInvocationUsage).toHaveBeenCalledWith( + expect.not.objectContaining({ startedAt: expect.anything() }), + ); + const linkageUpdates = executionRepository.updateExecutionInvocation.mock.calls.filter(([, update]) => ( + (update as { providerInvocationId?: string }).providerInvocationId === "prov-inv-1" + )); + expect(linkageUpdates).toHaveLength(1); + expect(executionRepository.updateExecutionInvocation).not.toHaveBeenCalledWith( + "exec-inv-1", + expect.objectContaining({ status: "completed" }), + ); + expect(executionRepository.appendExecutionInvocationMessage).toHaveBeenCalledWith("exec-inv-1", { + role: "user", + contentMarkdown: "test prompt", + }); + }); + + it("starts provider timestamps and duration after the concurrency wait", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-13T12:00:00.000Z")); + const waitForSlotAndClaim = vi.fn().mockImplementation(async (_provider, _limit, input) => { + expect(input).not.toHaveProperty("startedAt"); + vi.setSystemTime(new Date("2026-07-13T12:00:10.000Z")); + return { id: "prov-inv-delayed" }; + }); + providerRunner.runProvider.mockImplementation(async () => { + vi.setSystemTime(new Date("2026-07-13T12:00:10.750Z")); + return mockResult; + }); + service = new ProviderExecutionService({ + providerRunner, + executionRepository, + logger: logger as any, + getGithubToken: vi.fn(), + providerConcurrencyService: { waitForSlotAndClaim } as any, + }); + + await service.executeProvider({ + ...defaultArgs, + invocationId: "exec-inv-1", + finalizeExecutionInvocation: false, + }); + + expect(waitForSlotAndClaim).toHaveBeenCalledWith( + "claude-code", + expect.any(Number), + expect.not.objectContaining({ startedAt: expect.anything() }), + undefined, + undefined, + "exec-inv-1", + ); + expect(executionRepository.updateProviderInvocationUsage).toHaveBeenCalledWith( + "prov-inv-delayed", + expect.objectContaining({ + status: "completed", + durationMs: 750, + }), + ); + }); + + it("does not start or update provider work when a supplied execution is cancelled before claim completion", async () => { + const waitForSlotAndClaim = vi.fn().mockImplementation(async () => { + executionInvocationState.status = "cancelled"; + return { id: "prov-inv-cancelled" }; + }); + service = new ProviderExecutionService({ + providerRunner, + executionRepository, + logger: logger as any, + getGithubToken: vi.fn(), + providerConcurrencyService: { waitForSlotAndClaim } as any, + }); + + await expect(service.executeProvider({ + ...defaultArgs, + invocationId: "exec-inv-1", + finalizeExecutionInvocation: false, + })).rejects.toThrow("provider execution will not continue"); + + expect(executionRepository.createExecutionInvocation).not.toHaveBeenCalled(); + expect(providerRunner.runProvider).not.toHaveBeenCalled(); + expect(executionRepository.updateProviderInvocationUsage).not.toHaveBeenCalled(); + expect(executionRepository.updateExecutionInvocation).not.toHaveBeenCalledWith( + "exec-inv-1", + expect.objectContaining({ providerInvocationId: "prov-inv-cancelled" }), ); }); @@ -607,9 +707,9 @@ describe("ProviderExecutionService", () => { }); it("does not rewrite provider usage after external recovery closes it", async () => { - executionRepository.getProviderInvocationUsage.mockReturnValue({ id: "prov-inv-1", status: "failed" } as any); - executionRepository.getExecutionInvocation.mockReturnValue({ id: "exec-inv-1", status: "failed" } as any); providerRunner.runProvider.mockImplementation(async (opts: any) => { + executionInvocationState.status = "failed"; + executionRepository.getProviderInvocationUsage.mockReturnValue({ id: "prov-inv-1", status: "failed" } as any); opts.onTelemetry({ transcriptText: "late telemetry", inputTokens: 1, diff --git a/tests/backend/services/runtime-startup-recovery-service.test.ts b/tests/backend/services/runtime-startup-recovery-service.test.ts index ae24d1354a..4de563d8c9 100644 --- a/tests/backend/services/runtime-startup-recovery-service.test.ts +++ b/tests/backend/services/runtime-startup-recovery-service.test.ts @@ -603,7 +603,47 @@ describe("RuntimeStartupRecoveryService", () => { }); }); - it("reconciles stale task coding invocation audit rows when the provider invocation already finished", async () => { + it("fails a stale pre-provider CLI coding row with useful recovery evidence", async () => { + const { + projectRepository, + executionRepository, + service, + } = await createFixture(); + + const project = projectRepository.createProject({ + name: "Pre-provider Coding Recovery Project", + sourceType: "local", + sourceRef: "/workspace/pre-provider-coding-recovery-project", + }); + const invocation = executionRepository.createExecutionInvocation({ + projectId: project.id, + type: "cli_task_coding", + provider: "codex", + status: "running", + startedAt: "2026-03-29T10:00:00.000Z", + }); + + const result = await service.recover(); + + expect(result.reconciledTaskCodingInvocationIds).toContain(invocation.id); + expect(executionRepository.getExecutionInvocation(invocation.id)).toMatchObject({ + status: "failed", + finishedAt: expect.any(String), + errorMessage: expect.stringContaining("without provider runtime linkage"), + }); + expect(executionRepository.listExecutionInvocationMessages(invocation.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: "system", + contentMarkdown: expect.stringContaining("without provider runtime linkage"), + metadata: expect.objectContaining({ + recovery: "startup_task_coding_invocation_reconcile", + provider: "codex", + }), + }), + ])); + }); + + it("settles an interrupted CLI workflow from terminal provider and dispatch evidence", async () => { const { projectRepository, executionRepository, @@ -624,23 +664,33 @@ describe("RuntimeStartupRecoveryService", () => { const task = projectRepository.createTask(project.id, { sprintId: sprint.id, title: "Recover stale coding audit", - executorType: "jules", + executorType: "docker_cli", status: "in_progress", }); const sprintRun = executionRepository.createSprintRun({ projectId: project.id, sprintId: sprint.id, - executorMode: "jules", + executorMode: "docker_cli", status: "running", }); + const dispatch = executionRepository.createTaskDispatch({ + projectId: project.id, + sprintId: sprint.id, + taskId: task.id, + sprintRunId: sprintRun.id, + executorType: "docker_cli", + status: "running", + startedAt: "2026-03-29T10:00:00.000Z", + }); const taskRun = executionRepository.createTaskRun({ projectId: project.id, sprintId: sprint.id, taskId: task.id, sprintRunId: sprintRun.id, - provider: "jules", - mode: "jules", - sessionId: "jules-stale-task-coding", + dispatchId: dispatch.id, + provider: "codex", + mode: "docker_cli", + sessionId: "cli-stale-task-coding", state: "RUNNING", startedAt: "2026-03-29T10:00:00.000Z", }); @@ -649,29 +699,35 @@ describe("RuntimeStartupRecoveryService", () => { sprintId: sprint.id, taskId: task.id, sprintRunId: sprintRun.id, + dispatchId: dispatch.id, taskRunId: taskRun.id, - sessionId: "jules-stale-task-coding", - provider: "jules", + sessionId: "cli-stale-task-coding", + provider: "codex", purpose: "task_coding", status: "completed", startedAt: "2026-03-29T10:00:00.000Z", finishedAt: "2026-03-29T10:02:00.000Z", }); + executionRepository.updateProviderInvocationUsage(providerInvocation.id, { + status: "completed", + finishedAt: "2026-03-29T10:02:00.000Z", + }); const invocation = executionRepository.createExecutionInvocation({ projectId: project.id, sprintId: sprint.id, taskId: task.id, sprintRunId: sprintRun.id, + dispatchId: dispatch.id, taskRunId: taskRun.id, providerInvocationId: providerInvocation.id, - type: "task_coding", - provider: "jules", + type: "cli_task_coding", + provider: "codex", status: "running", startedAt: "2026-03-29T10:00:01.000Z", }); sessionTracking.createSession({ - id: "jules-stale-task-coding", - provider: "jules", + id: "cli-stale-task-coding", + provider: "codex", taskId: "Sprint 9", title: "Recover stale coding audit", state: "RUNNING", @@ -682,12 +738,34 @@ describe("RuntimeStartupRecoveryService", () => { const result = await service.recover(); + expect(result.reconciledTerminalProviderDispatchIds).toContain(dispatch.id); expect(result.reconciledTaskCodingInvocationIds).toEqual(expect.arrayContaining([invocation.id])); expect(executionRepository.getExecutionInvocation(invocation.id)).toMatchObject({ + status: "failed", + finishedAt: expect.any(String), + errorMessage: expect.stringContaining("linked task run was already FAILED"), + }); + expect(executionRepository.getProviderInvocationUsage(providerInvocation.id)).toMatchObject({ status: "completed", - errorMessage: null, + finishedAt: "2026-03-29T10:02:00.000Z", + }); + expect(executionRepository.getTaskDispatch(dispatch.id)).toMatchObject({ + status: expect.stringMatching(/^(failed|cancelled)$/), + finishedAt: expect.any(String), }); - expect(sessionTracking.getSession("jules-stale-task-coding")?.state).toBe("COMPLETED"); + expect(executionRepository.getTaskRun(taskRun.id)).toMatchObject({ state: "FAILED" }); + expect(executionRepository.getSprintRun(sprintRun.id)).toMatchObject({ status: "running" }); + expect(projectRepository.getTask(task.id)).toMatchObject({ status: "pending" }); + expect(sessionTracking.getSession("cli-stale-task-coding")?.state).toBe("FAILED"); + expect(executionRepository.listTaskRunEvents(taskRun.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "task_dispatch_reconciled", + payload: expect.objectContaining({ + reason: "terminal_provider_active_dispatch_mismatch", + providerStatus: "completed", + }), + }), + ])); }); it("reconciles stale non-task execution audit rows when the provider invocation already failed", async () => { diff --git a/tests/dashboard/invocations/invocation-optimistic-update.test.tsx b/tests/dashboard/invocations/invocation-optimistic-update.test.tsx index e0121f8471..64289ea43c 100644 --- a/tests/dashboard/invocations/invocation-optimistic-update.test.tsx +++ b/tests/dashboard/invocations/invocation-optimistic-update.test.tsx @@ -1,13 +1,16 @@ /** @vitest-environment happy-dom */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderHook, act } from "@testing-library/preact"; import { getActiveInvocationPollingKey } from "../../../dashboard/src/v2/hooks/use-chat-page-data.js"; import { useInvocationPaneData } from "../../../dashboard/src/v2/hooks/use-invocation-pane-data.js"; import { useMessageCache } from "../../../dashboard/src/v2/hooks/useMessageCache.js"; import type { ExecutionInvocationRecord } from "../../../dashboard/src/v2/types.js"; -const buildServerInvocation = (createdAt: string): ExecutionInvocationRecord => ({ - id: "invocation-1", +const buildServerInvocation = ( + createdAt: string, + overrides: Partial = {}, +): ExecutionInvocationRecord => ({ + id: "persisted-cli-preparation-1", projectId: "project-1", sprintId: null, taskId: null, @@ -16,7 +19,7 @@ const buildServerInvocation = (createdAt: string): ExecutionInvocationRecord => taskRunId: null, attentionItemId: null, providerInvocationId: null, - type: "dashboard_reply", + type: "cli_task_coding", status: "running", provider: null, model: null, @@ -37,6 +40,7 @@ const buildServerInvocation = (createdAt: string): ExecutionInvocationRecord => totalTokens: 0, createdAt, updatedAt: createdAt, + ...overrides, }); describe("invocation server snapshots", () => { @@ -63,7 +67,60 @@ describe("invocation server snapshots", () => { expect(result.current.serverInvocationCount).toBe(1); expect(result.current.invocationTotalCount).toBe(2); expect(result.current.hasMoreInvocations).toBe(true); - expect(result.current.invocationIndex.get("invocation-1")).toEqual(invocation); + expect(result.current.invocationIndex.get(invocation.id)).toEqual(invocation); + }); + + it("exposes only persisted ids to invocation detail and lifecycle action consumers", async () => { + const { result } = renderHook(() => { + const cache = useMessageCache(); + return useInvocationPaneData({ + selectedProject: { id: "project-1" }, + cache, + }); + }); + const createdAt = "2026-07-13T10:00:00.000Z"; + const runningInvocation = buildServerInvocation(createdAt); + const openDetail = vi.fn(); + const cancelInvocation = vi.fn(); + const restartInvocation = vi.fn(); + + expect(result.current.selectedInvocation).toBeNull(); + + await act(async () => { + result.current.setInvocationsSnapshot([runningInvocation]); + await result.current.activateInvocation(runningInvocation.id, { + preferredInvocation: runningInvocation, + }); + }); + + const indexedInvocation = result.current.invocationIndex.get(runningInvocation.id); + expect(indexedInvocation).toBe(runningInvocation); + expect(result.current.selectedInvocation).toBe(runningInvocation); + openDetail(indexedInvocation?.id); + cancelInvocation(result.current.selectedInvocation?.id); + + const failedInvocation = buildServerInvocation(createdAt, { + id: "persisted-planning-failure-2", + type: "planning", + status: "failed", + finishedAt: "2026-07-13T10:01:00.000Z", + }); + await act(async () => { + result.current.setInvocationsSnapshot([failedInvocation]); + await result.current.activateInvocation(failedInvocation.id, { + preferredInvocation: failedInvocation, + }); + }); + restartInvocation(result.current.selectedInvocation?.id); + + expect(openDetail).toHaveBeenCalledWith("persisted-cli-preparation-1"); + expect(cancelInvocation).toHaveBeenCalledWith("persisted-cli-preparation-1"); + expect(restartInvocation).toHaveBeenCalledWith("persisted-planning-failure-2"); + expect([ + openDetail.mock.calls[0]?.[0], + cancelInvocation.mock.calls[0]?.[0], + restartInvocation.mock.calls[0]?.[0], + ]).not.toContain(expect.stringMatching(/^optimistic:/)); }); it("polls only running invocation records", () => { diff --git a/tests/dashboard/v2/use-chat-page-data.test.tsx b/tests/dashboard/v2/use-chat-page-data.test.tsx index f6547706a3..b6fc102b20 100644 --- a/tests/dashboard/v2/use-chat-page-data.test.tsx +++ b/tests/dashboard/v2/use-chat-page-data.test.tsx @@ -17,6 +17,7 @@ import { upsertConversationDraft, } from "../../../dashboard/src/v2/lib/connection-api.js"; import { fetchInvocationMessages, fetchProjectInvocations } from "../../../dashboard/src/v2/lib/invocation-api.js"; +import type { ExecutionInvocationMessageRecord, ExecutionInvocationRecord } from "../../../dashboard/src/v2/types.js"; // Mock connection-api calls to prevent external requests vi.mock("../../../dashboard/src/v2/lib/connection-api.js", () => ({ @@ -62,6 +63,55 @@ vi.mock("../../../dashboard/src/v2/lib/invocation-api.js", () => ({ let mockRealtimeCallback: any = null; +const buildInvocation = ( + overrides: Partial = {}, +): ExecutionInvocationRecord => ({ + id: "persisted-cli-preparation-1", + projectId: "proj-1", + sprintId: "sprint-1", + taskId: "task-1", + sprintRunId: "sprint-run-1", + dispatchId: "dispatch-1", + taskRunId: "task-run-1", + attentionItemId: null, + providerInvocationId: "provider-invocation-1", + type: "cli_task_coding", + status: "running", + provider: "codex", + model: "test-model", + systemPrompt: null, + startedAt: "2026-07-13T10:00:00.000Z", + finishedAt: null, + errorMessage: null, + lastErrorCategory: null, + lastErrorMessage: null, + lastRetryAfterIso: null, + messageCount: 1, + lastMessageAt: "2026-07-13T10:00:00.000Z", + invocationSource: "cli", + agentPresetId: null, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + totalTokens: 0, + createdAt: "2026-07-13T10:00:00.000Z", + updatedAt: "2026-07-13T10:00:00.000Z", + ...overrides, +}); + +const buildInvocationMessage = ( + invocationId: string, + contentMarkdown = "Preparing the task workspace.", +): ExecutionInvocationMessageRecord => ({ + id: `message-${invocationId}`, + invocationId, + role: "system", + contentMarkdown, + toolCallsJson: null, + metadata: { phase: "preparation" }, + createdAt: "2026-07-13T10:00:00.000Z", +}); + vi.mock("../../../dashboard/src/lib/realtime/dashboard-realtime-client.js", () => ({ subscribeToDashboardRealtime: vi.fn((scopes, callback) => { mockRealtimeCallback = callback; @@ -102,6 +152,8 @@ describe("useChatPageResources integration", () => { id: "thread-new", messageCount: 0, projectId: "project-1", scope: "project" } as any); vi.mocked(cancelThreadTurn).mockResolvedValue({ cancelled: true }); + vi.mocked(fetchProjectInvocations).mockResolvedValue([]); + vi.mocked(fetchInvocationMessages).mockResolvedValue([]); }); it("treats invocation messages as changed when reasoning content or metadata mutates in place", () => { @@ -674,11 +726,18 @@ describe("useChatPageResources integration", () => { expect(reply?.deliveryStatus).toBe("delivered"); }); - it("force-refreshes the selected invocation's messages on a project.execution.updated event", async () => { - const refreshInvocationMessages = vi.fn(); - const selectedInvocationIdRef = { current: "inv-1" }; + it("waits for project.execution.updated to return an early persisted CLI invocation", async () => { + const persistedInvocation = buildInvocation(); + const preparationMessage = buildInvocationMessage(persistedInvocation.id); + let resolveRealtimeList: ((value: ExecutionInvocationRecord[]) => void) | null = null; + vi.mocked(fetchProjectInvocations) + .mockResolvedValueOnce([]) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRealtimeList = resolve; + })); + vi.mocked(fetchInvocationMessages).mockResolvedValue([preparationMessage]); - renderHook(() => { + const { result } = renderHook(() => { const cache = useMessageCache(); const threadData = useChatThreadData({ selectedProject: { id: "proj-1" }, @@ -687,17 +746,11 @@ describe("useChatPageResources integration", () => { workerRouting: null, }); - const invocationData = { - selectedInvocationIdRef, - setInvocationsSnapshot: vi.fn(), - setInvocationMessagesSnapshot: vi.fn(), - setSelectedInvocationId: vi.fn(), - setError: vi.fn(), - activateInvocation: vi.fn(), - refreshInvocationMessages, - } as any; - - useChatPageResources({ + const invocationData = useInvocationPaneData({ + selectedProject: { id: "proj-1" }, + cache, + }); + const resources = useChatPageResources({ selectedProject: { id: "proj-1" }, cache, chatMode: "invocations", @@ -705,9 +758,13 @@ describe("useChatPageResources integration", () => { invocationData, }); - return { cache, threadData }; + return { invocationData, resources }; }); + await waitFor(() => expect(fetchProjectInvocations).toHaveBeenCalledTimes(1)); + expect(result.current.invocationData.invocations).toEqual([]); + expect(result.current.invocationData.selectedInvocationId).toBeNull(); + await act(async () => { if (mockRealtimeCallback) { mockRealtimeCallback({ @@ -720,7 +777,85 @@ describe("useChatPageResources integration", () => { } }); - expect(refreshInvocationMessages).toHaveBeenCalledWith("inv-1", { force: true }); + await waitFor(() => expect(fetchProjectInvocations).toHaveBeenCalledTimes(2)); + expect(result.current.invocationData.invocations).toEqual([]); + expect(result.current.invocationData.invocationIndex.has("optimistic:proj-1")).toBe(false); + + await act(async () => { + resolveRealtimeList?.([persistedInvocation]); + }); + + await waitFor(() => { + expect(result.current.invocationData.selectedInvocationId).toBe(persistedInvocation.id); + }); + expect(result.current.invocationData.invocations).toEqual([persistedInvocation]); + expect(result.current.invocationData.selectedInvocation).toBe(persistedInvocation); + expect(result.current.invocationData.invocationMessages).toEqual([preparationMessage]); + expect(fetchInvocationMessages).toHaveBeenCalledWith(persistedInvocation.id); + }); + + it("refreshes the server list on snapshot_required without replacing selection or messages", async () => { + const selectedInvocation = buildInvocation({ + id: "persisted-selected-1", + type: "planning", + status: "completed", + finishedAt: "2026-07-13T10:05:00.000Z", + }); + const selectedMessage = buildInvocationMessage(selectedInvocation.id, "Existing selected transcript."); + const earlyPreparationInvocation = buildInvocation({ + id: "persisted-cli-preparation-2", + startedAt: "2026-07-13T10:06:00.000Z", + createdAt: "2026-07-13T10:06:00.000Z", + updatedAt: "2026-07-13T10:06:00.000Z", + }); + vi.mocked(fetchProjectInvocations) + .mockResolvedValueOnce([selectedInvocation]) + .mockResolvedValueOnce([earlyPreparationInvocation, selectedInvocation]); + vi.mocked(fetchInvocationMessages).mockResolvedValue([selectedMessage]); + + const { result } = renderHook(() => { + const cache = useMessageCache(); + const threadData = useChatThreadData({ + selectedProject: { id: "proj-1" }, + cache, + execution: null, + workerRouting: null, + }); + const invocationData = useInvocationPaneData({ + selectedProject: { id: "proj-1" }, + cache, + }); + useChatPageResources({ + selectedProject: { id: "proj-1" }, + cache, + chatMode: "invocations", + threadData, + invocationData, + }); + + return { invocationData }; + }); + + await waitFor(() => { + expect(result.current.invocationData.selectedInvocationId).toBe(selectedInvocation.id); + }); + expect(result.current.invocationData.invocationMessages).toEqual([selectedMessage]); + vi.mocked(fetchInvocationMessages).mockClear(); + + await act(async () => { + mockRealtimeCallback?.({ type: "snapshot_required" }); + }); + + await waitFor(() => { + expect(result.current.invocationData.invocations).toEqual([ + earlyPreparationInvocation, + selectedInvocation, + ]); + }); + expect(result.current.invocationData.selectedInvocationId).toBe(selectedInvocation.id); + expect(result.current.invocationData.selectedInvocation).toBe(selectedInvocation); + expect(result.current.invocationData.invocationMessages).toEqual([selectedMessage]); + expect(fetchInvocationMessages).toHaveBeenCalledWith(selectedInvocation.id); }); it("loads chat invocations in 40-row pages and preserves the server total", async () => { diff --git a/tests/e2e/tasks/invocation-runtime.spec.ts b/tests/e2e/tasks/invocation-runtime.spec.ts index 06831eb390..fc5bd23100 100644 --- a/tests/e2e/tasks/invocation-runtime.spec.ts +++ b/tests/e2e/tasks/invocation-runtime.spec.ts @@ -319,6 +319,9 @@ test.describe('invocation runtime observability', () => { const invocations = await fetchProjectInvocations(request, project.id); const invocation = requireTaskCodingInvocation(invocations, task.id); + expect(invocations.items.filter((item) => ( + item.taskId === task.id && TASK_CODING_INVOCATION_TYPES.has(item.type) + ))).toHaveLength(1); expect(invocation).toMatchObject({ projectId: project.id, sprintId: sprint.id, @@ -331,8 +334,10 @@ test.describe('invocation runtime observability', () => { provider: MOCKUP_CLI_PROVIDER, model: 'default', executionMode: 'HOST', + providerInvocationId: expect.any(String), taskTitle: task.title, }); + expect(invocation.providerInvocationId).not.toBe(''); expect(invocation.messageCount).toBeGreaterThanOrEqual(2); expect(invocation.promptChars).toBeGreaterThanOrEqual(taskPrompt.length); expect(invocation.transcriptChars).toBeGreaterThan(0);