Skip to content

Commit 1f8c281

Browse files
authored
fix(executor): preserve stop-after across pause and resume (#8776)
* fix(executor): preserve stop-after across pause and resume * fix(executor): retain stop targets while sibling branches pause
1 parent df34c67 commit 1f8c281

10 files changed

Lines changed: 426 additions & 8 deletions

File tree

‎.github/scripts/http-e2e.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,15 @@ case "$group" in
122122
# Self-hosted: hosted billing admits a run only through a Redis usage reservation.
123123
stop-after)
124124
export NEXT_PUBLIC_FORCE_HOSTED=false
125+
export CRON_SECRET=stop-after-e2e-local-cron-secret
125126
export INTERNAL_API_SECRET=stop-after-http-ci-local-secret-at-least-32-characters
126127
export DB_TX_TRIPWIRE=throw
127128
export NEXT_PUBLIC_CHAT_DISABLED=true
128129
start_app stop-after 3018 workflow record-http-status
129130
STOP_AFTER_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
130131
STOP_AFTER_E2E_DATABASE_URL="$DATABASE_URL" \
131132
STOP_AFTER_E2E_REPORT_PATH="$report_dir/stop-after-http-report.json" \
133+
STOP_AFTER_E2E_CRON_SECRET="$CRON_SECRET" \
132134
bun run test:workflow-stop-after:e2e
133135
;;
134136

‎apps/sim/executor/execution/engine.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,53 @@ describe('ExecutionEngine', () => {
12581258
expect(executed).toEqual(['start', 'condition', 'stop'])
12591259
})
12601260

1261+
it.each([
1262+
['approval', 'stop'],
1263+
['stop', 'approval'],
1264+
])(
1265+
'completes without retaining a sibling pause when %s finishes before %s',
1266+
async (first, second) => {
1267+
const { engine, executed } = buildRun(
1268+
{
1269+
version: '1',
1270+
blocks: [
1271+
block('start', BlockType.STARTER),
1272+
block('approval'),
1273+
block('stop'),
1274+
block('after'),
1275+
],
1276+
connections: [
1277+
{ source: 'start', target: first },
1278+
{ source: 'start', target: second },
1279+
{ source: 'stop', target: 'after' },
1280+
],
1281+
loops: {},
1282+
parallels: {},
1283+
},
1284+
'stop',
1285+
{
1286+
approval: {
1287+
_pauseMetadata: {
1288+
contextId: 'approval',
1289+
blockId: 'approval',
1290+
response: {},
1291+
timestamp: new Date().toISOString(),
1292+
pauseKind: 'human',
1293+
},
1294+
},
1295+
}
1296+
)
1297+
1298+
const result = await engine.run('start')
1299+
1300+
expect(result.success).toBe(true)
1301+
expect(result.status).not.toBe('paused')
1302+
expect(result.pausePoints).toBeUndefined()
1303+
expect(result.snapshotSeed).toBeUndefined()
1304+
expect(executed).toEqual(['start', first, second])
1305+
}
1306+
)
1307+
12611308
it('runs a join reached through one taken and one skipped branch', async () => {
12621309
const { engine, executed } = buildRun(
12631310
{

‎apps/sim/executor/execution/engine.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,22 @@ export class ExecutionEngine {
128128
throw this.executionError
129129
}
130130

131+
if (this.stopBlockReached) {
132+
this.pausedBlocks.clear()
133+
this.context.metadata.pausePoints = []
134+
this.context.metadata.status = 'completed'
135+
this.context.metadata.stopAfterBlockReached = true
136+
}
137+
131138
/** A pause keeps a run whose stop block can still run; one proven unreachable fails. */
132-
if (!this.cancelledFlag && (this.stopBlockUnreachable || this.pausedBlocks.size === 0)) {
139+
const waitingForPausedBranch =
140+
this.context.metadata.resumeFromSnapshot === true &&
141+
this.context.metadata.resumeHasPendingPauses === true &&
142+
!this.responseOutputLocked
143+
if (
144+
!this.cancelledFlag &&
145+
(this.stopBlockUnreachable || (this.pausedBlocks.size === 0 && !waitingForPausedBranch))
146+
) {
133147
this.assertStopBlockReached()
134148
}
135149

@@ -293,6 +307,19 @@ export class ExecutionEngine {
293307
}
294308

295309
private initializeQueue(triggerBlockId?: string): void {
310+
const stopBlockId = this.context.stopAfterBlockId
311+
if (
312+
this.context.metadata.resumeFromSnapshot &&
313+
this.context.metadata.stopAfterBlockReached &&
314+
stopBlockId
315+
) {
316+
this.stopBlockReached = true
317+
this.stoppedEarlyFlag = true
318+
const state = this.context.blockStates.get(stopBlockId)
319+
if (state) this.setFinalOutput(stopBlockId, state.output)
320+
return
321+
}
322+
296323
if (this.context.runFromBlockContext) {
297324
const { startBlockId } = this.context.runFromBlockContext
298325
this.execLogger.info('Initializing queue for run-from-block mode', {

‎apps/sim/executor/execution/snapshot-serializer.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ function buildExecutionSnapshot(
307307
: undefined,
308308
/** Preserve the run-level agent-events opt-in across HITL pause/resume. */
309309
agentEvents: metadataFromContext?.agentEvents === true ? true : undefined,
310+
stopAfterBlockId: metadataFromContext?.stopAfterBlockId,
310311
}
311312

312313
const snapshot = new ExecutionSnapshot(

‎apps/sim/executor/execution/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ export interface ExecutionMetadata {
5959
pendingBlocks?: string[]
6060
resumeFromSnapshot?: boolean
6161
resumeTerminalNoop?: boolean
62+
/** Other durable pauses remain after the current context resumes. */
63+
resumeHasPendingPauses?: boolean
64+
/** Original workflow block ID, before loop/parallel sentinel resolution. */
65+
stopAfterBlockId?: string
66+
/** A resumed pause completed the stop target before the engine starts. */
67+
stopAfterBlockReached?: boolean
6268
credentialAccountUserId?: string
6369
workflowStateOverride?: {
6470
blocks: Record<string, any>

‎apps/sim/executor/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ interface ExecutionMetadata {
383383
useDraftState?: boolean
384384
resumeFromSnapshot?: boolean
385385
resumeTerminalNoop?: boolean
386+
/** Other durable pauses remain after the current context resumes. */
387+
resumeHasPendingPauses?: boolean
388+
/** Stop-after completion retires outstanding pause contexts when settling a resume. */
389+
stopAfterBlockReached?: boolean
386390
executionMode?: 'sync' | 'stream' | 'async'
387391
/**
388392
* Run-level agent-events opt-in (see the snapshot ExecutionMetadata).

‎apps/sim/lib/workflows/executor/execution-core.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,6 @@ async function executeWorkflowCoreImpl(
631631
abortSignal,
632632
includeFileBase64,
633633
base64MaxBytes,
634-
stopAfterBlockId,
635634
runFromBlock,
636635
resumeDeploymentVersionId,
637636
draftState,
@@ -647,6 +646,8 @@ async function executeWorkflowCoreImpl(
647646
throw new Error(`Execution metadata missing workspaceId for workflow ${workflowId}`)
648647
}
649648
const resumeFromSnapshot = metadata.resumeFromSnapshot === true
649+
const stopAfterBlockId =
650+
options.stopAfterBlockId ?? (resumeFromSnapshot ? metadata.stopAfterBlockId : undefined)
650651
if (!resumeFromSnapshot && resumeDeploymentVersionId !== undefined) {
651652
throw new Error('Deployment version authority can only be supplied for a resumed execution')
652653
}
@@ -1236,7 +1237,14 @@ async function executeWorkflowCoreImpl(
12361237
...(workflowInputResolvedSecretTraceProvenance
12371238
? { workflowInputResolvedSecretTraceProvenance }
12381239
: {}),
1239-
metadata,
1240+
metadata: {
1241+
...metadata,
1242+
stopAfterBlockId,
1243+
stopAfterBlockReached:
1244+
resumeFromSnapshot &&
1245+
stopAfterBlockId !== undefined &&
1246+
snapshot.state?.completedPauseContexts?.includes(stopAfterBlockId) === true,
1247+
},
12401248
startRunMetadata,
12411249
abortSignal,
12421250
includeFileBase64,

‎apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,7 @@ export class PauseResumeManager {
993993
pausedExecutionId: pausedExecution.id,
994994
parentExecutionId: pausedExecution.executionId,
995995
contextId,
996+
stopAfterBlockReached: result.metadata?.stopAfterBlockReached === true,
996997
})
997998
}
998999
}
@@ -1478,6 +1479,9 @@ export class PauseResumeManager {
14781479
isClientSession: baseSnapshot.metadata.isClientSession,
14791480
resumeFromSnapshot: true,
14801481
resumeTerminalNoop: stateCopy?.resumeTerminalNoop === true,
1482+
resumeHasPendingPauses: Object.entries(pausePoints).some(
1483+
([id, point]) => id !== contextId && point.resumeStatus !== 'resumed'
1484+
),
14811485
}
14821486

14831487
const resumeSnapshot = new ExecutionSnapshot(
@@ -2095,6 +2099,7 @@ export class PauseResumeManager {
20952099
pausedExecutionId?: string
20962100
parentExecutionId?: string
20972101
contextId?: string
2102+
stopAfterBlockReached?: boolean
20982103
}): Promise<void> {
20992104
const { resumeEntryId, pausedExecutionId, parentExecutionId, contextId } = args
21002105
const now = new Date()
@@ -2178,6 +2183,45 @@ export class PauseResumeManager {
21782183

21792184
if (!pausedExecution || !contextId) return
21802185

2186+
if (args.stopAfterBlockReached) {
2187+
await tx
2188+
.update(pausedExecutions)
2189+
.set({
2190+
pausePoints: sql`(
2191+
SELECT COALESCE(jsonb_object_agg(point.key, point.value), '{}'::jsonb)
2192+
FROM jsonb_each(jsonb_set(
2193+
${updatePausePointResumeStateSql(contextId, 'resumed')},
2194+
ARRAY[${contextId}, 'resumedAt'],
2195+
${JSON.stringify(now.toISOString())}::jsonb
2196+
)) AS point
2197+
WHERE point.value->>'resumeStatus' = 'resumed'
2198+
)`,
2199+
totalPauseCount: sql`${pausedExecutions.resumedCount} + 1`,
2200+
resumedCount: sql`${pausedExecutions.resumedCount} + 1`,
2201+
status: 'fully_resumed',
2202+
nextResumeAt: null,
2203+
automaticResumeRetryCount: 0,
2204+
metadata: sql`${pausedExecutions.metadata} - 'automaticResumeWaiting'`,
2205+
updatedAt: now,
2206+
})
2207+
.where(eq(pausedExecutions.id, targetPausedExecutionId))
2208+
await tx
2209+
.update(resumeQueue)
2210+
.set({
2211+
status: 'failed',
2212+
completedAt: now,
2213+
failureReason: 'Execution stopped after its target block',
2214+
})
2215+
.where(
2216+
and(
2217+
eq(resumeQueue.parentExecutionId, targetParentExecutionId),
2218+
eq(resumeQueue.pausedExecutionId, targetPausedExecutionId),
2219+
eq(resumeQueue.status, 'pending')
2220+
)
2221+
)
2222+
return
2223+
}
2224+
21812225
await tx
21822226
.update(pausedExecutions)
21832227
.set({

0 commit comments

Comments
 (0)