diff --git a/packages/engine/src/__tests__/step-session-executor.test.ts b/packages/engine/src/__tests__/step-session-executor.test.ts index c87a170e4f..6bb71e32e7 100644 --- a/packages/engine/src/__tests__/step-session-executor.test.ts +++ b/packages/engine/src/__tests__/step-session-executor.test.ts @@ -1274,6 +1274,52 @@ describe("StepSessionExecutor", () => { expect(onStepStart).toHaveBeenNthCalledWith(3, 2); }); + it("skips live-terminal steps before starting resumed sessions", async () => { + const prompt = makeStepPrompt("FN-7248", 2); + const task = makeTaskDetail({ + id: "FN-7248", + prompt, + steps: [ + { name: "Preflight", status: "pending" }, + { name: "Implement", status: "pending" }, + ], + }); + const settings = makeSettings({ maxParallelSteps: 1 }); + const session = makeMockSession(); + mockedCreateFnAgent.mockResolvedValue({ session } as any); + const onStepStart = vi.fn(); + const store = { + getTask: vi.fn().mockResolvedValue({ + ...task, + steps: [ + { name: "Preflight", status: "done" }, + { name: "Implement", status: "in-progress" }, + ], + }), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + }; + + const executor = new StepSessionExecutor({ + store: store as any, + taskDetail: task, + worktreePath: "/project/.worktrees/main", + rootDir: "/project", + settings, + onStepStart, + }); + + const results = await executor.executeAll(); + + /* + * FNXC:WorkflowResume 2026-06-29-18:26: + * Resume must use TaskStore as the authoritative projection before scheduling per-step sessions. A stale snapshot may still list Step 0 as pending, but if the live task says Step 0 is done then no Step 0 session or onStepStart callback may run. + */ + expect(results.map((result) => result.stepIndex)).toEqual([1]); + expect(onStepStart).toHaveBeenCalledTimes(1); + expect(onStepStart).toHaveBeenCalledWith(1); + expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); + }); + it("includes token usage from session stats on successful step completion", async () => { const prompt = makeStepPrompt("FN-001", 1); const task = makeTaskDetail({ diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index fece1d8c60..0dc0433430 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -791,14 +791,23 @@ export class StepSessionExecutor { for (const wave of waves) { if (this.aborted) break; - if (wave.indices.length === 1) { + /* + * FNXC:WorkflowResume 2026-06-29-18:26: + * Engine restart/resume may construct StepSessionExecutor with a stale taskDetail snapshot while TaskStore already shows earlier steps as done/skipped. Re-read live step status before scheduling each wave so resumed graph-owned execution does not fire onStepStart for completed steps and produce noisy done→in-progress regressions like FN-7248. + */ + const runnableIndices = await this.filterLiveRunnableStepIndices(wave.indices); + if (runnableIndices.length === 0) { + continue; + } + + if (runnableIndices.length === 1) { // Single step — use primary worktree - const stepIdx = wave.indices[0]!; + const stepIdx = runnableIndices[0]!; const result = await this.executeStep(stepIdx, this.options.worktreePath); this.stepResults.push(result); } else { // Multiple steps — parallel wave - const waveResults = await this.executeParallelWave(wave); + const waveResults = await this.executeParallelWave({ ...wave, indices: runnableIndices }); this.stepResults.push(...waveResults); } } @@ -807,6 +816,36 @@ export class StepSessionExecutor { return this.stepResults.sort((a, b) => a.stepIndex - b.stepIndex); } + private async filterLiveRunnableStepIndices(stepIndices: number[]): Promise { + if (!this.options.store || stepIndices.length === 0) { + return stepIndices; + } + + try { + const liveTask = await this.options.store.getTask(this.options.taskDetail.id); + if (!liveTask || liveTask.id !== this.options.taskDetail.id) { + return stepIndices; + } + return stepIndices.filter((stepIndex) => { + const liveStatus = liveTask.steps?.[stepIndex]?.status; + if (liveStatus === "done" || liveStatus === "skipped") { + stepExecLog.log( + `Skipping step ${stepIndex} for task ${this.options.taskDetail.id}: live status is ${liveStatus}`, + ); + return false; + } + return true; + }); + } catch (err) { + stepExecLog.warn( + `Failed to inspect live step status for task ${this.options.taskDetail.id}; continuing from snapshot: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return stepIndices; + } + } + /** * Terminate all active agent sessions and set the aborted flag. *