From c8f2628f73c7f02671086fca4ca3cbda5b1bfc53 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 18:27:49 +0000 Subject: [PATCH 1/2] feat(task T02): implement via codex --- src/services/cli-workflow-service.ts | 29 +++++ .../pipeline/execute-provider-stage.ts | 1 + src/services/provider-concurrency-service.ts | 35 +++++- src/services/provider-execution-service.ts | 63 ++++++++--- .../services/cli-workflow-service.test.ts | 14 ++- .../pipeline/pipeline-stages.test.ts | 39 +++++++ .../provider-concurrency-service.test.ts | 54 +++++++++ .../provider-execution-service.test.ts | 104 +++++++++++++++++- 8 files changed, 317 insertions(+), 22 deletions(-) diff --git a/src/services/cli-workflow-service.ts b/src/services/cli-workflow-service.ts index bee1d8415b..0744682183 100644 --- a/src/services/cli-workflow-service.ts +++ b/src/services/cli-workflow-service.ts @@ -492,6 +492,7 @@ export class CliWorkflowService { category, errorMessage: blocker, }, `cli:workflow:blocked:agent:${args.sessionId}`); + this.finalizeExecutionInvocation(ctx.executionInvocationId, "failed", finishedAt, blocker); return; } this.appendExecutionEvent(args, "cli_git_no_changes", { @@ -511,6 +512,7 @@ export class CliWorkflowService { provider: args.provider, outcome: "no_changes", }, "cli:workflow:completed:no-changes"); + this.finalizeExecutionInvocation(ctx.executionInvocationId, "completed", finishedAt); return; } @@ -552,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); @@ -713,6 +716,12 @@ export class CliWorkflowService { message, }); } + this.finalizeExecutionInvocation( + ctx.executionInvocationId, + abortController.signal.aborted ? "cancelled" : "failed", + finishedAt, + message, + ); } finally { try { const cleanupResult = preserveWorkspaceForShutdown @@ -895,6 +904,26 @@ 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, + ...(status === "completed" ? { errorMessage: null } : { errorMessage: errorMessage ?? null }), + }); + } + 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 fc33d2a6c6..00444eb6e2 100644 --- a/src/services/cli-workflow/pipeline/execute-provider-stage.ts +++ b/src/services/cli-workflow/pipeline/execute-provider-stage.ts @@ -108,6 +108,7 @@ export async function executeProviderStage(ctx: PipelineContext, providerPrompt: continueSessionId, openCodeBaselineRawUsageJson, invocationId: ctx.executionInvocationId, + finalizeExecutionInvocation: ctx.executionInvocationId ? false : undefined, workflowSettings: ctx.workflowSettings, repoPath: ctx.repoPath, gitPolicy: { diff --git a/src/services/provider-concurrency-service.ts b/src/services/provider-concurrency-service.ts index e3db0a3676..bc0626494c 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 && invocation.status !== "running" && invocation.status !== "paused") { + 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..4a9d2471ac 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,18 @@ export class ProviderExecutionService { return !current || current.status === "running"; } + private isProviderWorkStillRunning(providerInvocationId: string, executionInvocationId: string | null): boolean { + return this.isProviderInvocationStillRunning(providerInvocationId) + && (!executionInvocationId || this.isExecutionInvocationStillRunning(executionInvocationId)); + } + + private assertExecutionInvocationCanRun(executionInvocationId: string): void { + const current = this.deps.executionRepository?.getExecutionInvocation?.(executionInvocationId); + if (current && current.status !== "running" && current.status !== "paused") { + throw new Error(`Execution invocation ${executionInvocationId} is ${current.status}; provider execution will not continue.`); + } + } + private isExecutionInvocationStillRunning(executionInvocationId: string): boolean { const current = this.deps.executionRepository?.getExecutionInvocation?.(executionInvocationId); return !current || current.status === "running" || current.status === "paused"; diff --git a/tests/backend/services/cli-workflow-service.test.ts b/tests/backend/services/cli-workflow-service.test.ts index f0844cd438..400760f83c 100644 --- a/tests/backend/services/cli-workflow-service.test.ts +++ b/tests/backend/services/cli-workflow-service.test.ts @@ -141,6 +141,10 @@ describe("CliWorkflowService unpushed commit detection", () => { 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"); }), @@ -191,7 +195,14 @@ describe("CliWorkflowService unpushed commit detection", () => { vi.mocked(executeProviderStage).mockResolvedValue(buildProviderStageResult( "No repository changes were required.\nCODE_UX_TASK_OUTCOME: completed", )); - vi.mocked(executeGitFinalizeStage).mockResolvedValue({ hasChanges: false, committedChanges: false }); + 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({ @@ -254,6 +265,7 @@ describe("CliWorkflowService unpushed commit detection", () => { expect.objectContaining({ executionInvocationId: "xi-preparation" }), "mock prompt", ); + expect(storedInvocation).toMatchObject({ status: "completed" }); }); it("runs task workflow pipeline and handles error", async () => { 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/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, From 15e1a243e863dce418069bc6e103abbb2e206066 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 18:35:49 +0000 Subject: [PATCH 2/2] fix(ci): resolve failing checks on task/feature-codux-22-t02-codex-d591266c-mrjjhe7r --- src/services/provider-concurrency-service.ts | 2 +- src/services/provider-execution-service.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/services/provider-concurrency-service.ts b/src/services/provider-concurrency-service.ts index bc0626494c..df5800918d 100644 --- a/src/services/provider-concurrency-service.ts +++ b/src/services/provider-concurrency-service.ts @@ -156,7 +156,7 @@ export class ProviderConcurrencyService { return; } const invocation = this.deps.executionRepository.getExecutionInvocation(executionInvocationId); - if (invocation && invocation.status !== "running" && invocation.status !== "paused") { + if (invocation?.status === "cancelled") { throw new Error(`Execution invocation ${executionInvocationId} is ${invocation.status}; provider slot will not be claimed.`); } } diff --git a/src/services/provider-execution-service.ts b/src/services/provider-execution-service.ts index 4a9d2471ac..c88cd825bd 100644 --- a/src/services/provider-execution-service.ts +++ b/src/services/provider-execution-service.ts @@ -912,16 +912,20 @@ export class ProviderExecutionService { private isProviderWorkStillRunning(providerInvocationId: string, executionInvocationId: string | null): boolean { return this.isProviderInvocationStillRunning(providerInvocationId) - && (!executionInvocationId || this.isExecutionInvocationStillRunning(executionInvocationId)); + && (!executionInvocationId || !this.isExecutionInvocationCancelled(executionInvocationId)); } private assertExecutionInvocationCanRun(executionInvocationId: string): void { const current = this.deps.executionRepository?.getExecutionInvocation?.(executionInvocationId); - if (current && current.status !== "running" && current.status !== "paused") { + 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";