From 784eba64845e64e9786abc728df404cb47d0f696 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 18 May 2026 10:05:14 +0200 Subject: [PATCH 1/2] fix(workflow-executor): use last step from history instead of finding first non-done The orchestrator is the source of truth for which step to execute next. Always pick the last entry in workflowHistory rather than scanning for the first non-done/non-cancelled/non-errored step. Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/forest-server-workflow-port.ts | 2 +- .../adapters/run-to-available-step-mapper.ts | 2 +- .../run-to-available-step-mapper.test.ts | 65 +++---------------- 3 files changed, 10 insertions(+), 59 deletions(-) diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index 7cba97f694..5fc111d2e2 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -131,7 +131,7 @@ export default class ForestServerWorkflowPort implements WorkflowPort { run: ServerHydratedWorkflowRun, err: WorkflowExecutorError, ): MalformedRunInfo { - const pending = run.workflowHistory.find(s => !s.done && !s.cancelled && !s.context?.error); + const pending = run.workflowHistory.at(-1) ?? null; return { runId: String(run.id), diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index f86a074c45..3e0f62a70e 100644 --- a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -136,7 +136,7 @@ export default function toAvailableStepExecution( ); } - const pending = run.workflowHistory.find(s => !s.done && !s.cancelled && !s.context?.error); + const pending = run.workflowHistory.at(-1) ?? null; if (!pending) return null; const result = { diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 99feb048ef..f84edcf5a7 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -94,41 +94,18 @@ describe('toAvailableStepExecution', () => { expect(result?.baseRecordRef.recordId).toEqual(['rec-abc']); }); - it('should return null when all steps are done', () => { - const run = makeRun({ - workflowHistory: [ - makeStepHistory({ stepIndex: 0, done: true }), - makeStepHistory({ stepIndex: 1, done: true }), - ], - }); - - expect(toAvailableStepExecution(run)).toBeNull(); - }); - - it('should return null when all steps are done or cancelled', () => { - const run = makeRun({ - workflowHistory: [ - makeStepHistory({ stepIndex: 0, done: true }), - makeStepHistory({ stepIndex: 1, done: false, cancelled: true }), - ], - }); - - expect(toAvailableStepExecution(run)).toBeNull(); - }); - it('should return null when workflowHistory is empty', () => { const run = makeRun({ workflowHistory: [] }); expect(toAvailableStepExecution(run)).toBeNull(); }); - it('should pick the first non-done, non-cancelled step as pending', () => { + it('picks the last step — orchestrator is the source of truth for which step to execute', () => { const run = makeRun({ workflowHistory: [ makeStepHistory({ stepName: 's0', stepIndex: 0, done: true }), - makeStepHistory({ stepName: 's1', stepIndex: 1, done: false, cancelled: true }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: true }), makeStepHistory({ stepName: 's2', stepIndex: 2, done: false }), - makeStepHistory({ stepName: 's3', stepIndex: 3, done: false }), ], }); @@ -138,33 +115,6 @@ describe('toAvailableStepExecution', () => { expect(result?.stepIndex).toBe(2); }); - it('errored step (done:false + context.error) is skipped — next pending step is returned', () => { - // Scenario: back changed errored steps to done:false so the front can offer Continue/Revise. - // The executor must skip the errored step and pick the next pending one. - const run = makeRun({ - workflowHistory: [ - makeStepHistory({ stepName: 's0', stepIndex: 0, done: false, context: { error: 'boom' } }), - makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), - ], - }); - - const result = toAvailableStepExecution(run); - - expect(result?.stepId).toBe('s1'); - expect(result?.stepIndex).toBe(1); - }); - - it('returns null when the only non-done step is errored', () => { - const run = makeRun({ - workflowHistory: [ - makeStepHistory({ stepIndex: 0, done: true }), - makeStepHistory({ stepIndex: 1, done: false, context: { error: 'failed' } }), - ], - }); - - expect(toAvailableStepExecution(run)).toBeNull(); - }); - it('should strip unknown server keys (e.g. automaticExecution) from guidance step without throwing', () => { const run = makeRun({ workflowHistory: [ @@ -404,18 +354,19 @@ describe('toAvailableStepExecution', () => { }); }); - it('should not include done steps that are after the available step', () => { + it('should not include the pending step itself in previousSteps', () => { const run = makeRun({ workflowHistory: [ - makeStepHistory({ stepName: 's0', stepIndex: 0, done: false }), - makeStepHistory({ stepName: 's1', stepIndex: 1, done: true }), + makeStepHistory({ stepName: 's0', stepIndex: 0, done: true }), + makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), ], }); const result = toAvailableStepExecution(run); - expect(result?.stepId).toBe('s0'); - expect(result?.previousSteps).toHaveLength(0); + expect(result?.stepId).toBe('s1'); + expect(result?.previousSteps).toHaveLength(1); + expect(result?.previousSteps[0].stepOutcome.stepId).toBe('s0'); }); it.each([ From efc855934a1e6a4b2c14b012301568b35f51692f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 18 May 2026 12:08:04 +0200 Subject: [PATCH 2/2] fix(workflow-executor): return null when last step is done (completed run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit at(-1) picks the orchestrator's current step but must still return null when that step is already done — the run is complete and nothing to execute. Co-Authored-By: Claude Sonnet 4.6 --- .../src/adapters/run-to-available-step-mapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index 3e0f62a70e..02953a75f1 100644 --- a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -137,7 +137,7 @@ export default function toAvailableStepExecution( } const pending = run.workflowHistory.at(-1) ?? null; - if (!pending) return null; + if (!pending || pending.done) return null; const result = { runId: String(run.id),